Bug 1731730 - [bidi] Implement capability matching for features for the session.new command. r=webdriver-reviewers,jdescottes,whimboo

Differential Revision: https://phabricator.services.mozilla.com/D178619
This commit is contained in:
Alexandra Borovova 2023-06-08 11:47:39 +00:00
Родитель 20cac1714b
Коммит e031c9e914
7 изменённых файлов: 446 добавлений и 208 удалений

Просмотреть файл

@ -20,6 +20,19 @@ XPCOMUtils.defineLazyGetter(lazy, "remoteAgent", () => {
return Cc["@mozilla.org/remote/agent;1"].createInstance(Ci.nsIRemoteAgent);
});
// List of capabilities which only relevant for Webdriver Classic.
export const WEBDRIVER_CLASSIC_CAPABILTIES = [
"pageLoadStrategy",
"timeouts",
"strictFileInteractability",
"unhandledPromptBehavior",
"webSocketUrl",
"moz:useNonSpecCompliantPointerOrigin",
"moz:webdriverClick",
"moz:debuggerAddress",
"moz:firefoxOptions",
];
/** Representation of WebDriver session timeouts. */
export class Timeouts {
constructor() {
@ -510,137 +523,166 @@ export class Capabilities extends Map {
if (typeof json == "undefined" || json === null) {
json = {};
}
lazy.assert.object(
json,
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();
for (let [k, v] of Object.entries(json)) {
switch (k) {
case "acceptInsecureCerts":
lazy.assert.boolean(
v,
lazy.pprint`Expected ${k} to be a boolean, got ${v}`
);
break;
case "pageLoadStrategy":
lazy.assert.string(
v,
lazy.pprint`Expected ${k} to be a string, got ${v}`
);
if (!Object.values(PageLoadStrategy).includes(v)) {
throw new lazy.error.InvalidArgumentError(
"Unknown page load strategy: " + v
);
}
break;
case "proxy":
v = Proxy.fromJSON(v);
break;
case "setWindowRect":
lazy.assert.boolean(
v,
lazy.pprint`Expected ${k} to be boolean, got ${v}`
);
if (!lazy.AppInfo.isAndroid && !v) {
throw new lazy.error.InvalidArgumentError(
"setWindowRect cannot be disabled"
);
} else if (lazy.AppInfo.isAndroid && v) {
throw new lazy.error.InvalidArgumentError(
"setWindowRect is only supported on desktop"
);
}
break;
case "timeouts":
v = Timeouts.fromJSON(v);
break;
case "strictFileInteractability":
v = lazy.assert.boolean(v);
break;
case "unhandledPromptBehavior":
lazy.assert.string(
v,
lazy.pprint`Expected ${k} to be a string, got ${v}`
);
if (!Object.values(UnhandledPromptBehavior).includes(v)) {
throw new lazy.error.InvalidArgumentError(
`Unknown unhandled prompt behavior: ${v}`
);
}
break;
case "webSocketUrl":
lazy.assert.boolean(
v,
lazy.pprint`Expected ${k} to be boolean, got ${v}`
);
if (!v) {
throw new lazy.error.InvalidArgumentError(
lazy.pprint`Expected ${k} to be true, got ${v}`
);
}
break;
case "moz:accessibilityChecks":
lazy.assert.boolean(
v,
lazy.pprint`Expected ${k} to be boolean, got ${v}`
);
break;
// Don't set the value because it's only used to return the address
// of the Remote Agent's debugger (HTTP server).
case "moz:debuggerAddress":
continue;
case "moz:useNonSpecCompliantPointerOrigin":
lazy.assert.boolean(
v,
lazy.pprint`Expected ${k} to be boolean, got ${v}`
);
break;
case "moz:webdriverClick":
lazy.assert.boolean(
v,
lazy.pprint`Expected ${k} to be boolean, got ${v}`
);
break;
case "moz:windowless":
lazy.assert.boolean(
v,
lazy.pprint`Expected ${k} to be boolean, got ${v}`
);
// Only supported on MacOS
if (v && !lazy.AppInfo.isMac) {
throw new lazy.error.InvalidArgumentError(
"moz:windowless only supported on MacOS"
);
}
break;
// Don't set the value because it's only used to return the address
// of the Remote Agent's debugger (HTTP server).
if (k == "moz:debuggerAddress") {
continue;
}
if (v !== null) {
capabilities.set(k, Capabilities.validate(k, v));
}
matched.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 <var>value</var> doesn't pass validation,
* which depends on <var>name</var>.
*
* @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 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 boolean, got ${value}`
);
case "moz:accessibilityChecks":
return lazy.assert.boolean(
value,
lazy.pprint`Expected ${name} to be boolean, got ${value}`
);
case "moz:useNonSpecCompliantPointerOrigin":
return lazy.assert.boolean(
value,
lazy.pprint`Expected ${name} to be boolean, got ${value}`
);
case "moz:webdriverClick":
return lazy.assert.boolean(
value,
lazy.pprint`Expected ${name} to be boolean, got ${value}`
);
case "moz:windowless":
lazy.assert.boolean(
value,
lazy.pprint`Expected ${name} to be 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 boolean, got ${value}`
);
default:
lazy.assert.string(
name,
lazy.pprint`Expected capability name to be string, got ${name}`
);
if (name.includes(":")) {
const [prefix] = name.split(":");
if (prefix === "moz") {
throw new lazy.error.InvalidArgumentError(
`${name} is not the name of a known capability or extension capability`
);
}
} else {
throw new lazy.error.InvalidArgumentError(
`${name} is not the name of a known capability or extension capability`
);
}
return value;
}
}
}
@ -735,3 +777,122 @@ function maybeProfile() {
return "<protected>";
}
}
/**
* Merge WebDriver capabilities.
*
* @see https://w3c.github.io/webdriver/#dfn-merging-capabilities
*
* @param {object} primary
* Required capabilities which need to be merged with <var>secondary</var>.
* @param {object} secondary
* Secondary capabilities.
*
* @returns {object} Validated capabilities.
*
* @throws {InvalidArgumentError}
* If <var>primary</var> and <var>secondary</var> 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) {
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 <var>capabilities</var> 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 {Map<string, object>} params.capabilities
* Capabilities which need to be processed.
*
* @returns {object} Processed capabilities.
*
* @throws {InvalidArgumentError}
* If <var>capabilities</var> 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;
}

Просмотреть файл

@ -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" }],
},
})
);
});

Просмотреть файл

@ -36,7 +36,7 @@ export class Browser extends BrowserBase {
static async create(opts: Options): Promise<Browser> {
// 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: [

Просмотреть файл

@ -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_CAPABILTIES:
"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_CAPABILTIES.includes(key)) {
object[key] = value;
}
return object;
},
{}
);
} else if (module === "session" && command === "status") {
result = lazy.RemoteAgent.webDriverBiDi.getSessionReadinessStatus();
} else {

Просмотреть файл

@ -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"

Просмотреть файл

@ -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)

Просмотреть файл

@ -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):