diff --git a/javascript/selenium-webdriver/BUILD.bazel b/javascript/selenium-webdriver/BUILD.bazel index 292370e429251..0fe8ce60d7369 100644 --- a/javascript/selenium-webdriver/BUILD.bazel +++ b/javascript/selenium-webdriver/BUILD.bazel @@ -39,6 +39,7 @@ js_library( "common/*.js", "bidi/*.js", "bidi/external/*.js", + "bidi/emulation/*.js", ]), deps = [ ":node_modules/@bazel/runfiles", diff --git a/javascript/selenium-webdriver/bidi/emulation/emulation.js b/javascript/selenium-webdriver/bidi/emulation/emulation.js new file mode 100644 index 0000000000000..5fcbbda39eb14 --- /dev/null +++ b/javascript/selenium-webdriver/bidi/emulation/emulation.js @@ -0,0 +1,94 @@ +// 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. + +const GeolocationCoordinates = require('./geolocationCoordinates') + +const GeolocationPositionError = Object.freeze({ + type: 'positionUnavailable', +}) + +/** + * Emulation class provides methods to interact with browser emulation features + * via the BiDi protocol. + */ +class Emulation { + constructor(driver) { + this._driver = driver + } + + async init() { + if (!(await this._driver.getCapabilities()).get('webSocketUrl')) { + throw Error('WebDriver instance must support BiDi protocol') + } + + this.bidi = await this._driver.getBidi() + } + + /** + * Overrides the browser's geolocation. + * @param {GeolocationCoordinates|GeolocationPositionError} value - Geolocation coordinates or error constant. + * @param {string|string[]|undefined} contexts - Optional browsing context(s) to apply the override. + * @param {string|string[]|undefined} userContexts - Optional user context(s) to apply the override. + * @throws {Error} If arguments are invalid or the BiDi command fails. + */ + async setGeolocationOverride(value, contexts = undefined, userContexts = undefined) { + const map = new Map() + + if (value instanceof GeolocationCoordinates) { + map.set('coordinates', Object.fromEntries(value.asMap())) + } else if (value === GeolocationPositionError) { + map.set('error', value) + } else { + throw new Error('First argument must be a GeoCoordinates instance or GeolocationPositionError constant') + } + + if (contexts !== undefined && typeof contexts === 'string') { + contexts = [contexts] + } else if (contexts !== undefined && !Array.isArray(contexts)) { + throw new Error('contexts must be a string or an array of strings') + } + + map.set('contexts', contexts) + + if (userContexts !== undefined && typeof userContexts === 'string') { + userContexts = [userContexts] + } else if (userContexts !== undefined && !Array.isArray(userContexts)) { + throw new Error('userContexts must be a string or an array of strings') + } + + map.set('userContexts', userContexts) + + const command = { + method: 'emulation.setGeolocationOverride', + params: Object.fromEntries(map), + } + + const response = await this.bidi.send(command) + + if (response.type === 'error') { + throw new Error(`${response.error}: ${response.message}`) + } + } +} + +async function getEmulationInstance(driver) { + let instance = new Emulation(driver) + await instance.init() + return instance +} + +module.exports = { getEmulationInstance, GeolocationPositionError } diff --git a/javascript/selenium-webdriver/bidi/emulation/geolocationCoordinates.js b/javascript/selenium-webdriver/bidi/emulation/geolocationCoordinates.js new file mode 100644 index 0000000000000..d5fe04c11f9a1 --- /dev/null +++ b/javascript/selenium-webdriver/bidi/emulation/geolocationCoordinates.js @@ -0,0 +1,137 @@ +// 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. + +/** + * Represents geolocation coordinates with optional accuracy, altitude, heading, and speed. + * + * Example usage: + * const coords = new GeolocationCoordinates(37.7749, -122.4194) + * .accuracy(10) + * .altitude(30) + * .heading(90) + * .speed(5); + * + * Properties: + * - latitude: number (-90.0 to 90.0) + * - longitude: number (-180.0 to 180.0) + * - accuracy: number >= 0.0 (optional) + * - altitude: number or null (optional) + * - altitudeAccuracy: number >= 0.0 or null (optional) + * - heading: number 0.0 to 360.0 or null (optional) + * - speed: number >= 0.0 or null (optional) + */ +class GeolocationCoordinates { + #map = new Map() + + /** + * Constructs a GeolocationCoordinates instance. + * @param {number} latitude - Latitude (-90.0 to 90.0). + * @param {number} longitude - Longitude (-180.0 to 180.0). + * @throws {Error} If latitude or longitude are out of bounds. + */ + constructor(latitude, longitude) { + if (typeof latitude !== 'number' || latitude < -90.0 || latitude > 90.0) { + throw new Error(`Latitude must be a number between -90.0 and 90.0. Received: '${latitude}'`) + } + this.#map.set('latitude', latitude) + + if (typeof longitude !== 'number' || longitude < -180.0 || longitude > 180.0) { + throw new Error(`Longitude must be a number between -180.0 and 180.0. Received: '${longitude}'`) + } + this.#map.set('longitude', longitude) + } + + /** + * Sets the accuracy in meters. + * @param {number} value - Accuracy (>= 0.0). + * @returns {GeolocationCoordinates} This instance. + * @throws {Error} If value is invalid. + */ + accuracy(value) { + if (typeof value !== 'number' || value < 0.0) { + throw new Error(`Accuracy must be a number >= 0.0. Received: '${value}'`) + } + this.#map.set('accuracy', value) + return this + } + + /** + * Sets the altitude in meters. + * @param {number|null} value - Altitude or null. + * @returns {GeolocationCoordinates} This instance. + * @throws {Error} If value is invalid. + */ + altitude(value) { + if (value !== null && typeof value !== 'number') { + throw new Error(`Altitude must be a number. Received: '${value}'`) + } + this.#map.set('altitude', value) + return this + } + + /** + * Sets the altitude accuracy in meters. + * @param {number|null} value - Altitude accuracy (>= 0.0) or null. + * @returns {GeolocationCoordinates} This instance. + * @throws {Error} If value is invalid. + */ + altitudeAccuracy(value) { + if (value !== null && (typeof value !== 'number' || value < 0.0)) { + throw new Error(`AltitudeAccuracy must be a number >= 0.0. Received: '${value}'`) + } + this.#map.set('altitudeAccuracy', value) + return this + } + + /** + * Sets the heading in degrees. + * @param {number|null} value - Heading (0.0 to 360.0) or null. + * @returns {GeolocationCoordinates} This instance. + * @throws {Error} If value is invalid. + */ + heading(value) { + if (value !== null && (typeof value !== 'number' || value < 0.0 || value > 360.0)) { + throw new Error(`Heading must be a number between 0.0 and 360.0. Received: '${value}'`) + } + this.#map.set('heading', value) + return this + } + + /** + * Sets the speed in meters per second. + * @param {number|null} value - Speed (>= 0.0) or null. + * @returns {GeolocationCoordinates} This instance. + * @throws {Error} If value is invalid. + */ + speed(value) { + if (value !== null && (typeof value !== 'number' || value < 0.0)) { + throw new Error(`Speed must be a number >= 0.0. Received: '${value}'`) + } + this.#map.set('speed', value) + return this + } + + /** + * Returns the internal map of coordinate properties. + * @returns {Map} Map of properties. + */ + asMap() { + return this.#map + } +} + +module.exports = GeolocationCoordinates diff --git a/javascript/selenium-webdriver/test/bidi/emulation_test.js b/javascript/selenium-webdriver/test/bidi/emulation_test.js new file mode 100644 index 0000000000000..e021d8d1a2ab0 --- /dev/null +++ b/javascript/selenium-webdriver/test/bidi/emulation_test.js @@ -0,0 +1,161 @@ +// 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. + +'use strict' + +const assert = require('node:assert') +const { Pages, suite, ignore } = require('../../lib/test') +const { Browser } = require('selenium-webdriver') +const BrowserBiDi = require('selenium-webdriver/bidi/browser') +const getScriptManager = require('selenium-webdriver/bidi/scriptManager') +const { GeolocationPositionError, getEmulationInstance } = require('selenium-webdriver/bidi/emulation/emulation') +const GeolocationCoordinates = require('selenium-webdriver/bidi/emulation/geolocationCoordinates') +const BrowsingContext = require('selenium-webdriver/bidi/browsingContext') +const { getPermissionInstance, PermissionState } = require('selenium-webdriver/bidi/external/permissions') +const { CreateContextParameters } = require('selenium-webdriver/bidi/createContextParameters') + +suite( + function (env) { + describe('BiDi Emulation', function () { + let driver, emulation, permission, script, browser, windowHandle + + const GET_ORIGIN = '() => {return window.location.origin;}' + + const GET_CURRENT_GEOLOCATION = ` + new Promise((resolve) => { + navigator.geolocation.getCurrentPosition( + position => { + const coords = position.coords; + resolve({ + latitude: coords.latitude, + longitude: coords.longitude, + accuracy: coords.accuracy, + altitude: coords.altitude, + altitudeAccuracy: coords.altitudeAccuracy, + heading: coords.heading, + speed: coords.speed, + timestamp: position.timestamp + }); + }, + error => resolve({ error: error.message }), + { enableHighAccuracy: false, timeout: 10000, maximumAge: 0 } + ); + })` + + beforeEach(async function () { + driver = await env.builder().build() + emulation = await getEmulationInstance(driver) + permission = await getPermissionInstance(driver) + script = await getScriptManager([], driver) + browser = await BrowserBiDi(driver) + windowHandle = await driver.getWindowHandle() + }) + + afterEach(function () { + return driver.quit() + }) + + it('can override geolocation for browsing context', async function () { + const context = await BrowsingContext(driver, { browsingContextId: windowHandle }) + await context.navigate(Pages.blankPage, 'complete') + + const origin = await script.callFunctionInBrowsingContext(context.id, GET_ORIGIN, true, []) + const originValue = origin.result.value + await permission.setPermission({ name: 'geolocation' }, PermissionState.GRANTED, originValue) + + const coords = new GeolocationCoordinates(37.7749, -122.4194) + + await emulation.setGeolocationOverride(coords, windowHandle) + + const result = await script.evaluateFunctionInBrowsingContext(context.id, GET_CURRENT_GEOLOCATION, true) + + const geolocation = result.result.value + const currentLatitude = geolocation.latitude.value + const currentLongitude = geolocation.longitude.value + + assert.strictEqual(currentLatitude, 37.7749) + assert.strictEqual(currentLongitude, -122.4194) + }) + + it('can override geolocation for user context', async function () { + const userContext1 = await browser.createUserContext() + const userContext2 = await browser.createUserContext() + + const createParams1 = new CreateContextParameters().userContext(userContext1) + const createParams2 = new CreateContextParameters().userContext(userContext2) + + const context1 = await BrowsingContext(driver, { type: 'tab', createParameters: createParams1 }) + + const context2 = await BrowsingContext(driver, { type: 'tab', createParameters: createParams2 }) + + const coords = new GeolocationCoordinates(45.5, -122.4194) + + await emulation.setGeolocationOverride(coords, undefined, [userContext1, userContext2]) + + await driver.switchTo().window(context1.id) + + await context1.navigate(Pages.blankPage, 'complete') + const origin1 = (await script.callFunctionInBrowsingContext(context1.id, GET_ORIGIN, true, [])).result.value + await permission.setPermission({ name: 'geolocation' }, PermissionState.GRANTED, origin1, userContext1) + + const result1 = await script.evaluateFunctionInBrowsingContext(context1.id, GET_CURRENT_GEOLOCATION, true) + const geolocation1 = result1.result.value + const currentLatitude1 = geolocation1.latitude.value + const currentLongitude1 = geolocation1.longitude.value + + assert.strictEqual(currentLatitude1, 45.5) + assert.strictEqual(currentLongitude1, -122.4194) + + await driver.switchTo().window(context2.id) + + await context2.navigate(Pages.blankPage, 'complete') + const origin2 = (await script.callFunctionInBrowsingContext(context1.id, GET_ORIGIN, true, [])).result.value + await permission.setPermission({ name: 'geolocation' }, PermissionState.GRANTED, origin2, userContext2) + + const result2 = await script.evaluateFunctionInBrowsingContext(context2.id, GET_CURRENT_GEOLOCATION, true) + const geolocation2 = result2.result.value + const currentLatitude2 = geolocation2.latitude.value + const currentLongitude2 = geolocation2.longitude.value + + assert.strictEqual(currentLatitude2, 45.5) + assert.strictEqual(currentLongitude2, -122.4194) + }) + + ignore(env.browsers(Browser.FIREFOX)).it('can override geolocation with error', async function () { + const context = await BrowsingContext(driver, { browsingContextId: windowHandle }) + await context.navigate(Pages.blankPage, 'complete') + + const origin = await script.callFunctionInBrowsingContext(context.id, GET_ORIGIN, true, []) + const originValue = origin.result.value + + await permission.setPermission({ name: 'geolocation' }, PermissionState.GRANTED, originValue) + + await emulation.setGeolocationOverride(GeolocationPositionError, windowHandle) + + const result = await script.evaluateFunctionInBrowsingContext(context.id, GET_CURRENT_GEOLOCATION, true) + + const geolocation = result.result.value + + assert.ok( + Object.hasOwn(geolocation, 'error'), + `Expected geolocation to have 'error' key, but got: ${JSON.stringify(geolocation)}`, + ) + }) + }) + }, + { browsers: [Browser.FIREFOX, Browser.CHROME, Browser.EDGE] }, +)