diff --git a/remote/shared/webdriver/Capabilities.sys.mjs b/remote/shared/webdriver/Capabilities.sys.mjs index 0a418267847d..51eb30b80ae6 100644 --- a/remote/shared/webdriver/Capabilities.sys.mjs +++ b/remote/shared/webdriver/Capabilities.sys.mjs @@ -20,6 +20,19 @@ XPCOMUtils.defineLazyGetter(lazy, "remoteAgent", () => { return Cc["@mozilla.org/remote/agent;1"].createInstance(Ci.nsIRemoteAgent); }); +// List of capabilities which are only relevant for Webdriver Classic. +export const WEBDRIVER_CLASSIC_CAPABILITIES = [ + "pageLoadStrategy", + "timeouts", + "strictFileInteractability", + "unhandledPromptBehavior", + "webSocketUrl", + "moz:useNonSpecCompliantPointerOrigin", + "moz:webdriverClick", + "moz:debuggerAddress", + "moz:firefoxOptions", +]; + /** Representation of WebDriver session timeouts. */ export class Timeouts { constructor() { @@ -515,13 +528,9 @@ export class Capabilities extends Map { lazy.pprint`Expected "capabilities" to be an object, got ${json}"` ); - return Capabilities.match_(json); - } - - // Matches capabilities as described by WebDriver. - static match_(json = {}) { - let matched = new Capabilities(); - + const capabilities = new Capabilities(); + // TODO: Bug 1823907. We can start using here spec compliant method `validate`, + // as soon as `desiredCapabilities` and `requiredCapabilities` are not supported. for (let [k, v] of Object.entries(json)) { switch (k) { case "acceptInsecureCerts": @@ -636,11 +645,152 @@ export class Capabilities extends Map { } break; } - - matched.set(k, v); + capabilities.set(k, v); } - return matched; + return capabilities; + } + + /** + * Validate WebDriver capability. + * + * @param {string} name + * The name of capability. + * @param {string} value + * The value of capability. + * + * @throws {InvalidArgumentError} + * If value doesn't pass validation, + * which depends on name. + * + * @returns {string} + * The validated capability value. + */ + static validate(name, value) { + if (value === null) { + return value; + } + switch (name) { + case "acceptInsecureCerts": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + return value; + + case "browserName": + case "browserVersion": + case "platformName": + return lazy.assert.string( + value, + lazy.pprint`Expected ${name} to be a string, got ${value}` + ); + + case "pageLoadStrategy": + lazy.assert.string( + value, + lazy.pprint`Expected ${name} to be a string, got ${value}` + ); + if (!Object.values(PageLoadStrategy).includes(value)) { + throw new lazy.error.InvalidArgumentError( + "Unknown page load strategy: " + value + ); + } + return value; + + case "proxy": + return Proxy.fromJSON(value); + + case "strictFileInteractability": + return lazy.assert.boolean(value); + + case "timeouts": + return Timeouts.fromJSON(value); + + case "unhandledPromptBehavior": + lazy.assert.string( + value, + lazy.pprint`Expected ${name} to be a string, got ${value}` + ); + if (!Object.values(UnhandledPromptBehavior).includes(value)) { + throw new lazy.error.InvalidArgumentError( + `Unknown unhandled prompt behavior: ${value}` + ); + } + return value; + + case "webSocketUrl": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + if (!value) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected ${name} to be true, got ${value}` + ); + } + return value; + + case "moz:firefoxOptions": + return lazy.assert.object( + value, + lazy.pprint`Expected ${name} to be an object, got ${value}` + ); + + case "moz:accessibilityChecks": + return lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + case "moz:useNonSpecCompliantPointerOrigin": + return lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + case "moz:webdriverClick": + return lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + case "moz:windowless": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + // Only supported on MacOS + if (value && !lazy.AppInfo.isMac) { + throw new lazy.error.InvalidArgumentError( + "moz:windowless only supported on MacOS" + ); + } + return value; + + case "moz:debuggerAddress": + return lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + default: + lazy.assert.string( + name, + lazy.pprint`Expected capability name to be a string, got ${name}` + ); + if (name.includes(":")) { + const [prefix] = name.split(":"); + if (prefix !== "moz") { + return value; + } + } + throw new lazy.error.InvalidArgumentError( + `${name} is not the name of a known capability or extension capability` + ); + } } } @@ -735,3 +885,125 @@ function maybeProfile() { return ""; } } + +/** + * Merge WebDriver capabilities. + * + * @see https://w3c.github.io/webdriver/#dfn-merging-capabilities + * + * @param {object} primary + * Required capabilities which need to be merged with secondary. + * @param {object=} secondary + * Secondary capabilities. + * + * @returns {object} Merged capabilities. + * + * @throws {InvalidArgumentError} + * If primary and secondary have the same keys. + */ +export function mergeCapabilities(primary, secondary) { + const result = { ...primary }; + + if (secondary === undefined) { + return result; + } + + Object.entries(secondary).forEach(([name, value]) => { + if (primary[name] !== undefined) { + // Since at the moment we always pass as `primary` `alwaysMatch` object + // and as `secondary` an item from `firstMatch` array from `capabilities`, + // we can make this error message more specific. + throw new lazy.error.InvalidArgumentError( + `firstMatch key ${name} shadowed a value in alwaysMatch` + ); + } + result[name] = value; + }); + + return result; +} + +/** + * Validate WebDriver capabilities. + * + * @see https://w3c.github.io/webdriver/#dfn-validate-capabilities + * + * @param {object} capabilities + * Capabilities which need to be validated. + * + * @returns {object} Validated capabilities. + * + * @throws {InvalidArgumentError} + * If capabilities is not an object. + */ +export function validateCapabilities(capabilities) { + lazy.assert.object(capabilities); + + const result = {}; + + Object.entries(capabilities).forEach(([name, value]) => { + const deserialized = Capabilities.validate(name, value); + if (deserialized !== null) { + if (name === "proxy" || name === "timeouts") { + // Return pure value, the Proxy and Timeouts objects will be setup + // during session creation. + result[name] = value; + } else { + result[name] = deserialized; + } + } + }); + + return result; +} + +/** + * Process WebDriver capabilities. + * + * @see https://w3c.github.io/webdriver/#processing-capabilities + * + * @param {object} params + * @param {object} params.capabilities + * Capabilities which need to be processed. + * + * @returns {object} Processed capabilities. + * + * @throws {InvalidArgumentError} + * If capabilities do not satisfy the criteria. + */ +export function processCapabilities(params) { + const { capabilities } = params; + lazy.assert.object(capabilities); + + let { + alwaysMatch: requiredCapabilities = {}, + firstMatch: allFirstMatchCapabilities = [{}], + } = capabilities; + + requiredCapabilities = validateCapabilities(requiredCapabilities); + + lazy.assert.array(allFirstMatchCapabilities); + lazy.assert.that( + firstMatch => firstMatch.length >= 1, + lazy.pprint`Expected firstMatch ${allFirstMatchCapabilities} to have at least 1 entry` + )(allFirstMatchCapabilities); + + const validatedFirstMatchCapabilities = + allFirstMatchCapabilities.map(validateCapabilities); + + const mergedCapabilities = []; + validatedFirstMatchCapabilities.forEach(firstMatchCapabilities => { + const merged = mergeCapabilities( + requiredCapabilities, + firstMatchCapabilities + ); + mergedCapabilities.push(merged); + }); + + // TODO: Bug 1836288. Implement the capability matching logic + // for "browserName", "browserVersion" and "platformName" features, + // for now we can just pick the first merged capability. + const matchedCapabilities = mergedCapabilities[0]; + + return matchedCapabilities; +} diff --git a/remote/shared/webdriver/test/xpcshell/test_Capabilities.js b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js index e88c0126c9d3..74e788e7a39e 100644 --- a/remote/shared/webdriver/test/xpcshell/test_Capabilities.js +++ b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js @@ -16,10 +16,13 @@ const { error } = ChromeUtils.importESModule( ); const { Capabilities, + mergeCapabilities, PageLoadStrategy, + processCapabilities, Proxy, Timeouts, UnhandledPromptBehavior, + validateCapabilities, } = ChromeUtils.importESModule( "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs" ); @@ -439,9 +442,6 @@ add_task(function test_Capabilities_fromJSON() { for (let typ of [{}, null, undefined]) { ok(fromJSON(typ).has("browserName")); } - for (let typ of [true, 42, "foo", []]) { - Assert.throws(() => fromJSON(typ), /InvalidArgumentError/); - } // matching let caps = new Capabilities(); @@ -450,23 +450,11 @@ add_task(function test_Capabilities_fromJSON() { equal(true, caps.get("acceptInsecureCerts")); caps = fromJSON({ acceptInsecureCerts: false }); equal(false, caps.get("acceptInsecureCerts")); - Assert.throws( - () => fromJSON({ acceptInsecureCerts: "foo" }), - /InvalidArgumentError/ - ); for (let strategy of Object.values(PageLoadStrategy)) { caps = fromJSON({ pageLoadStrategy: strategy }); equal(strategy, caps.get("pageLoadStrategy")); } - Assert.throws( - () => fromJSON({ pageLoadStrategy: "foo" }), - /InvalidArgumentError/ - ); - Assert.throws( - () => fromJSON({ pageLoadStrategy: null }), - /InvalidArgumentError/ - ); let proxyConfig = { proxyType: "manual" }; caps = fromJSON({ proxy: proxyConfig }); @@ -476,20 +464,6 @@ add_task(function test_Capabilities_fromJSON() { caps = fromJSON({ timeouts: timeoutsConfig }); equal(123, caps.get("timeouts").implicit); - if (!AppInfo.isAndroid) { - caps = fromJSON({ setWindowRect: true }); - equal(true, caps.get("setWindowRect")); - Assert.throws( - () => fromJSON({ setWindowRect: false }), - /InvalidArgumentError/ - ); - } else { - Assert.throws( - () => fromJSON({ setWindowRect: true }), - /InvalidArgumentError/ - ); - } - caps = fromJSON({ strictFileInteractability: false }); equal(false, caps.get("strictFileInteractability")); caps = fromJSON({ strictFileInteractability: true }); @@ -497,27 +471,11 @@ add_task(function test_Capabilities_fromJSON() { caps = fromJSON({ webSocketUrl: true }); equal(true, caps.get("webSocketUrl")); - Assert.throws( - () => fromJSON({ webSocketUrl: false }), - /InvalidArgumentError/ - ); - Assert.throws( - () => fromJSON({ webSocketUrl: "foo" }), - /InvalidArgumentError/ - ); caps = fromJSON({ "moz:accessibilityChecks": true }); equal(true, caps.get("moz:accessibilityChecks")); caps = fromJSON({ "moz:accessibilityChecks": false }); equal(false, caps.get("moz:accessibilityChecks")); - Assert.throws( - () => fromJSON({ "moz:accessibilityChecks": "foo" }), - /InvalidArgumentError/ - ); - Assert.throws( - () => fromJSON({ "moz:accessibilityChecks": 1 }), - /InvalidArgumentError/ - ); // capability is always populated with null if remote agent is not listening caps = fromJSON({}); @@ -531,26 +489,137 @@ add_task(function test_Capabilities_fromJSON() { equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin")); caps = fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true }); equal(true, caps.get("moz:useNonSpecCompliantPointerOrigin")); - Assert.throws( - () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": "foo" }), - /InvalidArgumentError/ - ); - Assert.throws( - () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": 1 }), - /InvalidArgumentError/ - ); - caps = fromJSON({ "moz:webdriverClick": true }); equal(true, caps.get("moz:webdriverClick")); caps = fromJSON({ "moz:webdriverClick": false }); equal(false, caps.get("moz:webdriverClick")); +}); + +add_task(function test_mergeCapabilities() { + // Shadowed values. Assert.throws( - () => fromJSON({ "moz:webdriverClick": "foo" }), + () => + mergeCapabilities( + { acceptInsecureCerts: true }, + { acceptInsecureCerts: false } + ), /InvalidArgumentError/ ); - Assert.throws( - () => fromJSON({ "moz:webdriverClick": 1 }), - /InvalidArgumentError/ + + deepEqual( + { acceptInsecureCerts: true }, + mergeCapabilities({ acceptInsecureCerts: true }, undefined) + ); + deepEqual( + { acceptInsecureCerts: true, browserName: "Firefox" }, + mergeCapabilities({ acceptInsecureCerts: true }, { browserName: "Firefox" }) + ); +}); + +add_task(function test_validateCapabilities_invalid() { + const invalidCapabilities = [ + true, + 42, + "foo", + [], + { acceptInsecureCerts: "foo" }, + { browserName: true }, + { browserVersion: true }, + { platformName: true }, + { pageLoadStrategy: "foo" }, + { proxy: false }, + { strictFileInteractability: "foo" }, + { timeouts: false }, + { unhandledPromptBehavior: false }, + { webSocketUrl: false }, + { webSocketUrl: "foo" }, + { "moz:firefoxOptions": "foo" }, + { "moz:accessibilityChecks": "foo" }, + { "moz:useNonSpecCompliantPointerOrigin": "foo" }, + { "moz:useNonSpecCompliantPointerOrigin": 1 }, + { "moz:webdriverClick": "foo" }, + { "moz:webdriverClick": 1 }, + { "moz:debuggerAddress": "foo" }, + { "moz:someRandomString": {} }, + ]; + for (const capabilities of invalidCapabilities) { + Assert.throws( + () => validateCapabilities(capabilities), + /InvalidArgumentError/ + ); + } +}); + +add_task(function test_validateCapabilities_valid() { + // Ignore null value. + deepEqual({}, validateCapabilities({ test: null })); + + const validCapabilities = [ + { acceptInsecureCerts: true }, + { browserName: "firefox" }, + { browserVersion: "12" }, + { platformName: "linux" }, + { pageLoadStrategy: "eager" }, + { proxy: { proxyType: "manual", httpProxy: "test.com" } }, + { strictFileInteractability: true }, + { timeouts: { pageLoad: 500 } }, + { unhandledPromptBehavior: "accept" }, + { webSocketUrl: true }, + { "moz:firefoxOptions": {} }, + { "moz:accessibilityChecks": true }, + { "moz:useNonSpecCompliantPointerOrigin": true }, + { "moz:webdriverClick": true }, + { "moz:debuggerAddress": true }, + { "test:extension": "foo" }, + ]; + for (const validCapability of validCapabilities) { + deepEqual(validCapability, validateCapabilities(validCapability)); + } +}); + +add_task(function test_processCapabilities() { + for (const invalidValue of [ + { capabilities: null }, + { capabilities: undefined }, + { capabilities: "foo" }, + { capabilities: true }, + { capabilities: [] }, + { capabilities: { alwaysMatch: null } }, + { capabilities: { alwaysMatch: "foo" } }, + { capabilities: { alwaysMatch: true } }, + { capabilities: { alwaysMatch: [] } }, + { capabilities: { firstMatch: null } }, + { capabilities: { firstMatch: "foo" } }, + { capabilities: { firstMatch: true } }, + { capabilities: { firstMatch: {} } }, + { capabilities: { firstMatch: [] } }, + ]) { + Assert.throws( + () => processCapabilities(invalidValue), + /InvalidArgumentError/ + ); + } + + deepEqual( + { acceptInsecureCerts: true }, + processCapabilities({ + capabilities: { alwaysMatch: { acceptInsecureCerts: true } }, + }) + ); + deepEqual( + { browserName: "Firefox" }, + processCapabilities({ + capabilities: { firstMatch: [{ browserName: "Firefox" }] }, + }) + ); + deepEqual( + { acceptInsecureCerts: true, browserName: "Firefox" }, + processCapabilities({ + capabilities: { + alwaysMatch: { acceptInsecureCerts: true }, + firstMatch: [{ browserName: "Firefox" }], + }, + }) ); }); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Browser.ts index 9741ce7129d5..0aa06c21382b 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Browser.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Browser.ts @@ -36,7 +36,7 @@ export class Browser extends BrowserBase { static async create(opts: Options): Promise { // TODO: await until the connection is established. try { - await opts.connection.send('session.new', {}); + await opts.connection.send('session.new', { capabilities: {}}); } catch {} await opts.connection.send('session.subscribe', { events: [ diff --git a/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs b/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs index 96482222b34d..e102a5086b82 100644 --- a/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs +++ b/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs @@ -12,7 +12,11 @@ ChromeUtils.defineESModuleGetters(lazy, { assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", Log: "chrome://remote/content/shared/Log.sys.mjs", + processCapabilities: + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", + WEBDRIVER_CLASSIC_CAPABILITIES: + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", }); XPCOMUtils.defineLazyGetter(lazy, "logger", () => @@ -156,11 +160,25 @@ export class WebDriverBiDiConnection extends WebSocketConnection { // Handle static commands first if (module === "session" && command === "new") { - // TODO: Needs capability matching code + const processedCapabilities = lazy.processCapabilities(params); + result = await lazy.RemoteAgent.webDriverBiDi.createSession( - params, + processedCapabilities, this ); + + // Since in Capabilities class we setup default values also for capabilities which are + // not relevant for bidi, we want to remove them from the payload before returning to a client. + result.capabilities = Array.from(result.capabilities.entries()).reduce( + (object, [key, value]) => { + if (!lazy.WEBDRIVER_CLASSIC_CAPABILITIES.includes(key)) { + object[key] = value; + } + + return object; + }, + {} + ); } else if (module === "session" && command === "status") { result = lazy.RemoteAgent.webDriverBiDi.getSessionReadinessStatus(); } else { diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py index f78552ee121f..72de324f15f0 100644 --- a/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py @@ -171,7 +171,7 @@ class TestCapabilityMatching(MarionetteTestCase): self.delete_session() - for value in ["", "EAGER", True, 42, {}, [], None]: + for value in ["", "EAGER", True, 42, {}, []]: print("invalid strategy {}".format(value)) with self.assertRaisesRegexp( SessionNotCreatedException, "InvalidArgumentError" @@ -179,20 +179,10 @@ class TestCapabilityMatching(MarionetteTestCase): self.marionette.start_session({"pageLoadStrategy": value}) def test_set_window_rect(self): - if self.browser_name == "firefox": - self.marionette.start_session({"setWindowRect": True}) - self.delete_session() - with self.assertRaisesRegexp( - SessionNotCreatedException, "InvalidArgumentError" - ): - self.marionette.start_session({"setWindowRect": False}) - else: + with self.assertRaisesRegexp( + SessionNotCreatedException, "InvalidArgumentError" + ): self.marionette.start_session({"setWindowRect": False}) - self.delete_session() - with self.assertRaisesRegexp( - SessionNotCreatedException, "InvalidArgumentError" - ): - self.marionette.start_session({"setWindowRect": True}) def test_timeouts(self): for value in ["", 2.5, {}, []]: @@ -260,7 +250,7 @@ class TestCapabilityMatching(MarionetteTestCase): # Invalid values self.delete_session() - for behavior in [None, "", "ACCEPT", True, 42, {}, []]: + for behavior in ["", "ACCEPT", True, 42, {}, []]: print("invalid unhandled prompt behavior {}".format(behavior)) with self.assertRaisesRegexp( SessionNotCreatedException, "InvalidArgumentError" diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py index 281bf806b841..609bed052778 100644 --- a/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py @@ -48,7 +48,7 @@ class TestEnforcePreferences(MarionetteTestCase): def test_restart_preserves_requested_capabilities(self): self.marionette.delete_session() - self.marionette.start_session(capabilities={"moz:fooBar": True}) + self.marionette.start_session(capabilities={"test:fooBar": True}) self.enforce_prefs() - self.assertEqual(self.marionette.session.get("moz:fooBar"), True) + self.assertEqual(self.marionette.session.get("test:fooBar"), True) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py index dc4bc71715f9..f41b374896be 100644 --- a/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py @@ -190,10 +190,10 @@ class TestQuitRestart(MarionetteTestCase): def test_restart_preserves_requested_capabilities(self): self.marionette.delete_session() - self.marionette.start_session(capabilities={"moz:fooBar": True}) + self.marionette.start_session(capabilities={"test:fooBar": True}) self.marionette.restart(in_app=False) - self.assertEqual(self.marionette.session.get("moz:fooBar"), True) + self.assertEqual(self.marionette.session.get("test:fooBar"), True) def test_restart_safe_mode(self): try: @@ -304,11 +304,11 @@ class TestQuitRestart(MarionetteTestCase): def test_in_app_restart_preserves_requested_capabilities(self): self.marionette.delete_session() - self.marionette.start_session(capabilities={"moz:fooBar": True}) + self.marionette.start_session(capabilities={"test:fooBar": True}) details = self.marionette.restart() self.assertTrue(details["in_app"], "Expected in_app restart") - self.assertEqual(self.marionette.session.get("moz:fooBar"), True) + self.assertEqual(self.marionette.session.get("test:fooBar"), True) @unittest.skipUnless(sys.platform.startswith("darwin"), "Only supported on MacOS") def test_in_app_silent_restart_fails_without_windowless_flag_on_mac_os(self):