diff --git a/.github/workflows/ci-ruby.yml b/.github/workflows/ci-ruby.yml index 30dcc3652cd4b..a231aae9d1ead 100644 --- a/.github/workflows/ci-ruby.yml +++ b/.github/workflows/ci-ruby.yml @@ -91,7 +91,7 @@ jobs: fail-fast: false matrix: os: [windows] - tag_filters: [chrome-beta, firefox-beta, edge-local] + tag_filters: [chrome-beta, firefox-beta, "edge-local,edge-bidi"] include: - os: macos tag_filters: safari-local,safari-preview-bidi diff --git a/rb/lib/selenium/webdriver/bidi/error.rb b/rb/lib/selenium/webdriver/bidi/error.rb index 9331efafd2d44..cb53231f0c3d1 100644 --- a/rb/lib/selenium/webdriver/bidi/error.rb +++ b/rb/lib/selenium/webdriver/bidi/error.rb @@ -23,6 +23,10 @@ module Selenium module WebDriver module Error + # Raised locally when a BiDi wire payload does not match this Selenium's generated + # schema. It is not a protocol error code; the (de)serialization layer raises it directly. + class SerializationError < WebDriverError; end + # Register each BiDi-only code as a WebDriverError subclass; shared codes keep their classic class. BiDi::Protocol::ErrorCode::CLASS_NAMES.each_value do |name| const_set(name, Class.new(WebDriverError)) unless const_defined?(name, false) diff --git a/rb/lib/selenium/webdriver/bidi/serialization.rb b/rb/lib/selenium/webdriver/bidi/serialization.rb index 9e3a05ab3ea65..0525550486a3b 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization.rb @@ -79,7 +79,7 @@ def self.to_symbol(name, value, enum) return value if value.nil? return value.map { |element| to_symbol(name, element, enum) } if value.is_a?(::Array) - enum.key(value) || raise(Error::WebDriverError, "#{name} received an unknown value: #{value.inspect}") + enum.key(value) || raise(Error::SerializationError, "#{name} received an unknown value: #{value.inspect}") end end end # BiDi diff --git a/rb/lib/selenium/webdriver/bidi/serialization/record.rb b/rb/lib/selenium/webdriver/bidi/serialization/record.rb index 679357dab7183..00a198c7b4f55 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization/record.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization/record.rb @@ -85,7 +85,7 @@ def new(**kwargs) # (extensible) or warned and dropped (closed) — strict on shape, lenient on extras. def from_json(json_payload) unless json_payload.is_a?(::Hash) - raise Error::WebDriverError, "#{name} expected an object on the wire, got #{json_payload.inspect}" + raise Error::SerializationError, "#{name} expected an object on the wire, got #{json_payload.inspect}" end attributes = fields.to_h do |f| @@ -228,7 +228,7 @@ def wire_value(field, json_payload) # which matters for the required-and-nullable fields the schema flags. def missing_required(field) message = "#{name}##{field.name} is required but was missing from the response" - raise Error::WebDriverError, message if Serialization.strict? + raise Error::SerializationError, message if Serialization.strict? WebDriver.logger.warn(message, id: :bidi_missing_required) UNSET @@ -238,7 +238,7 @@ def read(field, raw) if raw.nil? return raw if field.nullable - raise Error::WebDriverError, "#{name}##{field.name} received null but is not nullable" + raise Error::SerializationError, "#{name}##{field.name} received null but is not nullable" end check_shape(field, raw) return Serialization.to_symbol("#{name}##{field.name}", raw, enum_hash(field)) if field.enum @@ -268,7 +268,7 @@ def check_shape(field, raw) return if field.list == raw.is_a?(::Array) return unless field.list || field.enum || field.ref - raise Error::WebDriverError, + raise Error::SerializationError, "#{name}##{field.name} expected #{field.list ? 'a list' : 'a single value'}, got #{raw.inspect}" end @@ -285,7 +285,7 @@ def check_primitive(field, raw) expected = PRIMITIVE_TYPES[field.primitive] return if expected.nil? || expected.any? { |type| raw.is_a?(type) } - raise Error::WebDriverError, "#{name}##{field.name} expected #{field.primitive}, got #{raw.inspect}" + raise Error::SerializationError, "#{name}##{field.name} expected #{field.primitive}, got #{raw.inspect}" end def enum_hash(field) @@ -314,7 +314,7 @@ def read_list(field, raw, klass) # malformed entry and is rejected outright. def read_map_entry(field, element, klass) unless element.is_a?(::Array) && element.size == 2 - raise Error::WebDriverError, + raise Error::SerializationError, "#{name}##{field.name} expected a [key, value] pair, got #{element.inspect}" end @@ -332,7 +332,7 @@ def scalar_value(field, value) expected = Array(field.scalar).flat_map { |primitive| PRIMITIVE_TYPES[primitive] || [] } return value if expected.empty? || expected.any? { |type| value.is_a?(type) } - raise Error::WebDriverError, + raise Error::SerializationError, "#{name}##{field.name} expected #{Array(field.scalar).join(' or ')}, got #{value.inspect}" end diff --git a/rb/lib/selenium/webdriver/bidi/serialization/union.rb b/rb/lib/selenium/webdriver/bidi/serialization/union.rb index 551ff2addc699..7eb19754f9682 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization/union.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization/union.rb @@ -60,12 +60,12 @@ def from_json(json_payload) unless json_payload.is_a?(::Hash) return json_payload unless @object_only - raise Error::WebDriverError, "#{name} expected an object on the wire, got #{json_payload.inspect}" + raise Error::SerializationError, "#{name} expected an object on the wire, got #{json_payload.inspect}" end variant = select(json_payload) unless variant - raise Error::WebDriverError, + raise Error::SerializationError, "#{name} received a variant not in this Selenium's BiDi schema: #{json_payload.inspect}" end Protocol.const_get(variant).from_json(json_payload) diff --git a/rb/spec/BUILD.bazel b/rb/spec/BUILD.bazel index aa42929e07a1d..ebcd6ad90e93b 100644 --- a/rb/spec/BUILD.bazel +++ b/rb/spec/BUILD.bazel @@ -18,6 +18,7 @@ rb_library( "//rb/spec/integration:all_srcs", "//rb/spec/integration/selenium/webdriver:all_srcs", "//rb/spec/integration/selenium/webdriver/bidi:all_srcs", + "//rb/spec/integration/selenium/webdriver/bidi/protocol:all_srcs", "//rb/spec/integration/selenium/webdriver/chrome:all_srcs", "//rb/spec/integration/selenium/webdriver/edge:all_srcs", "//rb/spec/integration/selenium/webdriver/firefox:all_srcs", diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/BUILD.bazel b/rb/spec/integration/selenium/webdriver/bidi/protocol/BUILD.bazel new file mode 100644 index 0000000000000..b913d90f09cd1 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/BUILD.bazel @@ -0,0 +1,23 @@ +load("//rb/spec:tests.bzl", "rb_integration_test") + +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + +[ + rb_integration_test( + name = file[:-8], + srcs = [file], + bidi = True, + classic = False, + data = ["//common/extensions"], + tags = ["exclusive-if-local"], + deps = [ + "//rb/lib/selenium/webdriver:bidi", + ], + ) + for file in glob(["*_spec.rb"]) +] diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/bluetooth_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/bluetooth_spec.rb new file mode 100644 index 0000000000000..63a41aa41a400 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/bluetooth_spec.rb @@ -0,0 +1,578 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Bluetooth, + pending_if: {browser: :firefox, + exception: {class: Error::UnknownCommandError}, + reason: 'Firefox returns unknown command for BiDi Bluetooth simulation'}, + skip_if: {browser_family: :safari, + reason: 'Safari coverage tracked in safari_support_probe_spec.rb'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after do |example| + next if example.metadata[:skip] || example.skip + + begin + bluetooth.disable_simulation(context: driver.window_handle) + rescue StandardError + nil + end + reset_driver!(example: example) + end + + let(:bluetooth) { described_class.new(driver) } + let(:script) { Script.new(driver) } + let(:session) { Session.new(driver) } + + def target + Script::ContextTarget.new(context: driver.window_handle) + end + + def address + '00:11:22:33:44:55' + end + + def service_uuid + '0000180d-0000-1000-8000-00805f9b34fb' + end + + def characteristic_uuid + '00002a37-0000-1000-8000-00805f9b34fb' + end + + def descriptor_uuid + '00002902-0000-1000-8000-00805f9b34fb' + end + + def manufacturer_data + [Bluetooth::BluetoothManufacturerData.new(key: 1, data: 'AQID')] + end + + def scan_record + Bluetooth::ScanRecord.new( + name: 'Ruby Heart Rate', + uuids: [service_uuid], + appearance: 832, + manufacturer_data: manufacturer_data + ) + end + + def scan_entry + Bluetooth::SimulateAdvertisementScanEntryParameters.new( + device_address: address, + rssi: -60, + scan_record: scan_record + ) + end + + def enable_adapter + driver.navigate.to url_for('blank.html') + bluetooth.simulate_adapter(context: driver.window_handle, state: :powered_on, le_supported: true) + end + + def simulate_preconnected_device + enable_adapter + bluetooth.simulate_preconnected_peripheral( + context: driver.window_handle, + address: address, + name: 'Ruby Heart Rate', + manufacturer_data: manufacturer_data, + known_service_uuids: [service_uuid] + ) + end + + def subscribe(event) + events = [] + callback = driver.bidi.add_callback(event) { |params| events << params } + session.subscribe(events: [event]) + [events, callback] + rescue StandardError + driver.bidi.remove_callback(event, callback) if callback + raise + end + + def unsubscribe(event, callback) + begin + session.unsubscribe(events: [event]) + rescue StandardError + nil + end + ensure + driver.bidi.remove_callback(event, callback) if callback + end + + def evaluate_value(expression, await_promise: false, user_activation: WebDriver::BiDi::Serialization::UNSET) + result = script.evaluate( + expression: expression, + target: target, + await_promise: await_promise, + user_activation: user_activation + ) + if result.is_a?(Script::EvaluateResultException) + raise Error::WebDriverError, "script.evaluate raised: #{result.exception_details.text}" + end + + value = result.result + value.respond_to?(:value) ? value.value : nil + end + + def start_request_device + evaluate_value( + <<~JS, + (() => { + window.__rubyBluetoothDevicePromise = navigator.bluetooth.requestDevice({ + filters: [{services: [#{service_uuid.inspect}]}] + }).then(device => { + window.__rubyBluetoothDevice = device; + return device.name; + }).catch(error => `ERROR:${error.name}`); + return 'started'; + })() + JS + user_activation: true + ) + end + + def request_device_prompt(events) + wait.until do + events.find do |event| + Array(event['devices']).any? { |device| device['name'] == 'Ruby Heart Rate' } + end + end + end + + def advertised_device(prompt) + prompt['devices'].find { |device| device['name'] == 'Ruby Heart Rate' } + end + + def select_device + enable_adapter + events, callback = subscribe('bluetooth.requestDevicePromptUpdated') + start_request_device + + expect(bluetooth.simulate_advertisement( + context: driver.window_handle, + scan_entry: scan_entry + )).to be_empty + + prompt = request_device_prompt(events) + device = advertised_device(prompt) + expect(device['id']).to be_a(String) + + bluetooth.handle_request_device_prompt( + context: driver.window_handle, + prompt: prompt['prompt'], + accept: true, + device: device['id'] + ) + expect(evaluate_value('window.__rubyBluetoothDevicePromise', await_promise: true)).to eq('Ruby Heart Rate') + ensure + unsubscribe('bluetooth.requestDevicePromptUpdated', callback) if callback + end + + def start_gatt_connection + evaluate_value( + <<~JS + (() => { + window.__rubyBluetoothGattPromise = window.__rubyBluetoothDevice.gatt.connect() + .then(server => server.connected) + .catch(error => `ERROR:${error.name}`); + return 'started'; + })() + JS + ) + end + + def connect_selected_device + events, callback = subscribe('bluetooth.gattConnectionAttempted') + start_gatt_connection + attempt = wait.until { events.find { |event| event['address'] == address } } + + expect(attempt['context']).to eq(driver.window_handle) + expect(bluetooth.simulate_gatt_connection_response( + context: driver.window_handle, + address: address, + code: 0 + )).to be_empty + expect(evaluate_value('window.__rubyBluetoothGattPromise', await_promise: true)).to be(true) + ensure + unsubscribe('bluetooth.gattConnectionAttempted', callback) if callback + end + + def store_primary_service + evaluate_value( + <<~JS, + window.__rubyBluetoothDevice.gatt.getPrimaryService(#{service_uuid.inspect}) + .then(service => { + window.__rubyBluetoothService = service; + return service.uuid; + }) + JS + await_promise: true + ) + end + + def store_characteristic + evaluate_value( + <<~JS, + window.__rubyBluetoothService.getCharacteristic(#{characteristic_uuid.inspect}) + .then(characteristic => { + window.__rubyBluetoothCharacteristic = characteristic; + return characteristic.uuid; + }) + JS + await_promise: true + ) + end + + def store_descriptor + evaluate_value( + <<~JS, + window.__rubyBluetoothCharacteristic.getDescriptor(#{descriptor_uuid.inspect}) + .then(descriptor => { + window.__rubyBluetoothDescriptor = descriptor; + return descriptor.uuid; + }) + JS + await_promise: true + ) + end + + def add_service + bluetooth.simulate_service(context: driver.window_handle, address: address, uuid: service_uuid, type: :add) + end + + def add_characteristic + properties = Bluetooth::CharacteristicProperties.new(read: true, write: true, notify: true) + bluetooth.simulate_characteristic( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + type: :add, + characteristic_properties: properties + ) + end + + def add_descriptor + bluetooth.simulate_descriptor( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + descriptor_uuid: descriptor_uuid, + type: :add + ) + end + + describe '#simulate_adapter' do + it 'simulates a powered-on adapter' do + expect(enable_adapter).to be_empty + end + end + + describe '#disable_simulation' do + it 'disables active Bluetooth simulation' do + enable_adapter + + expect(bluetooth.disable_simulation(context: driver.window_handle)).to be_empty + end + end + + describe '#simulate_preconnected_peripheral' do + it 'simulates a preconnected peripheral' do + expect(simulate_preconnected_device).to be_empty + end + end + + describe '#simulate_service' do + it 'adds and removes a simulated service' do + simulate_preconnected_device + + expect(bluetooth.simulate_service( + context: driver.window_handle, + address: address, + uuid: service_uuid, + type: :add + )).to be_empty + expect(bluetooth.simulate_service( + context: driver.window_handle, + address: address, + uuid: service_uuid, + type: :remove + )).to be_empty + end + end + + describe '#simulate_characteristic' do + it 'adds and removes a characteristic with properties' do + simulate_preconnected_device + add_service + properties = Bluetooth::CharacteristicProperties.new(read: true, write: true, notify: true) + + expect(bluetooth.simulate_characteristic( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + type: :add, + characteristic_properties: properties + )).to be_empty + expect(bluetooth.simulate_characteristic( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + type: :remove + )).to be_empty + end + end + + describe '#simulate_descriptor' do + it 'adds and removes a descriptor' do + simulate_preconnected_device + add_service + add_characteristic + + expect(bluetooth.simulate_descriptor( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + descriptor_uuid: descriptor_uuid, + type: :add + )).to be_empty + expect(bluetooth.simulate_descriptor( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + descriptor_uuid: descriptor_uuid, + type: :remove + )).to be_empty + end + end + + # These commands can only be exercised by driving the Web Bluetooth JS chooser, which never + # completes on Chromium in CI (navigator.bluetooth undefined on Linux, times out elsewhere). + context 'when driving the Web Bluetooth chooser (device-response commands)', + skip_if: {browser_family: :chromium, + reason: 'chromium bluetooth device-response: undefined on Linux, times out elsewhere'} do + describe '#handle_request_device_prompt' do + it 'accepts a prompt' do + select_device + + expect(evaluate_value('window.__rubyBluetoothDevice.name')).to eq('Ruby Heart Rate') + end + + it 'cancels a prompt' do + enable_adapter + events, callback = subscribe('bluetooth.requestDevicePromptUpdated') + start_request_device + + expect(bluetooth.simulate_advertisement( + context: driver.window_handle, + scan_entry: scan_entry + )).to be_empty + + prompt = request_device_prompt(events) + bluetooth.handle_request_device_prompt( + context: driver.window_handle, + prompt: prompt['prompt'], + accept: false + ) + expect(evaluate_value('window.__rubyBluetoothDevicePromise', await_promise: true)) + .to start_with('ERROR:') + ensure + unsubscribe('bluetooth.requestDevicePromptUpdated', callback) if callback + end + end + + describe '#simulate_advertisement' do + it 'simulates an advertisement scan entry' do + enable_adapter + events, callback = subscribe('bluetooth.requestDevicePromptUpdated') + start_request_device + + expect(bluetooth.simulate_advertisement( + context: driver.window_handle, + scan_entry: scan_entry + )).to be_empty + + prompt = request_device_prompt(events) + expect(advertised_device(prompt)['id']).to be_a(String) + ensure + unsubscribe('bluetooth.requestDevicePromptUpdated', callback) if callback + end + end + + describe '#simulate_gatt_connection_response' do + it 'simulates a successful GATT connection response' do + select_device + + connect_selected_device + expect(evaluate_value('window.__rubyBluetoothDevice.gatt.connected')).to be(true) + end + end + + describe '#simulate_gatt_disconnection' do + it 'simulates a GATT disconnection' do + select_device + connect_selected_device + + expect(bluetooth.simulate_gatt_disconnection( + context: driver.window_handle, + address: address + )).to be_empty + wait.until { evaluate_value('window.__rubyBluetoothDevice.gatt.connected') == false } + end + end + + describe '#simulate_characteristic_response' do + it 'simulates characteristic read and write responses' do + select_device + add_service + add_characteristic + connect_selected_device + store_primary_service + store_characteristic + + events, callback = subscribe('bluetooth.characteristicEventGenerated') + evaluate_value( + <<~JS + (() => { + window.__rubyBluetoothReadPromise = window.__rubyBluetoothCharacteristic.readValue() + .then(value => Array.from(new Uint8Array(value.buffer)).join(',')) + .catch(error => `ERROR:${error.name}`); + return 'started'; + })() + JS + ) + wait.until { events.find { |event| event['type'] == 'read' } } + expect(bluetooth.simulate_characteristic_response( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + type: :read, + code: 0, + data: [1, 2, 3] + )).to be_empty + expect(evaluate_value('window.__rubyBluetoothReadPromise', await_promise: true)).to eq('1,2,3') + + evaluate_value( + <<~JS + (() => { + window.__rubyBluetoothWritePromise = window.__rubyBluetoothCharacteristic + .writeValueWithResponse(new Uint8Array([4, 5])) + .then(() => 'written') + .catch(error => `ERROR:${error.name}`); + return 'started'; + })() + JS + ) + wait.until { events.find { |event| event['type'] == 'write-with-response' } } + expect(bluetooth.simulate_characteristic_response( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + type: :write, + code: 0 + )).to be_empty + expect(evaluate_value('window.__rubyBluetoothWritePromise', await_promise: true)).to eq('written') + ensure + unsubscribe('bluetooth.characteristicEventGenerated', callback) if callback + end + end + + describe '#simulate_descriptor_response' do + it 'simulates descriptor read and write responses' do + select_device + add_service + add_characteristic + add_descriptor + connect_selected_device + store_primary_service + store_characteristic + store_descriptor + + events, callback = subscribe('bluetooth.descriptorEventGenerated') + evaluate_value( + <<~JS + (() => { + window.__rubyBluetoothDescriptorReadPromise = window.__rubyBluetoothDescriptor.readValue() + .then(value => Array.from(new Uint8Array(value.buffer)).join(',')) + .catch(error => `ERROR:${error.name}`); + return 'started'; + })() + JS + ) + wait.until { events.find { |event| event['type'] == 'read' } } + expect(bluetooth.simulate_descriptor_response( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + descriptor_uuid: descriptor_uuid, + type: :read, + code: 0, + data: [1, 2] + )).to be_empty + expect(evaluate_value('window.__rubyBluetoothDescriptorReadPromise', await_promise: true)).to eq('1,2') + + evaluate_value( + <<~JS + (() => { + window.__rubyBluetoothDescriptorWritePromise = window.__rubyBluetoothDescriptor + .writeValue(new Uint8Array([6, 7])) + .then(() => 'written') + .catch(error => `ERROR:${error.name}`); + return 'started'; + })() + JS + ) + wait.until { events.find { |event| event['type'] == 'write' } } + expect(bluetooth.simulate_descriptor_response( + context: driver.window_handle, + address: address, + service_uuid: service_uuid, + characteristic_uuid: characteristic_uuid, + descriptor_uuid: descriptor_uuid, + type: :write, + code: 0 + )).to be_empty + expect(evaluate_value('window.__rubyBluetoothDescriptorWritePromise', + await_promise: true)).to eq('written') + ensure + unsubscribe('bluetooth.descriptorEventGenerated', callback) if callback + end + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/browser_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/browser_spec.rb new file mode 100644 index 0000000000000..3fe71856f3d92 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/browser_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' +require 'tmpdir' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Browser, + skip_if: {browser_family: :safari, + reason: 'Safari coverage tracked in safari_support_probe_spec.rb'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:browser) { described_class.new(driver) } + + def create_user_context + browser.create_user_context.user_context + end + + context 'with user contexts and client windows' do + describe '#create_user_context' do + it 'returns the created user context id' do + user_context = create_user_context + + expect(user_context).to be_a(String) + expect(browser.get_user_contexts.user_contexts.map(&:user_context)).to include(user_context) + ensure + browser.remove_user_context(user_context: user_context) if user_context + end + + it 'accepts optional proxy and prompt behavior parameters' do + user_context = browser.create_user_context( + accept_insecure_certs: true, + proxy: Session::DirectProxyConfiguration.new, + unhandled_prompt_behavior: Session::UserPromptHandler.new(default: :dismiss) + ).user_context + + expect(user_context).to be_a(String) + ensure + browser.remove_user_context(user_context: user_context) if user_context + end + end + + describe '#get_client_windows' do + it 'returns typed client window information' do + windows = browser.get_client_windows.client_windows + + expect(windows).not_to be_empty + expect(windows.first).to be_a(Browser::ClientWindowInfo) + expect(windows.first.client_window).to be_a(String) + expect(windows.first.active).to be(true).or be(false) + expect(windows.first.width).to be_positive + expect(windows.first.height).to be_positive + expect(%i[fullscreen maximized minimized normal].include?(windows.first.state)).to be(true) + end + end + + describe '#get_user_contexts' do + it 'includes newly created user contexts' do + user_contexts = Array.new(2) { create_user_context } + all_ids = browser.get_user_contexts.user_contexts.map(&:user_context) + + expect(all_ids).to include(*user_contexts) + ensure + user_contexts&.each { |id| browser.remove_user_context(user_context: id) } + end + end + + describe '#remove_user_context' do + it 'removes the requested user context' do + user_context = create_user_context + + browser.remove_user_context(user_context: user_context) + + expect(browser.get_user_contexts.user_contexts.map(&:user_context)).not_to include(user_context) + end + end + + describe '#set_client_window_state' do + it 'sets a client window to a normal rectangle' do + client_window = browser.get_client_windows.client_windows.first.client_window + + result = browser.set_client_window_state( + client_window: client_window, + state: :normal, + width: 640, + height: 480, + x: 10, + y: 10 + ) + + expect(result).to be_a(Browser::ClientWindowInfo) + expect(result.client_window).to eq(client_window) + expect(result.state).to eq(:normal) + expect(result.width).to be >= 320 + expect(result.height).to be >= 240 + end + end + + describe '#set_download_behavior' do + it 'allows downloads into a requested folder', + skip_if: {browser: %i[chrome edge firefox], platform: :windows, + reason: 'Times out waiting for the download to complete'} do + Dir.mktmpdir('selenium-bidi-downloads') do |directory| + behavior = Browser::DownloadBehavior::Allowed.new(destination_folder: directory) + browser.set_download_behavior(download_behavior: behavior) + + driver.navigate.to url_for('downloads/download.html') + driver.find_element(id: 'file-1').click + + wait.until { Dir.children(directory).any? { |file| file.start_with?('file_1') } } + expect(Dir.children(directory)).to include(a_string_matching(/^file_1.*\.txt$/)) + ensure + browser.set_download_behavior(download_behavior: nil) + end + end + + it 'accepts a user-context scoped download behavior' do + user_context = create_user_context + behavior = Browser::DownloadBehavior::Denied.new + + expect(browser.set_download_behavior(download_behavior: behavior, + user_contexts: [user_context])).to be_empty + ensure + browser.set_download_behavior(download_behavior: nil, user_contexts: [user_context]) if user_context + browser.remove_user_context(user_context: user_context) if user_context + end + end + end + + describe '#close', + pending_if: {browser: :firefox, + exception: {class: Error::UnsupportedOperationError, + message: /Closing the browser in a session /}, + reason: 'Firefox unsupported operation for browser.close on Classic sessions'} do + it 'closes the browser session' do + driver.navigate.to url_for('blank.html') + + expect(browser.close).to be_empty + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/browsing_context_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/browsing_context_spec.rb new file mode 100644 index 0000000000000..14156b058d946 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/browsing_context_spec.rb @@ -0,0 +1,394 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe BrowsingContext, skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:browsing_context) { described_class.new(driver) } + + def script_target(context = driver.window_handle, sandbox: nil) + kwargs = {context: context} + kwargs[:sandbox] = sandbox if sandbox + Script::ContextTarget.new(**kwargs) + end + + def evaluate(expression, context = driver.window_handle) + Script.new(driver).evaluate(expression: expression, target: script_target(context), await_promise: false) + end + + describe '#activate' do + it 'activates an existing browsing context' do + context = browsing_context.create(type: :tab, background: true).context + + expect(browsing_context.activate(context: context)).to be_empty + expect(driver.window_handles).to include(context) + end + end + + describe '#capture_screenshot', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari does not implement browsingContext.captureScreenshot'} do + it 'returns base64 PNG screenshot data' do + browsing_context.navigate(context: driver.window_handle, url: url_for('blank.html'), wait: :complete) + + result = browsing_context.capture_screenshot(context: driver.window_handle) + + expect(result).to be_a(BrowsingContext::CaptureScreenshotResult) + expect(result.data).to start_with('iVBOR') + end + + it 'accepts viewport origin, image format, and box clip parameters' do + browsing_context.navigate(context: driver.window_handle, url: url_for('blank.html'), wait: :complete) + + result = browsing_context.capture_screenshot( + context: driver.window_handle, + origin: :viewport, + format: BrowsingContext::ImageFormat.new(type: 'image/png'), + clip: BrowsingContext::BoxClipRectangle.new(x: 0, y: 0, width: 100, height: 100) + ) + + expect(result.data).to start_with('iVBOR') + end + end + + describe '#close' do + it 'closes a created tab' do + context = browsing_context.create(type: :tab).context + + browsing_context.close(context: context) + + expect(driver.window_handles).not_to include(context) + end + + it 'accepts the prompt unload option' do + context = browsing_context.create(type: :tab).context + + expect(browsing_context.close(context: context, prompt_unload: false)).to be_empty + expect(driver.window_handles).not_to include(context) + end + end + + describe '#create' do + it 'creates a tab context' do + result = browsing_context.create(type: :tab) + + expect(result).to be_a(BrowsingContext::CreateResult) + expect(driver.window_handles).to include(result.context) + end + + it 'creates a window context' do + result = browsing_context.create(type: :window) + + expect(result.context).to be_a(String) + expect(driver.window_handles).to include(result.context) + end + + it 'accepts reference, background, and user context parameters', + pending_if: {browser_family: :safari, + reason: 'Safari create_user_context result fails strict deserialization'} do + user_context = Browser.new(driver).create_user_context.user_context + + result = browsing_context.create( + type: :tab, + reference_context: driver.window_handle, + background: true, + user_context: user_context + ) + + expect(driver.window_handles).to include(result.context) + expect(browsing_context.get_tree(root: result.context).contexts.first.user_context).to eq(user_context) + ensure + browsing_context.close(context: result.context) if result&.context + Browser.new(driver).remove_user_context(user_context: user_context) if user_context + end + end + + describe '#get_tree' do + it 'returns the current context tree' do + browsing_context.navigate(context: driver.window_handle, url: url_for('iframes.html'), wait: :complete) + + result = browsing_context.get_tree(root: driver.window_handle) + + expect(result.contexts).to contain_exactly(be_a(BrowsingContext::Info)) + expect(result.contexts.first.context).to eq(driver.window_handle) + expect(result.contexts.first.url).to include('iframes.html') + expect(result.contexts.first.children).not_to be_empty + end + + it 'accepts max depth and root parameters' do + browsing_context.navigate(context: driver.window_handle, url: url_for('iframes.html'), wait: :complete) + + result = browsing_context.get_tree(max_depth: 0, root: driver.window_handle) + + expect(result.contexts.size).to eq(1) + expect(result.contexts.first.context).to eq(driver.window_handle) + expect(result.contexts.first.children).to be_nil.or be_empty + end + end + + describe '#handle_user_prompt' do + it 'returns a no such alert error when no prompt is open' do + expect { + browsing_context.handle_user_prompt(context: driver.window_handle, accept: true) + }.to raise_error(Error::NoSuchAlertError) + end + + context 'when a prompt is open', + pending_if: {browser_family: :chromium, + reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do + it 'accepts user prompts without text' do + driver.navigate.to url_for('alerts.html') + driver.find_element(id: 'alert').click + wait_for_alert + browsing_context.handle_user_prompt(context: driver.window_handle, accept: true) + wait_for_no_alert + + expect(driver.title).to eq('Testing Alerts') + end + + it 'accepts user prompts with text' do + driver.navigate.to url_for('alerts.html') + driver.find_element(id: 'prompt').click + wait_for_alert + browsing_context.handle_user_prompt( + context: driver.window_handle, + accept: true, + user_text: 'Hello, world!' + ) + wait_for_no_alert + + expect(driver.title).to eq('Testing Alerts') + end + + it 'rejects user prompts' do + driver.navigate.to url_for('alerts.html') + driver.find_element(id: 'alert').click + wait_for_alert + browsing_context.handle_user_prompt(context: driver.window_handle, accept: false) + wait_for_no_alert + + expect(driver.title).to eq('Testing Alerts') + end + end + end + + describe '#locate_nodes', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari does not implement browsingContext.locateNodes'} do + it 'finds nodes by CSS selector' do + browsing_context.navigate(context: driver.window_handle, url: url_for('xhtmlTest.html'), wait: :complete) + + result = browsing_context.locate_nodes( + context: driver.window_handle, + locator: BrowsingContext::CssLocator.new(value: 'div.content'), + max_node_count: 1 + ) + + expect(result.nodes).to contain_exactly(be_a(Script::NodeRemoteValue)) + expect(result.nodes.first.value.local_name).to eq('div') + expect(result.nodes.first.value.attributes).to include('class' => 'content') + expect(result.nodes.first.shared_id).to be_a(String) + end + + it 'accepts serialization options and start nodes' do + browsing_context.navigate(context: driver.window_handle, url: url_for('formPage.html'), wait: :complete) + forms = browsing_context.locate_nodes( + context: driver.window_handle, + locator: BrowsingContext::CssLocator.new(value: 'form'), + max_node_count: 1 + ) + start_node = Script::SharedReference.new(shared_id: forms.nodes.first.shared_id) + + result = browsing_context.locate_nodes( + context: driver.window_handle, + locator: BrowsingContext::CssLocator.new(value: 'input'), + max_node_count: 3, + serialization_options: Script::SerializationOptions.new(max_dom_depth: 0), + start_nodes: [start_node] + ) + + expect(result.nodes.size).to eq(3) + expect(result.nodes.map { |node| node.value.local_name }.uniq).to eq(['input']) + end + end + + describe '#navigate' do + it 'navigates a context to a URL' do + result = browsing_context.navigate( + context: driver.window_handle, + url: url_for('formPage.html'), + wait: :complete + ) + + expect(result).to be_a(BrowsingContext::NavigateResult) + expect(result.url).to eq(url_for('formPage.html')) + expect(driver.find_element(name: 'login')).to be_displayed + end + end + + describe '#print', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari does not implement browsingContext.print'} do + it 'returns base64 PDF data' do + browsing_context.navigate(context: driver.window_handle, url: url_for('printPage.html'), wait: :complete) + + result = browsing_context.print(context: driver.window_handle) + + expect(result).to be_a(BrowsingContext::PrintResult) + expect(result.data).to start_with('JVBER') + end + + it 'accepts page layout parameters' do + browsing_context.navigate(context: driver.window_handle, url: url_for('printPage.html'), wait: :complete) + + result = browsing_context.print( + context: driver.window_handle, + background: true, + margin: BrowsingContext::PrintMarginParameters.new(top: 0.5, bottom: 0.5, left: 0.5, right: 0.5), + orientation: :landscape, + page: BrowsingContext::PrintPageParameters.new(width: 8.5, height: 11), + page_ranges: ['1'], + scale: 1.0, + shrink_to_fit: true + ) + + expect(result.data).to start_with('JVBER') + end + end + + describe '#reload' do + it 'reloads a context', + pending_if: {browser: :firefox, + exception: {class: Error::UnsupportedOperationError, + message: /Argument "ignoreCache" /}, + reason: 'Firefox lacks browsingContext.reload ignoreCache (bugzilla 1851561)'} do + browsing_context.navigate(context: driver.window_handle, url: url_for('formPage.html'), wait: :complete) + + result = browsing_context.reload(context: driver.window_handle, ignore_cache: true, wait: :complete) + + expect(result.url).to eq(url_for('formPage.html')) + expect(driver.find_element(name: 'login')).to be_displayed + end + end + + describe '#set_bypass_csp', + pending_if: [{browser: :chrome, + exception: {class: Error::UnsupportedOperationError, + message: /browsingContext\.setBypassCSP/}, + reason: 'Chrome returns unsupported operation for browsingContext.setBypassCSP'}, + {browser: %i[edge firefox], + exception: {class: Error::UnknownCommandError}, + reason: 'Edge and Firefox return unknown command for browsingContext.setBypassCSP'}, + {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari does not implement browsingContext.setBypassCSP'}] do + it 'sets and clears CSP bypass for a context' do + expect(browsing_context.set_bypass_csp(bypass: true, contexts: [driver.window_handle])).to be_empty + expect(browsing_context.set_bypass_csp(bypass: nil, contexts: [driver.window_handle])).to be_empty + end + end + + describe '#set_viewport' do + it 'sets the viewport size and device pixel ratio', + pending_if: {browser_family: :safari, + exception: {class: RSpec::Expectations::ExpectationNotMetError}, + reason: 'Safari accepts browsingContext.setViewport but does not resize the window'} do + browsing_context.set_viewport( + context: driver.window_handle, + viewport: BrowsingContext::Viewport.new(width: 800, height: 600), + device_pixel_ratio: 2.0 + ) + + expect(evaluate('[window.innerWidth, window.innerHeight]').result.value.map(&:value)).to eq([800, 600]) + end + + it 'clears the viewport override' do + browsing_context.set_viewport( + context: driver.window_handle, + viewport: BrowsingContext::Viewport.new(width: 640, height: 480) + ) + + expect(browsing_context.set_viewport(context: driver.window_handle, viewport: nil)).to be_empty + end + end + + describe '#start_screencast', + pending_if: [{browser: :chrome, + exception: {class: Error::UnsupportedOperationError, + message: /browsingContext\.startScreencast/}, + reason: 'Chrome returns unsupported operation for browsingContext.startScreencast'}, + {browser: :edge, + exception: {class: Error::UnknownCommandError}, + reason: 'Edge returns unknown command for browsingContext.startScreencast'}, + {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari does not implement browsingContext.startScreencast'}, + {browser: :firefox, platform: :linux, + exception: {class: Error::UnknownError, message: /startScreencast/}, + reason: 'Firefox startScreencast fails with NS_ERROR_FAILURE on Linux'}] do + it 'starts and stops a screencast' do + result = browsing_context.start_screencast( + context: driver.window_handle, + mime_type: 'video/webm', + video: BrowsingContext::MediaTrackConstraints.new(width: 320, height: 240, frame_rate: 5), + audio: false + ) + + expect(result.screencast).to be_a(String) + expect(browsing_context.stop_screencast(screencast: result.screencast)).to be_a( + BrowsingContext::StopScreencastResult + ) + end + end + + describe '#traverse_history', + skip_if: {browser_family: :safari, + reason: 'Times out: browsingContext.traverseHistory hangs on Safari'} do + it 'moves backward and forward in the context history' do + browsing_context.navigate(context: driver.window_handle, url: url_for('blank.html'), wait: :complete) + browsing_context.navigate( + context: driver.window_handle, + url: url_for('bidi/logEntryAdded.html'), + wait: :complete + ) + + browsing_context.traverse_history(context: driver.window_handle, delta: -1) + wait_for_url('blank.html') + expect(driver.current_url).to include('blank.html') + + browsing_context.traverse_history(context: driver.window_handle, delta: 1) + wait_for_url('bidi/logEntryAdded.html') + expect(driver.current_url).to include('bidi/logEntryAdded.html') + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/emulation_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/emulation_spec.rb new file mode 100644 index 0000000000000..aba422a254dc3 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/emulation_spec.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Emulation, + skip_if: {browser_family: :safari, + reason: 'Safari coverage tracked in safari_support_probe_spec.rb'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:emulation) { described_class.new(driver) } + let(:script) { Script.new(driver) } + let(:browsing_context) { BrowsingContext.new(driver) } + + def target(context) + Script::ContextTarget.new(context: context) + end + + def evaluate_value(expression, context = driver.window_handle) + result = script.evaluate(expression: expression, target: target(context), await_promise: false).result + result.respond_to?(:value) ? result.value : nil + end + + def create_context + browsing_context.create(type: :tab).context + end + + describe '#set_forced_colors_mode_theme_override', + pending_if: [{browser_family: :chromium, + exception: {class: Error::UnsupportedOperationError, + message: /emulation\.setForcedColorsModeThemeOverride/}, + reason: 'Chromium unsupported operation: emulation.setForcedColorsModeThemeOverride'}, + {browser: :firefox, + exception: {class: Error::UnknownCommandError}, + reason: 'Firefox unknown command: emulation.setForcedColorsModeThemeOverride'}] do + it 'sets and clears forced-colors theme override' do + expect(emulation.set_forced_colors_mode_theme_override( + theme: :dark, + contexts: [driver.window_handle] + )).to be_empty + expect(emulation.set_forced_colors_mode_theme_override( + theme: nil, + contexts: [driver.window_handle] + )).to be_empty + end + end + + describe '#set_geolocation_override' do + it 'sets and clears geolocation coordinates' do + coordinates = Emulation::GeolocationCoordinates.new(latitude: 37, longitude: -122, accuracy: 10) + + expect(emulation.set_geolocation_override( + coordinates: coordinates, + contexts: [driver.window_handle] + )).to be_empty + expect(emulation.set_geolocation_override( + coordinates: nil, + contexts: [driver.window_handle] + )).to be_empty + end + + it 'sets a geolocation error override', + pending_if: {browser: :firefox, + exception: {class: Error::InvalidArgumentError, + message: /coordinates/}, + reason: 'Firefox does not support the geolocation error override'} do + expect(emulation.set_geolocation_override( + error: Emulation::GeolocationPositionError.new, + contexts: [driver.window_handle] + )).to be_empty + ensure + begin + emulation.set_geolocation_override(coordinates: nil, contexts: [driver.window_handle]) + rescue StandardError + nil + end + end + end + + describe '#set_locale_override' do + it 'overrides locale for a fresh context' do + context = create_context + + emulation.set_locale_override(locale: 'fr-FR', contexts: [context]) + browsing_context.navigate(context: context, url: url_for('blank.html'), wait: :complete) + + expect(evaluate_value('Intl.DateTimeFormat().resolvedOptions().locale', context)).to start_with('fr') + ensure + emulation.set_locale_override(locale: nil, contexts: [context]) if context + browsing_context.close(context: context) if context + end + end + + describe '#set_network_conditions' do + it 'sets and clears offline network conditions' do + expect(emulation.set_network_conditions( + network_conditions: Emulation::NetworkConditionsOffline.new, + contexts: [driver.window_handle] + )).to be_empty + expect(emulation.set_network_conditions( + network_conditions: nil, + contexts: [driver.window_handle] + )).to be_empty + end + end + + describe '#set_screen_orientation_override' do + it 'sets and clears screen orientation' do + orientation = Emulation::ScreenOrientation.new(natural: :portrait, type: :portrait_primary) + + expect(emulation.set_screen_orientation_override( + screen_orientation: orientation, + contexts: [driver.window_handle] + )).to be_empty + expect(emulation.set_screen_orientation_override( + screen_orientation: nil, + contexts: [driver.window_handle] + )).to be_empty + end + end + + describe '#set_screen_settings_override' do + it 'sets and clears screen area' do + expect(emulation.set_screen_settings_override( + screen_area: Emulation::ScreenArea.new(width: 800, height: 600), + contexts: [driver.window_handle] + )).to be_empty + expect(emulation.set_screen_settings_override( + screen_area: nil, + contexts: [driver.window_handle] + )).to be_empty + end + end + + describe '#set_scripting_enabled', + pending_if: {browser: :firefox, + exception: {class: Error::UnknownCommandError}, + reason: 'Firefox returns unknown command for emulation.setScriptingEnabled'} do + it 'disables and restores page scripting' do + context = driver.window_handle + + emulation.set_scripting_enabled(enabled: false, contexts: [context]) + browsing_context.navigate( + context: context, + url: "data:text/html,", + wait: :complete + ) + expect(evaluate_value('window.hello', context)).to be_nil + + emulation.set_scripting_enabled(enabled: nil, contexts: [context]) + browsing_context.navigate( + context: context, + url: "data:text/html,", + wait: :complete + ) + expect(evaluate_value('window.hello', context)).to eq('World') + ensure + begin + emulation.set_scripting_enabled(enabled: nil, contexts: [driver.window_handle]) + rescue StandardError + nil + end + end + end + + describe '#set_scrollbar_type_override', + pending_if: {browser: %i[edge firefox], + exception: {class: Error::UnknownCommandError}, + reason: 'Edge and Firefox return unknown command for setScrollbarTypeOverride'} do + it 'sets and clears scrollbar type override' do + expect(emulation.set_scrollbar_type_override( + scrollbar_type: :classic, + contexts: [driver.window_handle] + )).to be_empty + expect(emulation.set_scrollbar_type_override( + scrollbar_type: nil, + contexts: [driver.window_handle] + )).to be_empty + end + end + + describe '#set_timezone_override' do + it 'overrides timezone for a fresh context' do + context = create_context + + emulation.set_timezone_override(timezone: 'UTC', contexts: [context]) + browsing_context.navigate(context: context, url: url_for('blank.html'), wait: :complete) + + expect(evaluate_value('Intl.DateTimeFormat().resolvedOptions().timeZone', context)).to eq('UTC') + ensure + emulation.set_timezone_override(timezone: nil, contexts: [context]) if context + browsing_context.close(context: context) if context + end + end + + describe '#set_touch_override', + pending_if: {browser: :firefox, + exception: {class: Error::UnknownCommandError}, + reason: 'Firefox returns unknown command for emulation.setTouchOverride'} do + it 'sets and clears touch support' do + context = driver.window_handle + + emulation.set_touch_override(max_touch_points: 5, contexts: [context]) + expect(evaluate_value('navigator.maxTouchPoints', context)).to eq(5) + + expect(emulation.set_touch_override(max_touch_points: nil, contexts: [context])).to be_empty + end + end + + describe '#set_user_agent_override' do + it 'overrides and clears the user agent' do + context = driver.window_handle + custom_user_agent = 'Ruby BiDi UA/1.0' + + emulation.set_user_agent_override(user_agent: custom_user_agent, contexts: [context]) + browsing_context.navigate(context: context, url: url_for('blank.html'), wait: :complete) + expect(evaluate_value('navigator.userAgent', context)).to eq(custom_user_agent) + + emulation.set_user_agent_override(user_agent: nil, contexts: [context]) + browsing_context.navigate(context: context, url: url_for('blank.html'), wait: :complete) + expect(evaluate_value('navigator.userAgent', context)).not_to eq(custom_user_agent) + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/input_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/input_spec.rb new file mode 100644 index 0000000000000..08c38cb3ce27f --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/input_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Input, + pending_if: {browser_family: :safari, + reason: 'Safari script.evaluate result fails deserialization in input setup'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:input) { described_class.new(driver) } + let(:script) { Script.new(driver) } + + def target + Script::ContextTarget.new(context: driver.window_handle) + end + + def shared_reference(selector) + result = script.evaluate( + expression: "document.querySelector(#{selector.inspect})", + target: target, + await_promise: false, + result_ownership: :root + ) + Script::SharedReference.new(shared_id: result.result.shared_id, handle: result.result.handle) + end + + describe '#perform_actions' do + it 'clicks an element with pointer actions' do + driver.navigate.to url_for('javascriptPage.html') + element = shared_reference('#clickField') + + input.perform_actions( + context: driver.window_handle, + actions: [ + Input::PointerSourceActions.new( + id: 'mouse', + parameters: Input::PointerParameters.new(pointer_type: :mouse), + actions: [ + Input::PointerMoveAction.new( + x: 0, + y: 0, + origin: Input::ElementOrigin.new(element: element) + ), + Input::PointerDownAction.new(button: 0), + Input::PointerUpAction.new(button: 0) + ] + ) + ] + ) + + expect(driver.find_element(id: 'clickField').attribute('value')).to eq('Clicked') + end + end + + describe '#release_actions' do + it 'releases active input sources' do + driver.navigate.to url_for('javascriptPage.html') + element = shared_reference('#clickField') + + input.perform_actions( + context: driver.window_handle, + actions: [ + Input::PointerSourceActions.new( + id: 'mouse', + parameters: Input::PointerParameters.new(pointer_type: :mouse), + actions: [ + Input::PointerMoveAction.new(x: 0, y: 0, origin: Input::ElementOrigin.new(element: element)), + Input::PointerDownAction.new(button: 0) + ] + ) + ] + ) + + expect(input.release_actions(context: driver.window_handle)).to be_empty + end + end + + describe '#set_files', + pending_if: {browser: :firefox, platform: :windows, + exception: {class: Error::UnsupportedOperationError, + message: /(?:Unrecognized path|Failed to add file)/}, + reason: 'Firefox rejects the Windows temp file path for input.setFiles'} do + it 'sets files on a file input element' do + file = create_tempfile + driver.navigate.to url_for('upload.html') + element = shared_reference('#upload') + + expect(driver.find_element(id: 'upload').attribute('value')).to be_empty + + input.set_files(context: driver.window_handle, element: element, files: [file.path]) + + expect(driver.find_element(id: 'upload').attribute('value')).not_to be_empty + driver.find_element(id: 'go').click + wait.until { driver.find_element(id: 'upload_label').displayed? } + driver.switch_to.frame('upload_target') + expect(driver.find_element(tag_name: 'body').text).to include('This is a dummy test file') + ensure + file&.close + file&.unlink + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/network_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/network_spec.rb new file mode 100644 index 0000000000000..04695e714bac0 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/network_spec.rb @@ -0,0 +1,428 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Network, skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:network) { described_class.new(driver) } + let(:browsing_context) { BrowsingContext.new(driver) } + let(:session) { Session.new(driver) } + + def bytes(value) + Network::StringValue.new(value: value) + end + + def header(name, value) + Network::Header.new(name: name, value: bytes(value)) + end + + def subscribe(event) + events = [] + callback = driver.bidi.add_callback(event) { |params| events << params } + session.subscribe(events: [event]) + [events, callback] + end + + def unsubscribe(event, callback) + session.unsubscribe(events: [event]) + ensure + driver.bidi.remove_callback(event, callback) if callback + end + + def blocked_event(events, intercept) + wait.until do + events.find { |event| event['isBlocked'] && Array(event['intercepts']).include?(intercept.intercept) } + end + end + + describe '#add_data_collector', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.add_data_collector'} do + it 'returns a collector id' do + result = network.add_data_collector(data_types: [:response], max_encoded_data_size: 200_000_000) + + expect(result).to be_a(Network::AddDataCollectorResult) + expect(result.collector).to be_a(String) + ensure + network.remove_data_collector(collector: result.collector) if result&.collector + end + + it 'accepts collector type and context filters' do + result = network.add_data_collector( + data_types: [:request], + max_encoded_data_size: 200_000_000, + collector_type: :blob, + contexts: [driver.window_handle] + ) + + expect(result.collector).to be_a(String) + ensure + network.remove_data_collector(collector: result.collector) if result&.collector + end + end + + describe '#add_intercept', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.add_intercept'} do + it 'returns an intercept id' do + result = network.add_intercept(phases: [:before_request_sent]) + + expect(result).to be_a(Network::AddInterceptResult) + expect(result.intercept).to be_a(String) + ensure + network.remove_intercept(intercept: result.intercept) if result&.intercept + end + + it 'accepts context and URL pattern filters' do + result = network.add_intercept( + phases: [:before_request_sent], + contexts: [driver.window_handle], + url_patterns: [Network::UrlPatternString.new(pattern: url_for('formPage.html'))] + ) + + expect(result.intercept).to be_a(String) + ensure + network.remove_intercept(intercept: result.intercept) if result&.intercept + end + end + + describe '#continue_request', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.continue_request'} do + it 'continues an intercepted request' do + intercept = network.add_intercept(phases: [:before_request_sent]) + events, callback = subscribe('network.beforeRequestSent') + + driver.bidi.add_callback('network.beforeRequestSent') do |event| + next unless event['isBlocked'] && Array(event['intercepts']).include?(intercept.intercept) + + network.continue_request(request: event['request']['request']) + rescue Error::WebDriverError + nil + end + + browsing_context.navigate(context: driver.window_handle, url: url_for('formPage.html'), wait: :complete) + + expect(events.count { |event| event['isBlocked'] }).to be_positive + expect(driver.find_element(name: 'login')).to be_displayed + ensure + unsubscribe('network.beforeRequestSent', callback) if callback + network.remove_intercept(intercept: intercept.intercept) if intercept&.intercept + end + + it 'accepts optional headers, method, and URL parameters' do + intercept = network.add_intercept( + phases: [:before_request_sent], + url_patterns: [Network::UrlPatternString.new(pattern: url_for('bidi/emptyText.txt'))] + ) + events, callback = subscribe('network.beforeRequestSent') + + driver.bidi.add_callback('network.beforeRequestSent') do |event| + next unless event['isBlocked'] && Array(event['intercepts']).include?(intercept.intercept) + + network.continue_request( + request: event['request']['request'], + headers: [header('x-ruby-bidi', 'continued')], + method_: 'GET', + url: event['request']['url'] + ) + rescue Error::WebDriverError + nil + end + + browsing_context.navigate( + context: driver.window_handle, + url: url_for('bidi/emptyText.txt'), + wait: :complete + ) + + expect(blocked_event(events, intercept)['request']['url']).to eq(url_for('bidi/emptyText.txt')) + ensure + unsubscribe('network.beforeRequestSent', callback) if callback + network.remove_intercept(intercept: intercept.intercept) if intercept&.intercept + end + end + + describe '#continue_response', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.continue_response'} do + it 'continues an intercepted response' do + intercept = network.add_intercept(phases: [:response_started]) + events, callback = subscribe('network.responseStarted') + + driver.bidi.add_callback('network.responseStarted') do |event| + next unless event['isBlocked'] && Array(event['intercepts']).include?(intercept.intercept) + + network.continue_response(request: event['request']['request']) + rescue Error::WebDriverError + nil + end + + browsing_context.navigate(context: driver.window_handle, url: url_for('formPage.html'), wait: :complete) + + expect(blocked_event(events, intercept)['response']['status']).to eq(200) + expect(driver.find_element(name: 'login')).to be_displayed + ensure + unsubscribe('network.responseStarted', callback) if callback + network.remove_intercept(intercept: intercept.intercept) if intercept&.intercept + end + + it 'accepts optional response headers and status parameters' do + intercept = network.add_intercept(phases: [:response_started]) + events, callback = subscribe('network.responseStarted') + + driver.bidi.add_callback('network.responseStarted') do |event| + next unless event['isBlocked'] && Array(event['intercepts']).include?(intercept.intercept) + + network.continue_response( + request: event['request']['request'], + headers: [header('x-ruby-bidi-response', 'continued')], + reason_phrase: 'OK', + status_code: 200 + ) + rescue Error::WebDriverError + nil + end + + browsing_context.navigate(context: driver.window_handle, url: url_for('formPage.html'), wait: :complete) + + expect(blocked_event(events, intercept)['response']['status']).to eq(200) + ensure + unsubscribe('network.responseStarted', callback) if callback + network.remove_intercept(intercept: intercept.intercept) if intercept&.intercept + end + end + + describe '#continue_with_auth', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.continue_with_auth'} do + it 'provides credentials for an auth challenge' do + username, password = SpecSupport::RackServer::TestApp::BASIC_AUTH_CREDENTIALS + intercept = network.add_intercept(phases: [:auth_required]) + _events, callback = subscribe('network.authRequired') + + driver.bidi.add_callback('network.authRequired') do |event| + next unless event['isBlocked'] && Array(event['intercepts']).include?(intercept.intercept) + + network.continue_with_auth( + request: event['request']['request'], + action: :provide_credentials, + credentials: Network::AuthCredentials.new(username: username, password: password) + ) + rescue Error::WebDriverError + nil + end + + browsing_context.navigate(context: driver.window_handle, url: url_for('basicAuth'), wait: :complete) + + expect(driver.find_element(tag_name: 'h1').text).to eq('authorized') + ensure + unsubscribe('network.authRequired', callback) if callback + network.remove_intercept(intercept: intercept.intercept) if intercept&.intercept + end + end + + describe '#fail_request', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.fail_request'} do + it 'fails an intercepted request' do + intercept = network.add_intercept( + phases: [:before_request_sent], + url_patterns: [Network::UrlPatternString.new(pattern: url_for('formPage.html'))] + ) + _events, callback = subscribe('network.beforeRequestSent') + + driver.bidi.add_callback('network.beforeRequestSent') do |event| + next unless event['isBlocked'] && Array(event['intercepts']).include?(intercept.intercept) + + network.fail_request(request: event['request']['request']) + rescue Error::WebDriverError + nil + end + + expect { + browsing_context.navigate(context: driver.window_handle, url: url_for('formPage.html'), wait: :complete) + }.to raise_error(Error::WebDriverError) + ensure + unsubscribe('network.beforeRequestSent', callback) if callback + network.remove_intercept(intercept: intercept.intercept) if intercept&.intercept + end + end + + describe '#get_data', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.get_data'} do + it 'gets and disowns collected response data' do + collector = network.add_data_collector(data_types: [:response], max_encoded_data_size: 200_000_000) + events, callback = subscribe('network.responseCompleted') + + browsing_context.navigate( + context: driver.window_handle, + url: url_for('bidi/emptyText.txt'), + wait: :complete + ) + event = wait.until { events.find { |item| item.dig('request', 'url') == url_for('bidi/emptyText.txt') } } + + result = network.get_data( + data_type: :response, + collector: collector.collector, + request: event['request']['request'] + ) + + expect(result.bytes).to be_a(Network::StringValue).or be_a(Network::Base64Value) + expect(network.disown_data( + data_type: :response, + collector: collector.collector, + request: event['request']['request'] + )).to be_empty + ensure + unsubscribe('network.responseCompleted', callback) if callback + network.remove_data_collector(collector: collector.collector) if collector&.collector + end + end + + describe '#provide_response', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.provide_response'} do + it 'provides a complete response body' do + intercept = network.add_intercept( + phases: [:before_request_sent], + url_patterns: [Network::UrlPatternString.new(pattern: url_for('formPage.html'))] + ) + _events, callback = subscribe('network.beforeRequestSent') + + driver.bidi.add_callback('network.beforeRequestSent') do |event| + next unless event['isBlocked'] && Array(event['intercepts']).include?(intercept.intercept) + + network.provide_response( + request: event['request']['request'], + status_code: 200, + reason_phrase: 'OK', + headers: [header('content-type', 'text/html')], + body: bytes('Provided by Ruby BiDiok') + ) + rescue Error::WebDriverError + nil + end + + browsing_context.navigate(context: driver.window_handle, url: url_for('formPage.html'), wait: :complete) + + expect(driver.title).to eq('Provided by Ruby BiDi') + ensure + unsubscribe('network.beforeRequestSent', callback) if callback + network.remove_intercept(intercept: intercept.intercept) if intercept&.intercept + end + end + + describe '#remove_data_collector', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.remove_data_collector'} do + it 'removes a collector' do + collector = network.add_data_collector(data_types: [:response], max_encoded_data_size: 200_000_000) + + expect(network.remove_data_collector(collector: collector.collector)).to be_empty + end + end + + describe '#remove_intercept', + pending_if: {browser_family: :safari, + exception: {class: Error::UnknownCommandError}, + reason: 'Safari returns unknown command for network.remove_intercept'} do + it 'removes an intercept' do + intercept = network.add_intercept(phases: [:before_request_sent]) + + expect(network.remove_intercept(intercept: intercept.intercept)).to be_empty + end + end + + describe '#set_cache_behavior' do + it 'sets and clears context cache behavior' do + expect(network.set_cache_behavior(cache_behavior: :bypass, contexts: [driver.window_handle])).to be_empty + expect(network.set_cache_behavior(cache_behavior: :default, contexts: [driver.window_handle])).to be_empty + end + end + + describe '#set_extra_headers', + pending_if: {browser_family: :safari, + reason: 'Safari network.setExtraHeaders result fails strict deserialization'} do + it 'adds extra request headers for a context' do + events, callback = subscribe('network.beforeRequestSent') + + network.set_extra_headers(headers: [header('x-ruby-bidi-extra', 'present')], + contexts: [driver.window_handle]) + browsing_context.navigate( + context: driver.window_handle, + url: url_for('bidi/emptyText.txt'), + wait: :complete + ) + + event = wait.until do + events.find do |item| + Array(item.dig('request', 'headers')).any? do |item_header| + item_header['name'].casecmp?('x-ruby-bidi-extra') + end + end + end + actual = event['request']['headers'].find do |item_header| + item_header['name'].casecmp?('x-ruby-bidi-extra') + end + + expect(actual['value']['value']).to eq('present') + ensure + begin + network.set_extra_headers(headers: [], contexts: [driver.window_handle]) + rescue StandardError + nil + end + unsubscribe('network.beforeRequestSent', callback) if callback + end + + it 'accepts user-context filters' do + user_context = Browser.new(driver).create_user_context.user_context + + expect(network.set_extra_headers( + headers: [header('x-ruby-bidi-user-context', 'present')], + user_contexts: [user_context] + )).to be_empty + ensure + network.set_extra_headers(headers: [], user_contexts: [user_context]) if user_context + Browser.new(driver).remove_user_context(user_context: user_context) if user_context + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/permissions_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/permissions_spec.rb new file mode 100644 index 0000000000000..b5a24fef8882b --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/permissions_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Permissions, + skip_if: {browser_family: :safari, + reason: 'Safari coverage tracked in safari_support_probe_spec.rb'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:permissions) { described_class.new(driver) } + let(:script) { Script.new(driver) } + let(:browsing_context) { BrowsingContext.new(driver) } + + def target(context) + Script::ContextTarget.new(context: context) + end + + def evaluate(expression, context = driver.window_handle, await_promise: false) + script.evaluate(expression: expression, target: target(context), await_promise: await_promise) + end + + def geolocation_permission(context = driver.window_handle) + evaluate( + "navigator.permissions.query({name: 'geolocation'}).then(permission => permission.state)", + context, + await_promise: true + ).result.value + end + + def origin(context = driver.window_handle) + evaluate('window.location.origin', context).result.value + end + + describe '#set_permission' do + it 'sets geolocation permission to granted' do + browsing_context.navigate(context: driver.window_handle, url: url_for('blank.html'), wait: :complete) + + permissions.set_permission( + descriptor: Permissions::PermissionDescriptor.new(name: 'geolocation'), + state: :granted, + origin: origin + ) + + expect(geolocation_permission).to eq('granted') + end + + it 'accepts embedded origin and user context parameters' do + browser = Browser.new(driver) + user_context = browser.create_user_context.user_context + context = browsing_context.create(type: :tab, user_context: user_context).context + + browsing_context.navigate(context: driver.window_handle, url: url_for('blank.html'), wait: :complete) + browsing_context.navigate(context: context, url: url_for('blank.html'), wait: :complete) + permission_origin = origin(driver.window_handle) + original_state = geolocation_permission(driver.window_handle) + + permissions.set_permission( + descriptor: Permissions::PermissionDescriptor.new(name: 'geolocation'), + state: :granted, + origin: permission_origin, + embedded_origin: permission_origin, + user_context: user_context + ) + + expect(geolocation_permission(context)).to eq('granted') + expect(geolocation_permission(driver.window_handle)).to eq(original_state) + ensure + browsing_context.close(context: context) if context + browser.remove_user_context(user_context: user_context) if browser && user_context + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/safari_support_probe_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/safari_support_probe_spec.rb new file mode 100644 index 0000000000000..9f02e94b96a33 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/safari_support_probe_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + # One canary per BiDi module that Safari does not yet implement. The module's own spec is + # skipped on Safari (skip_if) so its dozens of unsupported examples don't burn Safari's slow, + # serial runner. Each probe below runs only on Safari, staying pending while the command + # raises. When Safari implements the module the command succeeds, the expectation runs, and the + # probe passes, raising PendingExampleFixedError so we drop skip_if(:safari) in the named spec. + describe 'Safari BiDi support probes', + skip_unless: {bidi: true, browser_family: :safari, reason: 'probes only run on Safari'} do + after { |example| reset_driver!(example: example) } + + it 'bluetooth is unimplemented on Safari', + pending_if: {browser_family: :safari, exception: {class: Error::UnknownCommandError}, + reason: 'when green, unblock bluetooth_spec.rb'} do + result = Bluetooth.new(driver).simulate_adapter( + context: driver.window_handle, state: :powered_on, le_supported: true + ) + expect(result).to be_empty + end + + it 'browser client windows are unimplemented on Safari', + pending_if: {browser_family: :safari, exception: {class: Error::UnknownCommandError}, + reason: 'when green, unblock browser_spec.rb'} do + expect(Browser.new(driver).get_client_windows.client_windows).to be_an(Array) + end + + it 'emulation is unimplemented on Safari', + pending_if: {browser_family: :safari, exception: {class: Error::UnknownCommandError}, + reason: 'when green, unblock emulation_spec.rb'} do + result = Emulation.new(driver).set_forced_colors_mode_theme_override( + theme: nil, contexts: [driver.window_handle] + ) + expect(result).to be_empty + end + + it 'permissions is unimplemented on Safari', + pending_if: {browser_family: :safari, exception: {class: Error::UnknownCommandError}, + reason: 'when green, unblock permissions_spec.rb'} do + context = driver.window_handle + BrowsingContext.new(driver).navigate(context: context, url: url_for('blank.html'), wait: :complete) + origin = Script.new(driver).evaluate( + expression: 'window.location.origin', + target: Script::ContextTarget.new(context: context), + await_promise: false + ).result.value + + result = Permissions.new(driver).set_permission( + descriptor: Permissions::PermissionDescriptor.new(name: 'geolocation'), + state: :granted, + origin: origin + ) + expect(result).to be_empty + end + + it 'storage is unimplemented on Safari', + pending_if: {browser_family: :safari, exception: {class: Error::UnknownError}, + reason: 'when green, unblock storage_spec.rb'} do + expect(Storage.new(driver).get_cookies.cookies).to be_an(Array) + end + + it 'user agent client hints are unimplemented on Safari', + pending_if: {browser_family: :safari, exception: {class: Error::UnknownCommandError}, + reason: 'when green, unblock user_agent_client_hints_spec.rb'} do + result = UserAgentClientHints.new(driver).set_client_hints_override( + client_hints: nil, contexts: [driver.window_handle] + ) + expect(result).to be_empty + end + + it 'web extensions are unimplemented on Safari', + pending_if: {browser_family: :safari, exception: {class: Error::UnknownCommandError}, + reason: 'when green, unblock web_extension_spec.rb'} do + expect(WebExtension.new(driver).uninstall(extension: 'ruby-bidi-probe')).to be_empty + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/script_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/script_spec.rb new file mode 100644 index 0000000000000..314a821435b1b --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/script_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Script, skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:script) { described_class.new(driver) } + + def target(context = driver.window_handle, sandbox: nil) + kwargs = {context: context} + kwargs[:sandbox] = sandbox if sandbox + Script::ContextTarget.new(**kwargs) + end + + def evaluate(expression, **kwargs) + script.evaluate( + expression: expression, + target: kwargs.fetch(:target, target), + await_promise: kwargs.fetch(:await_promise, false), + result_ownership: kwargs.fetch(:result_ownership, WebDriver::BiDi::Serialization::UNSET), + serialization_options: kwargs.fetch(:serialization_options, WebDriver::BiDi::Serialization::UNSET) + ) + end + + describe '#add_preload_script', + pending_if: {browser_family: :safari, + reason: 'Safari script.addPreloadScript result fails strict deserialization'} do + it 'runs a preload script in future documents' do + result = script.add_preload_script( + function_declaration: "() => { window.__ruby_bidi_preload = 'installed'; }" + ) + + driver.navigate.to url_for('blank.html') + + expect(result).to be_a(Script::AddPreloadScriptResult) + expect(result.script).to be_a(String) + expect(evaluate('window.__ruby_bidi_preload').result.value).to eq('installed') + ensure + script.remove_preload_script(script: result.script) if result&.script + end + + it 'accepts context and sandbox options' do + result = script.add_preload_script( + function_declaration: '() => { globalThis.__ruby_bidi_sandbox_preload = 7; }', + contexts: [driver.window_handle], + sandbox: 'ruby-preload' + ) + + driver.navigate.to url_for('blank.html') + sandbox_result = evaluate( + 'globalThis.__ruby_bidi_sandbox_preload', + target: target(driver.window_handle, sandbox: 'ruby-preload') + ) + + expect(sandbox_result.result.value).to eq(7) + ensure + script.remove_preload_script(script: result.script) if result&.script + end + end + + describe '#call_function' do + it 'calls a function with local value arguments', + pending_if: {browser_family: :safari, + reason: 'Safari remote value fails deserialization'} do + result = script.call_function( + function_declaration: '(left, right) => left + right', + await_promise: false, + target: target, + arguments: [Script::NumberValue.new(value: 2), Script::NumberValue.new(value: 3)] + ) + + expect(result).to be_a(Script::EvaluateResultSuccess) + expect(result.result).to eq(Script::NumberValue.new(value: 5)) + end + + it 'accepts this, ownership, serialization, and user activation options', + pending_if: {browser_family: :safari, + reason: 'Safari call_function returns an unexpected result for this-object binding'} do + result = script.call_function( + function_declaration: 'function (suffix) { return this.prefix + suffix; }', + await_promise: true, + target: target, + arguments: [Script::StringValue.new(value: 'BiDi')], + result_ownership: :none, + serialization_options: Script::SerializationOptions.new(max_object_depth: 1), + this: Script::ObjectLocalValue.new(value: [['prefix', Script::StringValue.new(value: 'Ruby ')]]), + user_activation: true + ) + + expect(result).to be_a(Script::EvaluateResultSuccess) + expect(result.result).to eq(Script::StringValue.new(value: 'Ruby BiDi')) + end + end + + describe '#disown', + pending_if: {browser_family: :safari, + reason: 'Safari remote value fails deserialization'} do + it 'disowns a remote handle' do + result = evaluate('({answer: 42})', result_ownership: :root) + handle = result.result.handle + + expect(handle).to be_a(String) + expect(script.disown(handles: [handle], target: target)).to be_empty + end + end + + describe '#evaluate' do + it 'evaluates an expression in the current context' do + result = evaluate('1 + 2') + + expect(result).to be_a(Script::EvaluateResultSuccess) + expect(result.result).to eq(Script::NumberValue.new(value: 3)) + expect(result.realm).to be_a(String) + end + + it 'awaits promises and accepts serialization options', + pending_if: {browser_family: :safari, + reason: 'Safari remote value fails deserialization'} do + result = evaluate( + 'Promise.resolve({name: "ruby", nested: {hidden: true}})', + await_promise: true, + result_ownership: :root, + serialization_options: Script::SerializationOptions.new(max_object_depth: 1) + ) + + expect(result).to be_a(Script::EvaluateResultSuccess) + expect(result.result).to be_a(Script::ObjectRemoteValue) + expect(result.result.handle).to be_a(String) + ensure + if result&.result.respond_to?(:handle) && result.result.handle.is_a?(String) + script.disown(handles: [result.result.handle], target: target) + end + end + end + + describe '#get_realms' do + it 'returns window realms' do + result = script.get_realms + + expect(result.realms).not_to be_empty + expect(result.realms).to all(respond_to(:realm)) + end + + it 'filters realms by context and type' do + result = script.get_realms(context: driver.window_handle, type: :window) + + expect(result.realms).not_to be_empty + expect(result.realms.map(&:context)).to all(eq(driver.window_handle)) + expect(result.realms.map { |realm| realm.type.to_s }.uniq).to eq(['window']) + end + end + + describe '#remove_preload_script', + pending_if: {browser_family: :safari, + reason: 'Safari script.addPreloadScript result fails strict deserialization'} do + it 'removes a preload script before future navigations' do + result = script.add_preload_script( + function_declaration: '() => { window.__ruby_bidi_removed_preload = true; }' + ) + + script.remove_preload_script(script: result.script) + driver.navigate.to url_for('blank.html') + + expect(evaluate('typeof window.__ruby_bidi_removed_preload').result.value).to eq('undefined') + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/session_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/session_spec.rb new file mode 100644 index 0000000000000..5273d93cb064c --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/session_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Session, skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:session) { described_class.new(driver) } + + describe '#status' do + it 'returns typed session status' do + result = session.status + + expect(result).to be_a(Session::StatusResult) + expect(result.ready).to be(true).or be(false) + expect(result.message).to be_a(String) + end + end + + describe '#subscribe', + pending_if: {browser_family: :safari, + reason: 'Safari session.subscribe result fails strict deserialization'} do + it 'subscribes to an event globally' do + result = session.subscribe(events: ['browsingContext.load']) + + expect(result).to be_a(Session::SubscribeResult) + expect(result.subscription).to be_a(String) + ensure + begin + session.unsubscribe(events: ['browsingContext.load']) + rescue StandardError + nil + end + end + + it 'subscribes to an event for a context' do + result = session.subscribe(events: ['browsingContext.load'], contexts: [driver.window_handle]) + + expect(result.subscription).to be_a(String) + ensure + session.unsubscribe(subscriptions: [result.subscription]) if result&.subscription + end + end + + describe '#unsubscribe', + pending_if: {browser_family: :safari, + reason: 'Safari session.unsubscribe result fails strict deserialization'} do + it 'unsubscribes by event name' do + session.subscribe(events: ['browsingContext.load']) + + expect(session.unsubscribe(events: ['browsingContext.load'])).to be_empty + end + + it 'unsubscribes by subscription id' do + result = session.subscribe(events: ['browsingContext.load'], contexts: [driver.window_handle]) + + expect(session.unsubscribe(subscriptions: [result.subscription])).to be_empty + end + end + + describe '#new' do + it 'is rejected on an already established WebDriver BiDi session' do + capabilities = Session::CapabilitiesRequest.new( + always_match: Session::CapabilityRequest.new( + accept_insecure_certs: false, + unhandled_prompt_behavior: Session::UserPromptHandler.new(default: :dismiss) + ) + ) + + expect { session.new(capabilities: capabilities) }.to raise_error(Error::WebDriverError) + end + end + + describe '#end_', + pending_if: {browser: :firefox, + exception: {class: Error::UnsupportedOperationError, + message: /Ending a session /}, + reason: 'Firefox returns unsupported operation for session.end on Classic sessions'} do + it 'ends the active BiDi session' do + expect(session.end_).to be_empty + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/storage_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/storage_spec.rb new file mode 100644 index 0000000000000..6c2a5766ad67c --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/storage_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' +require 'uri' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe Storage, + skip_if: {browser_family: :safari, + reason: 'Safari coverage tracked in safari_support_probe_spec.rb'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:storage) { described_class.new(driver) } + + def cookie_domain + URI(driver.current_url).host + end + + def cookie_value(value) + Network::StringValue.new(value: value) + end + + def partial_cookie(name, value, **options) + Storage::PartialCookie.new(name: name, value: cookie_value(value), domain: cookie_domain, **options) + end + + before do + driver.navigate.to url_for('ajaxy_page.html') + driver.manage.delete_all_cookies + end + + describe '#set_cookie' do + it 'sets a cookie that can be read by name' do + result = storage.set_cookie(cookie: partial_cookie('ruby-bidi-cookie', 'test-value')) + + cookies = storage.get_cookies( + filter: Storage::CookieFilter.new(name: 'ruby-bidi-cookie') + ).cookies + + expect(result.partition_key).to be_a(Storage::PartitionKey) + expect(cookies.map(&:name)).to include('ruby-bidi-cookie') + expect(cookies.find { |cookie| cookie.name == 'ruby-bidi-cookie' }.value.value).to eq('test-value') + end + + it 'sets a cookie with optional attributes and a context partition' do + partition = Storage::BrowsingContextPartitionDescriptor.new(context: driver.window_handle) + expiry = Time.now.to_i + 3600 + + result = storage.set_cookie( + cookie: partial_cookie( + 'ruby-bidi-partitioned-cookie', + 'partitioned', + path: '/', + http_only: true, + secure: false, + same_site: :lax, + expiry: expiry + ), + partition: partition + ) + + cookies = storage.get_cookies( + filter: Storage::CookieFilter.new(name: 'ruby-bidi-partitioned-cookie'), + partition: partition + ).cookies + + expect(result.partition_key).to be_a(Storage::PartitionKey) + expect(cookies.first.value.value).to eq('partitioned') + expect(cookies.first.http_only).to be(true) + expect(cookies.first.same_site).to eq(:lax) + end + end + + describe '#get_cookies' do + it 'returns matching cookies and the partition key' do + storage.set_cookie(cookie: partial_cookie('ruby-bidi-filter-cookie', 'filter-value')) + + result = storage.get_cookies(filter: Storage::CookieFilter.new(domain: cookie_domain)) + + expect(result).to be_a(Storage::GetCookiesResult) + expect(result.partition_key).to be_a(Storage::PartitionKey) + expect(result.cookies.map(&:name)).to include('ruby-bidi-filter-cookie') + end + + it 'returns an empty list when no cookies match the filter' do + result = storage.get_cookies(filter: Storage::CookieFilter.new(name: 'missing-ruby-bidi-cookie')) + + expect(result.cookies).to be_empty + end + end + + describe '#delete_cookies' do + it 'deletes a single matching cookie' do + storage.set_cookie(cookie: partial_cookie('ruby-bidi-delete-me', 'delete')) + storage.set_cookie(cookie: partial_cookie('ruby-bidi-keep-me', 'keep')) + + result = storage.delete_cookies(filter: Storage::CookieFilter.new(name: 'ruby-bidi-delete-me')) + + expect(result.partition_key).to be_a(Storage::PartitionKey) + deleted_cookies = storage.get_cookies( + filter: Storage::CookieFilter.new(name: 'ruby-bidi-delete-me') + ).cookies + kept_cookies = storage.get_cookies( + filter: Storage::CookieFilter.new(name: 'ruby-bidi-keep-me') + ).cookies + + expect(deleted_cookies).to be_empty + expect(kept_cookies).not_to be_empty + end + + it 'accepts a partition descriptor' do + partition = Storage::BrowsingContextPartitionDescriptor.new(context: driver.window_handle) + storage.set_cookie(cookie: partial_cookie('ruby-bidi-context-cookie', 'context'), partition: partition) + + result = storage.delete_cookies( + filter: Storage::CookieFilter.new(name: 'ruby-bidi-context-cookie'), + partition: partition + ) + + expect(result.partition_key).to be_a(Storage::PartitionKey) + expect(storage.get_cookies( + filter: Storage::CookieFilter.new(name: 'ruby-bidi-context-cookie'), + partition: partition + ).cookies).to be_empty + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/user_agent_client_hints_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/user_agent_client_hints_spec.rb new file mode 100644 index 0000000000000..e799c93fd1f89 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/user_agent_client_hints_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe UserAgentClientHints, + pending_if: {browser: :firefox, + exception: {class: Error::UnknownCommandError}, + reason: 'Firefox driver currently returns unknown command for userAgentClientHints'}, + skip_if: {browser_family: :safari, + reason: 'Safari coverage tracked in safari_support_probe_spec.rb'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + after { |example| reset_driver!(example: example) } + + let(:user_agent_client_hints) { described_class.new(driver) } + let(:script) { Script.new(driver) } + + def target + Script::ContextTarget.new(context: driver.window_handle) + end + + def evaluate(expression) + script.evaluate(expression: expression, target: target, await_promise: false).result.value + end + + def client_hints(platform: 'RubyOS') + described_class::ClientHintsMetadata.new( + brands: [described_class::BrandVersion.new(brand: 'RubyBrowser', version: '1')], + full_version_list: [described_class::BrandVersion.new(brand: 'RubyBrowser', version: '1.0.0')], + platform: platform, + platform_version: '1.0', + architecture: 'x86', + model: '', + mobile: false, + bitness: '64', + wow64: false, + form_factors: ['Desktop'] + ) + end + + describe '#set_client_hints_override' do + it 'overrides navigator user agent data for the current context' do + user_agent_client_hints.set_client_hints_override( + client_hints: client_hints, + contexts: [driver.window_handle] + ) + + driver.navigate.to url_for('blank.html') + + expect(evaluate('navigator.userAgentData.platform')).to eq('RubyOS') + expect(evaluate('navigator.userAgentData.brands[0].brand')).to eq('RubyBrowser') + ensure + begin + user_agent_client_hints.set_client_hints_override( + client_hints: nil, + contexts: [driver.window_handle] + ) + rescue StandardError + nil + end + end + + it 'accepts user-context filters' do + user_context = Browser.new(driver).create_user_context.user_context + + expect(user_agent_client_hints.set_client_hints_override( + client_hints: client_hints(platform: 'RubyUserContextOS'), + user_contexts: [user_context] + )).to be_empty + ensure + if user_context + user_agent_client_hints.set_client_hints_override(client_hints: nil, + user_contexts: [user_context]) + end + Browser.new(driver).remove_user_context(user_context: user_context) if user_context + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol/web_extension_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol/web_extension_spec.rb new file mode 100644 index 0000000000000..2ead067d4cbd4 --- /dev/null +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol/web_extension_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative '../../spec_helper' +require 'base64' +require 'selenium/webdriver/bidi/protocol' + +module Selenium + module WebDriver + class BiDi + module Protocol + describe WebExtension, + skip_if: {browser_family: :safari, + reason: 'Safari coverage tracked in safari_support_probe_spec.rb'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do + before { reset_driver!(args: chromium_web_extension_args) if GlobalTestEnv.browser_family == :chromium } + + after do |example| + if GlobalTestEnv.browser_family == :chromium + reset_driver!(example: example, args: chromium_web_extension_args) + else + reset_driver!(example: example) + end + end + + let(:web_extension) { described_class.new(driver) } + let(:expected_id) { 'webextensions-selenium-example-v3@example.com' } + + def chromium_web_extension_args + return [] unless GlobalTestEnv.browser_family == :chromium + + %w[--enable-unsafe-extension-debugging --remote-debugging-pipe] + end + + def extension_path(name) + File.expand_path("../../../../../../../common/extensions/#{name}", __dir__) + end + + def archive_extension + return 'webextensions-selenium-example.crx' if GlobalTestEnv.browser_family == :chromium + + 'webextensions-selenium-example.xpi' + end + + def expect_extension_injected + driver.navigate.to url_for('blank.html') + + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq('Content injected by webextensions-selenium-example') + end + + def install_and_assert(extension_data) + result = web_extension.install(extension_data: extension_data) + + expect(result).to be_a(WebExtension::InstallResult) + expect(result.extension).to be_a(String) + expect(result.extension).not_to be_empty + expect(result.extension).to eq(expected_id) if GlobalTestEnv.browser == :firefox + expect_extension_injected + + result + end + + describe '#install' do + it 'installs an extension from a directory path' do + result = install_and_assert( + WebExtension::ExtensionPath.new(path: extension_path('webextensions-selenium-example-signed')) + ) + + web_extension.uninstall(extension: result.extension) + driver.navigate.refresh + expect(driver.find_elements(id: 'webextensions-selenium-example')).to be_empty + end + + it 'installs an extension from an archive path', + pending_if: {browser_family: :chromium, + exception: {class: Error::UnsupportedOperationError, + message: /Archived and Base64 extensions are not supported/}, + reason: 'Chromium driver currently returns unsupported for archivePath payloads'} do + result = install_and_assert( + WebExtension::ExtensionArchivePath.new(path: extension_path(archive_extension)) + ) + + expect(result.extension).to be_a(String) + web_extension.uninstall(extension: result.extension) + end + + it 'installs an extension from base64 archive data', + pending_if: {browser_family: :chromium, + exception: {class: Error::UnsupportedOperationError, + message: /Archived and Base64 extensions are not supported/}, + reason: 'Chromium driver currently returns unsupported for base64 payloads'} do + encoded = Base64.strict_encode64(File.binread(extension_path(archive_extension))) + result = install_and_assert(WebExtension::ExtensionBase64Encoded.new(value: encoded)) + + expect(result.extension).to be_a(String) + web_extension.uninstall(extension: result.extension) + end + end + + describe '#uninstall' do + it 'uninstalls an installed extension' do + result = web_extension.install( + extension_data: WebExtension::ExtensionPath.new( + path: extension_path('webextensions-selenium-example-signed') + ) + ) + + expect(web_extension.uninstall(extension: result.extension)).to be_empty + end + end + end + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol_browsing_context_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol_browsing_context_spec.rb index 879bb0511ff1e..c36980929f752 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/protocol_browsing_context_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol_browsing_context_spec.rb @@ -84,42 +84,6 @@ module Protocol expect(driver.execute_script('return [window.innerWidth, window.innerHeight]')).to eq([800, 600]) end - it 'accepts user prompts without text', - pending_if: {browser_family: :chromium, - reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do - driver.navigate.to url_for('alerts.html') - driver.find_element(id: 'alert').click - wait_for_alert - browsing_context.handle_user_prompt(context: driver.window_handle, accept: true) - wait_for_no_alert - - expect(driver.title).to eq('Testing Alerts') - end - - it 'accepts user prompts with text', - pending_if: {browser_family: :chromium, - reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do - driver.navigate.to url_for('alerts.html') - driver.find_element(id: 'prompt').click - wait_for_alert - browsing_context.handle_user_prompt(context: driver.window_handle, accept: true, user_text: 'Hello, world!') - wait_for_no_alert - - expect(driver.title).to eq('Testing Alerts') - end - - it 'rejects user prompts', - pending_if: {browser_family: :chromium, - reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do - driver.navigate.to url_for('alerts.html') - driver.find_element(id: 'alert').click - wait_for_alert - browsing_context.handle_user_prompt(context: driver.window_handle, accept: false) - wait_for_no_alert - - expect(driver.title).to eq('Testing Alerts') - end - it 'activates a browser context', pending_if: {browser_family: :safari, reason: 'Safari does not focus the activated context'} do window = driver.window_handle diff --git a/rb/spec/integration/selenium/webdriver/spec_support/test_environment.rb b/rb/spec/integration/selenium/webdriver/spec_support/test_environment.rb index 6bb521997d4f2..be26da3a0b10b 100644 --- a/rb/spec/integration/selenium/webdriver/spec_support/test_environment.rb +++ b/rb/spec/integration/selenium/webdriver/spec_support/test_environment.rb @@ -40,6 +40,9 @@ def initialize WebDriver.logger.ignore(:logger_info) SeleniumManager.bin_path = root.join('bazel-bin/rb/bin').to_s if File.exist?(root.join('bazel-bin/rb/bin')) + # always run bidi tests in strict mode to detect browser bugs + ENV['SE_BIDI_STRICT'] ||= 'true' if ENV['WEBDRIVER_BIDI'] + @driver = ENV.fetch('WD_SPEC_DRIVER', 'chrome').tr('-', '_').to_sym @driver_instance = nil @remote_server = nil diff --git a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb index 2ad1d196182f2..ac18b04afb8f3 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb @@ -79,12 +79,12 @@ def valid_cookie_attrs payload = {'type' => 'futuristic', 'value' => 'x'} expect { BrowsingContext::Locator.from_json(payload) } - .to raise_error(Error::WebDriverError, /variant not in this Selenium's BiDi schema/) + .to raise_error(Error::SerializationError, /variant not in this Selenium's BiDi schema/) end it 'raises when an object-only union receives a bare scalar (no arm can match)' do expect { Script::RemoteValue.from_json('not-an-object') } - .to raise_error(Error::WebDriverError, /RemoteValue expected an object/) + .to raise_error(Error::SerializationError, /RemoteValue expected an object/) end it 'passes a bare scalar through a union that has a scalar arm (input.Origin)' do @@ -162,7 +162,7 @@ def valid_cookie_attrs wire = {'type' => 'object', 'value' => [[42, {'type' => 'number', 'value' => 2}]]} expect { Script::ObjectRemoteValue.from_json(wire) } - .to raise_error(Error::WebDriverError, /value expected string, got 42/) + .to raise_error(Error::SerializationError, /value expected string, got 42/) end # Scalar tolerance is the key's alone: the value is the object-only RemoteValue union, @@ -171,7 +171,7 @@ def valid_cookie_attrs wire = {'type' => 'object', 'value' => [['k', 'bare string, not an object']]} expect { Script::ObjectRemoteValue.from_json(wire) } - .to raise_error(Error::WebDriverError, /expected an object on the wire/) + .to raise_error(Error::SerializationError, /expected an object on the wire/) end # A map is `[key, value]` pairs; a malformed entry that is not a 2-item pair is a wire @@ -180,7 +180,7 @@ def valid_cookie_attrs wire = {'type' => 'object', 'value' => [['orphan-key']]} expect { Script::ObjectRemoteValue.from_json(wire) } - .to raise_error(Error::WebDriverError, /expected a \[key, value\] pair/) + .to raise_error(Error::SerializationError, /expected a \[key, value\] pair/) end end @@ -394,7 +394,7 @@ def moz_install(**kwargs) it 'raises on an inbound enum value outside our schema' do expect { Log::ConsoleLogEntry.from_json('type' => 'console', 'level' => 'futureLevel') } - .to raise_error(Error::WebDriverError, /level received an unknown value.*futureLevel/) + .to raise_error(Error::SerializationError, /level received an unknown value.*futureLevel/) end end @@ -545,7 +545,7 @@ def moz_install(**kwargs) it 'raises on an unrecognized inbound token' do expect { Bluetooth::SimulateAdapterParameters.from_json('context' => 'c', 'state' => 'powered-sideways') } - .to raise_error(Error::WebDriverError, /state received an unknown value.*powered-sideways/) + .to raise_error(Error::SerializationError, /state received an unknown value.*powered-sideways/) end it 'coerces each element of a list-valued enum' do @@ -566,7 +566,7 @@ def moz_install(**kwargs) expect(klass.new(scrollbar_type: nil).as_json).to eq('scrollbarType' => nil) expect { klass.new(scrollbar_type: :banana) }.to raise_error(ArgumentError, /must be one of/) expect { klass.from_json('scrollbarType' => 'banana') } - .to raise_error(Error::WebDriverError, /received an unknown value/) + .to raise_error(Error::SerializationError, /received an unknown value/) end end @@ -577,42 +577,42 @@ def moz_install(**kwargs) it 'raises when a non-nullable field arrives as explicit null' do expect { Network::Cookie.from_json(cookie_wire.merge('name' => nil)) } - .to raise_error(Error::WebDriverError, /Cookie#name received null but is not nullable/) + .to raise_error(Error::SerializationError, /Cookie#name received null but is not nullable/) end it 'raises when a list-typed field arrives as a scalar' do expect { Network::AddInterceptParameters.from_json('phases' => 'beforeRequestSent') } - .to raise_error(Error::WebDriverError, /phases expected a list/) + .to raise_error(Error::SerializationError, /phases expected a list/) end it 'raises when a scalar-typed field arrives as a list' do expect { Network::Cookie.from_json(cookie_wire.merge('sameSite' => %w[none])) } - .to raise_error(Error::WebDriverError, /same_site expected a single value/) + .to raise_error(Error::SerializationError, /same_site expected a single value/) end it 'raises when an object-typed record arrives as a scalar' do expect { Network::AuthCredentials.from_json('not-an-object') } - .to raise_error(Error::WebDriverError, /AuthCredentials expected an object/) + .to raise_error(Error::SerializationError, /AuthCredentials expected an object/) end it 'raises when a string-typed field arrives as a number' do expect { Network::Cookie.from_json(cookie_wire.merge('name' => 123)) } - .to raise_error(Error::WebDriverError, /name expected string/) + .to raise_error(Error::SerializationError, /name expected string/) end it 'raises when a boolean-typed field arrives as a string' do expect { Network::Cookie.from_json(cookie_wire.merge('secure' => 'yes')) } - .to raise_error(Error::WebDriverError, /secure expected boolean/) + .to raise_error(Error::SerializationError, /secure expected boolean/) end it 'raises when an integer-typed field arrives as a string' do expect { Bluetooth::BluetoothManufacturerData.from_json('key' => 'nope', 'data' => 'x') } - .to raise_error(Error::WebDriverError, /key expected integer/) + .to raise_error(Error::SerializationError, /key expected integer/) end it 'raises when an integer-typed field arrives as a non-integer float' do expect { Bluetooth::BluetoothManufacturerData.from_json('key' => 1.5, 'data' => 'x') } - .to raise_error(Error::WebDriverError, /key expected integer/) + .to raise_error(Error::SerializationError, /key expected integer/) end it 'accepts an integer for an integer-typed field' do @@ -625,7 +625,7 @@ def moz_install(**kwargs) # its leaf primitive, so a wrong-typed value is rejected instead of passing opaque. it 'raises when an alias-typed integer field (js-uint) arrives as a string' do expect { Network::Cookie.from_json(cookie_wire.merge('size' => 'big')) } - .to raise_error(Error::WebDriverError, /size expected integer/) + .to raise_error(Error::SerializationError, /size expected integer/) end end @@ -646,7 +646,7 @@ def moz_install(**kwargs) allow(ENV).to receive(:fetch).with('SE_BIDI_STRICT', '').and_return('true') expect { Bluetooth::RequestDeviceInfo.from_json('id' => 'dev-1') } - .to raise_error(Error::WebDriverError, /RequestDeviceInfo#name is required but was missing/) + .to raise_error(Error::SerializationError, /RequestDeviceInfo#name is required but was missing/) end end end