Bug 1898719 - [remote] Add support for "BiDi flag" in WebDriver Session. r=webdriver-reviewers,jdescottes

Differential Revision: https://phabricator.services.mozilla.com/D212307
This commit is contained in:
Henrik Skupin 2024-06-06 07:37:26 +00:00
parent 0ac177013f
commit 3687348b50
12 changed files with 436 additions and 161 deletions

View file

@ -116,7 +116,7 @@ export function GeckoDriver(server) {
this._currentSession = null; this._currentSession = null;
// Flag to indicate a WebDriver HTTP session // Flag to indicate a WebDriver HTTP session
this._flags = new Set([lazy.WebDriverSession.SESSION_FLAG_HTTP]); this._sessionConfigFlags = new Set([lazy.WebDriverSession.SESSION_FLAG_HTTP]);
// Flag to indicate that the application is shutting down // Flag to indicate that the application is shutting down
this._isShuttingDown = false; this._isShuttingDown = false;
@ -418,14 +418,14 @@ GeckoDriver.prototype.newSession = async function (cmd) {
// to handle the WebDriver session. // to handle the WebDriver session.
await lazy.RemoteAgent.webDriverBiDi.createSession( await lazy.RemoteAgent.webDriverBiDi.createSession(
capabilities, capabilities,
this._flags this._sessionConfigFlags
); );
} else { } else {
// If it's not the case then Marionette itself needs to handle it, and // If it's not the case then Marionette itself needs to handle it, and
// has to nullify the "webSocketUrl" capability. // has to nullify the "webSocketUrl" capability.
this._currentSession = new lazy.WebDriverSession( this._currentSession = new lazy.WebDriverSession(
capabilities, capabilities,
this._flags this._sessionConfigFlags
); );
this._currentSession.capabilities.delete("webSocketUrl"); this._currentSession.capabilities.delete("webSocketUrl");
} }

View file

@ -12,7 +12,7 @@ const { error } = ChromeUtils.importESModule(
); );
add_task(async function test_execute_missing_command_error() { add_task(async function test_execute_missing_command_error() {
const session = new WebDriverSession({}, new Set()); const session = new WebDriverSession({}, new Set(["bidi"]));
info("Attempt to execute an unknown protocol command"); info("Attempt to execute an unknown protocol command");
await Assert.rejects( await Assert.rejects(
@ -24,7 +24,7 @@ add_task(async function test_execute_missing_command_error() {
}); });
add_task(async function test_execute_missing_internal_command_error() { add_task(async function test_execute_missing_internal_command_error() {
const session = new WebDriverSession({}, new Set()); const session = new WebDriverSession({}, new Set(["bidi"]));
info( info(
"Attempt to execute a protocol command which relies on an unknown internal method" "Attempt to execute a protocol command which relies on an unknown internal method"

View file

@ -51,10 +51,18 @@ export const WEBDRIVER_CLASSIC_CAPABILITIES = [
"webSocketUrl", "webSocketUrl",
// Gecko specific capabilities // Gecko specific capabilities
"moz:accessibilityChecks",
"moz:debuggerAddress", "moz:debuggerAddress",
"moz:firefoxOptions", "moz:firefoxOptions",
"moz:useNonSpecCompliantPointerOrigin", "moz:useNonSpecCompliantPointerOrigin",
"moz:webdriverClick", "moz:webdriverClick",
// Extension capabilities
"webauthn:extension:credBlob",
"webauthn:extension:largeBlob",
"webauthn:extension:prf",
"webauthn:extension:uvm",
"webauthn:virtualAuthenticators",
]; ];
/** Representation of WebDriver session timeouts. */ /** Representation of WebDriver session timeouts. */
@ -436,36 +444,46 @@ export class Proxy {
export class Capabilities extends Map { export class Capabilities extends Map {
/** /**
* WebDriver session capabilities representation. * WebDriver session capabilities representation.
*
* @param {boolean} isBidi
* Flag indicating that it is a WebDriver BiDi session. Defaults to false.
*/ */
constructor() { constructor(isBidi = false) {
// Default values for capabilities supported by both WebDriver protocols // Default values for capabilities supported by both WebDriver protocols
super([ const defaults = [
["acceptInsecureCerts", false],
["browserName", getWebDriverBrowserName()], ["browserName", getWebDriverBrowserName()],
["browserVersion", lazy.AppInfo.version], ["browserVersion", lazy.AppInfo.version],
["platformName", getWebDriverPlatformName()], ["platformName", getWebDriverPlatformName()],
["acceptInsecureCerts", false],
["proxy", new Proxy()], ["proxy", new Proxy()],
["unhandledPromptBehavior", new lazy.UserPromptHandler()], ["unhandledPromptBehavior", new lazy.UserPromptHandler()],
["userAgent", lazy.userAgent], ["userAgent", lazy.userAgent],
// HTTP only capabilities
["pageLoadStrategy", PageLoadStrategy.Normal],
["timeouts", new Timeouts()],
["setWindowRect", !lazy.AppInfo.isAndroid],
["strictFileInteractability", false],
// Gecko specific capabilities // Gecko specific capabilities
["moz:accessibilityChecks", false],
["moz:buildID", lazy.AppInfo.appBuildID], ["moz:buildID", lazy.AppInfo.appBuildID],
["moz:debuggerAddress", lazy.debuggerAddress],
["moz:headless", lazy.isHeadless], ["moz:headless", lazy.isHeadless],
["moz:platformVersion", Services.sysinfo.getProperty("version")], ["moz:platformVersion", Services.sysinfo.getProperty("version")],
["moz:processID", lazy.AppInfo.processID], ["moz:processID", lazy.AppInfo.processID],
["moz:profile", maybeProfile()], ["moz:profile", maybeProfile()],
["moz:shutdownTimeout", lazy.shutdownTimeout], ["moz:shutdownTimeout", lazy.shutdownTimeout],
];
if (!isBidi) {
// HTTP-only capabilities
defaults.push(
["pageLoadStrategy", PageLoadStrategy.Normal],
["timeouts", new Timeouts()],
["setWindowRect", !lazy.AppInfo.isAndroid],
["strictFileInteractability", false],
["moz:accessibilityChecks", false],
["moz:debuggerAddress", lazy.debuggerAddress],
["moz:webdriverClick", true], ["moz:webdriverClick", true],
["moz:windowless", false], ["moz:windowless", false]
]); );
}
super(defaults);
} }
/** /**
@ -512,13 +530,13 @@ export class Capabilities extends Map {
* *
* @param {Object<string, *>=} json * @param {Object<string, *>=} json
* WebDriver capabilities. * WebDriver capabilities.
* @param {boolean=} isHttp * @param {boolean=} isBidi
* Flag indicating that it is a WebDriver classic session. Defaults to false. * Flag indicating that it is a WebDriver BiDi session. Defaults to false.
* *
* @returns {Capabilities} * @returns {Capabilities}
* Internal representation of WebDriver capabilities. * Internal representation of WebDriver capabilities.
*/ */
static fromJSON(json, isHttp = false) { static fromJSON(json, isBidi = false) {
if (typeof json == "undefined" || json === null) { if (typeof json == "undefined" || json === null) {
json = {}; json = {};
} }
@ -527,11 +545,12 @@ export class Capabilities extends Map {
lazy.pprint`Expected "capabilities" to be an object, got ${json}"` lazy.pprint`Expected "capabilities" to be an object, got ${json}"`
); );
const capabilities = new Capabilities(); const capabilities = new Capabilities(isBidi);
// TODO: Bug 1823907. We can start using here spec compliant method `validate`, // TODO: Bug 1823907. We can start using here spec compliant method `validate`,
// as soon as `desiredCapabilities` and `requiredCapabilities` are not supported. // as soon as `desiredCapabilities` and `requiredCapabilities` are not supported.
for (let [k, v] of Object.entries(json)) { for (let [k, v] of Object.entries(json)) {
if (!isHttp && WEBDRIVER_CLASSIC_CAPABILITIES.includes(k)) { if (isBidi && WEBDRIVER_CLASSIC_CAPABILITIES.includes(k)) {
// Ignore any WebDriver classic capability for a WebDriver BiDi session. // Ignore any WebDriver classic capability for a WebDriver BiDi session.
continue; continue;
} }

View file

@ -39,6 +39,8 @@ const webDriverSessions = new Map();
* define additional flags, or create sessions without the HTTP flag. * define additional flags, or create sessions without the HTTP flag.
* *
* <dl> * <dl>
* <dt><code>"bidi"</code> (string)
* <dd>Flag indicating a WebDriver BiDi session.
* <dt><code>"http"</code> (string) * <dt><code>"http"</code> (string)
* <dd>Flag indicating a WebDriver classic (HTTP) session. * <dd>Flag indicating a WebDriver classic (HTTP) session.
* </dl> * </dl>
@ -48,6 +50,7 @@ const webDriverSessions = new Map();
* Representation of WebDriver session. * Representation of WebDriver session.
*/ */
export class WebDriverSession { export class WebDriverSession {
#bidi;
#capabilities; #capabilities;
#connections; #connections;
#http; #http;
@ -55,6 +58,7 @@ export class WebDriverSession {
#messageHandler; #messageHandler;
#path; #path;
static SESSION_FLAG_BIDI = "bidi";
static SESSION_FLAG_HTTP = "http"; static SESSION_FLAG_HTTP = "http";
/** /**
@ -206,13 +210,26 @@ export class WebDriverSession {
this.#connections = new Set(); this.#connections = new Set();
this.#id = lazy.generateUUID(); this.#id = lazy.generateUUID();
// Flags for WebDriver session features
this.#bidi = flags.has(WebDriverSession.SESSION_FLAG_BIDI);
this.#http = flags.has(WebDriverSession.SESSION_FLAG_HTTP); this.#http = flags.has(WebDriverSession.SESSION_FLAG_HTTP);
if (this.#bidi == this.#http) {
// Initially a WebDriver session can either be HTTP or BiDi. An upgrade of a
// HTTP session to offer BiDi features is done after the constructor is run.
throw new lazy.error.SessionNotCreatedError(
`Initially the WebDriver session needs to be either HTTP or BiDi (bidi=${
this.#bidi
}, http=${this.#http})`
);
}
// Define the HTTP path to query this session via WebDriver BiDi // Define the HTTP path to query this session via WebDriver BiDi
this.#path = `/session/${this.#id}`; this.#path = `/session/${this.#id}`;
try { try {
this.#capabilities = lazy.Capabilities.fromJSON(capabilities, this.#http); this.#capabilities = lazy.Capabilities.fromJSON(capabilities, this.#bidi);
} catch (e) { } catch (e) {
throw new lazy.error.SessionNotCreatedError(e); throw new lazy.error.SessionNotCreatedError(e);
} }
@ -280,29 +297,6 @@ export class WebDriverSession {
} }
} }
async execute(module, command, params) {
// XXX: At the moment, commands do not describe consistently their destination,
// so we will need a translation step based on a specific command and its params
// in order to extract a destination that can be understood by the MessageHandler.
//
// For now, an option is to send all commands to ROOT, and all BiDi MessageHandler
// modules will therefore need to implement this translation step in the root
// implementation of their module.
const destination = {
type: lazy.RootMessageHandler.type,
};
if (!this.messageHandler.supportsCommand(module, command, destination)) {
throw new lazy.error.UnknownCommandError(`${module}.${command}`);
}
return this.messageHandler.handleCommand({
moduleName: module,
commandName: command,
params,
destination,
});
}
get a11yChecks() { get a11yChecks() {
return this.#capabilities.get("moz:accessibilityChecks"); return this.#capabilities.get("moz:accessibilityChecks");
} }
@ -311,6 +305,14 @@ export class WebDriverSession {
return this.#capabilities.get("acceptInsecureCerts"); return this.#capabilities.get("acceptInsecureCerts");
} }
get bidi() {
return this.#bidi;
}
set bidi(value) {
this.#bidi = value;
}
get capabilities() { get capabilities() {
return this.#capabilities; return this.#capabilities;
} }
@ -366,6 +368,33 @@ export class WebDriverSession {
return this.#capabilities.get("unhandledPromptBehavior"); return this.#capabilities.get("unhandledPromptBehavior");
} }
get webSocketUrl() {
return this.#capabilities.get("webSocketUrl");
}
async execute(module, command, params) {
// XXX: At the moment, commands do not describe consistently their destination,
// so we will need a translation step based on a specific command and its params
// in order to extract a destination that can be understood by the MessageHandler.
//
// For now, an option is to send all commands to ROOT, and all BiDi MessageHandler
// modules will therefore need to implement this translation step in the root
// implementation of their module.
const destination = {
type: lazy.RootMessageHandler.type,
};
if (!this.messageHandler.supportsCommand(module, command, destination)) {
throw new lazy.error.UnknownCommandError(`${module}.${command}`);
}
return this.messageHandler.handleCommand({
moduleName: module,
commandName: command,
params,
destination,
});
}
/** /**
* Remove the specified WebDriver BiDi connection. * Remove the specified WebDriver BiDi connection.
* *

View file

@ -369,8 +369,15 @@ add_task(function test_Proxy_fromJSON() {
deepEqual(p, Proxy.fromJSON(manual)); deepEqual(p, Proxy.fromJSON(manual));
}); });
add_task(function test_Capabilities_ctor() { add_task(function test_Capabilities_ctor_http_default() {
let caps = new Capabilities(); const caps = new Capabilities();
equal(true, caps.get("moz:webdriverClick"));
});
add_task(function test_Capabilities_ctor_http() {
const caps = new Capabilities(false);
ok(caps.has("browserName")); ok(caps.has("browserName"));
ok(caps.has("browserVersion")); ok(caps.has("browserVersion"));
ok(caps.has("platformName")); ok(caps.has("platformName"));
@ -390,6 +397,30 @@ add_task(function test_Capabilities_ctor() {
ok(caps.has("moz:processID")); ok(caps.has("moz:processID"));
ok(caps.has("moz:profile")); ok(caps.has("moz:profile"));
equal(true, caps.get("moz:webdriverClick")); equal(true, caps.get("moz:webdriverClick"));
});
add_task(function test_Capabilities_ctor_bidi() {
const caps = new Capabilities(true);
ok(caps.has("browserName"));
ok(caps.has("browserVersion"));
ok(caps.has("platformName"));
ok(["linux", "mac", "windows", "android"].includes(caps.get("platformName")));
equal(undefined, caps.get("pageLoadStrategy"));
equal(false, caps.get("acceptInsecureCerts"));
ok(!caps.has("timeouts"));
ok(caps.get("proxy") instanceof Proxy);
ok(!caps.has("setWindowRect"));
ok(!caps.has("strictFileInteractability"));
ok(!caps.has("webSocketUrl"));
ok(!caps.has("moz:accessibilityChecks"));
ok(caps.has("moz:buildID"));
ok(!caps.has("moz:debuggerAddress"));
ok(caps.has("moz:platformVersion"));
ok(caps.has("moz:processID"));
ok(caps.has("moz:profile"));
ok(!caps.has("moz:webdriverClick"));
// No longer supported capabilities // No longer supported capabilities
ok(!caps.has("moz:useNonSpecCompliantPointerOrigin")); ok(!caps.has("moz:useNonSpecCompliantPointerOrigin"));
@ -423,125 +454,164 @@ add_task(function test_Capabilities_toJSON() {
equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]); equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]);
}); });
add_task(function test_Capabilities_fromJSON() { add_task(function test_Capabilities_fromJSON_http() {
const { fromJSON } = Capabilities; const { fromJSON } = Capabilities;
// plain // plain
for (let typ of [{}, null, undefined]) { for (const type of [{}, null, undefined]) {
ok(fromJSON(typ).has("browserName")); ok(fromJSON(type, false).has("browserName"));
} }
// matching let caps;
let caps = new Capabilities();
caps = fromJSON({ acceptInsecureCerts: true }); // Capabilities supported by HTTP and BiDi
caps = fromJSON({ acceptInsecureCerts: true }, false);
equal(true, caps.get("acceptInsecureCerts")); equal(true, caps.get("acceptInsecureCerts"));
caps = fromJSON({ acceptInsecureCerts: false });
equal(false, caps.get("acceptInsecureCerts"));
let proxyConfig = { proxyType: "manual" }; let proxyConfig = { proxyType: "manual" };
caps = fromJSON({ proxy: proxyConfig }); caps = fromJSON({ proxy: proxyConfig }, false);
equal("manual", caps.get("proxy").proxyType); equal("manual", caps.get("proxy").proxyType);
// HTTP only capabilities // WebDriver HTTP-only capabilities
for (let strategy of Object.values(PageLoadStrategy)) { for (let strategy of Object.values(PageLoadStrategy)) {
caps = fromJSON({ pageLoadStrategy: strategy }, true); caps = fromJSON({ pageLoadStrategy: strategy }, false);
equal(strategy, caps.get("pageLoadStrategy")); equal(strategy, caps.get("pageLoadStrategy"));
caps = fromJSON({ pageLoadStrategy: strategy });
equal("normal", caps.get("pageLoadStrategy"));
} }
let timeoutsConfig = { implicit: 123 }; let timeoutsConfig = { implicit: 123 };
caps = fromJSON({ timeouts: timeoutsConfig }); caps = fromJSON({ timeouts: timeoutsConfig }, false);
equal(0, caps.get("timeouts").implicit);
caps = fromJSON({ timeouts: timeoutsConfig }, true);
equal(123, caps.get("timeouts").implicit); equal(123, caps.get("timeouts").implicit);
caps = fromJSON({ strictFileInteractability: false }, true); caps = fromJSON({ strictFileInteractability: true }, false);
equal(false, caps.get("strictFileInteractability"));
caps = fromJSON({ strictFileInteractability: true }, true);
equal(true, caps.get("strictFileInteractability")); equal(true, caps.get("strictFileInteractability"));
caps = fromJSON({ webSocketUrl: true }, true); caps = fromJSON({ webSocketUrl: true }, false);
equal(true, caps.get("webSocketUrl")); equal(true, caps.get("webSocketUrl"));
// Mozilla specific capabilities // Mozilla specific capabilities
caps = fromJSON({ "moz:accessibilityChecks": true }); caps = fromJSON({ "moz:accessibilityChecks": true }, false);
equal(true, caps.get("moz:accessibilityChecks")); equal(true, caps.get("moz:accessibilityChecks"));
caps = fromJSON({ "moz:accessibilityChecks": false });
equal(false, caps.get("moz:accessibilityChecks"));
caps = fromJSON({ "moz:webdriverClick": true }, true); caps = fromJSON({ "moz:webdriverClick": true }, false);
equal(true, caps.get("moz:webdriverClick")); equal(true, caps.get("moz:webdriverClick"));
caps = fromJSON({ "moz:webdriverClick": false }, true);
equal(false, caps.get("moz:webdriverClick"));
// capability is always populated with null if remote agent is not listening // capability is always populated with null if remote agent is not listening
caps = fromJSON({}); caps = fromJSON({}, false);
equal(null, caps.get("moz:debuggerAddress")); equal(null, caps.get("moz:debuggerAddress"));
caps = fromJSON({ "moz:debuggerAddress": "foo" }); caps = fromJSON({ "moz:debuggerAddress": "foo" }, false);
equal(null, caps.get("moz:debuggerAddress")); equal(null, caps.get("moz:debuggerAddress"));
caps = fromJSON({ "moz:debuggerAddress": true }); caps = fromJSON({ "moz:debuggerAddress": true }, false);
equal(null, caps.get("moz:debuggerAddress")); equal(null, caps.get("moz:debuggerAddress"));
// Extension capabilities // Extension capabilities
caps = fromJSON({ "webauthn:virtualAuthenticators": true }); caps = fromJSON({ "webauthn:virtualAuthenticators": true }, false);
equal(true, caps.get("webauthn:virtualAuthenticators")); equal(true, caps.get("webauthn:virtualAuthenticators"));
caps = fromJSON({ "webauthn:virtualAuthenticators": false });
equal(false, caps.get("webauthn:virtualAuthenticators"));
Assert.throws( Assert.throws(
() => fromJSON({ "webauthn:virtualAuthenticators": "foo" }), () => fromJSON({ "webauthn:virtualAuthenticators": "foo" }, false),
/InvalidArgumentError/ /InvalidArgumentError/
); );
caps = fromJSON({ "webauthn:extension:uvm": true }); caps = fromJSON({ "webauthn:extension:uvm": true }, false);
equal(true, caps.get("webauthn:extension:uvm")); equal(true, caps.get("webauthn:extension:uvm"));
caps = fromJSON({ "webauthn:extension:uvm": false });
equal(false, caps.get("webauthn:extension:uvm"));
Assert.throws( Assert.throws(
() => fromJSON({ "webauthn:extension:uvm": "foo" }), () => fromJSON({ "webauthn:extension:uvm": "foo" }, false),
/InvalidArgumentError/ /InvalidArgumentError/
); );
caps = fromJSON({ "webauthn:extension:prf": true }); caps = fromJSON({ "webauthn:extension:prf": true }, false);
equal(true, caps.get("webauthn:extension:prf")); equal(true, caps.get("webauthn:extension:prf"));
caps = fromJSON({ "webauthn:extension:prf": false });
equal(false, caps.get("webauthn:extension:prf"));
Assert.throws( Assert.throws(
() => fromJSON({ "webauthn:extension:prf": "foo" }), () => fromJSON({ "webauthn:extension:prf": "foo" }, false),
/InvalidArgumentError/ /InvalidArgumentError/
); );
caps = fromJSON({ "webauthn:extension:largeBlob": true }); caps = fromJSON({ "webauthn:extension:largeBlob": true }, false);
equal(true, caps.get("webauthn:extension:largeBlob")); equal(true, caps.get("webauthn:extension:largeBlob"));
caps = fromJSON({ "webauthn:extension:largeBlob": false });
equal(false, caps.get("webauthn:extension:largeBlob"));
Assert.throws( Assert.throws(
() => fromJSON({ "webauthn:extension:largeBlob": "foo" }), () => fromJSON({ "webauthn:extension:largeBlob": "foo" }, false),
/InvalidArgumentError/ /InvalidArgumentError/
); );
caps = fromJSON({ "webauthn:extension:credBlob": true }); caps = fromJSON({ "webauthn:extension:credBlob": true }, false);
equal(true, caps.get("webauthn:extension:credBlob")); equal(true, caps.get("webauthn:extension:credBlob"));
caps = fromJSON({ "webauthn:extension:credBlob": false });
equal(false, caps.get("webauthn:extension:credBlob"));
Assert.throws( Assert.throws(
() => fromJSON({ "webauthn:extension:credBlob": "foo" }), () => fromJSON({ "webauthn:extension:credBlob": "foo" }, false),
/InvalidArgumentError/ /InvalidArgumentError/
); );
// No longer supported capabilities // No longer supported capabilities
Assert.throws( Assert.throws(
() => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": false }, true), () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true }, false),
/InvalidArgumentError/
);
Assert.throws(
() => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true }, true),
/InvalidArgumentError/ /InvalidArgumentError/
); );
}); });
add_task(function test_Capabilities_fromJSON_bidi() {
const { fromJSON } = Capabilities;
// plain
for (const type of [{}, null, undefined]) {
ok(fromJSON(type, true).has("browserName"));
}
let caps;
// Capabilities supported by HTTP and BiDi
caps = fromJSON({ acceptInsecureCerts: true }, true);
equal(true, caps.get("acceptInsecureCerts"));
let proxyConfig = { proxyType: "manual" };
caps = fromJSON({ proxy: proxyConfig }, true);
equal("manual", caps.get("proxy").proxyType);
// HTTP capabilities are ignored for BiDi-only sessions
for (let strategy of Object.values(PageLoadStrategy)) {
caps = fromJSON({ pageLoadStrategy: strategy }, true);
ok(!caps.has("pageLoadStrategy"));
}
let timeoutsConfig = { implicit: 123 };
caps = fromJSON({ timeouts: timeoutsConfig }, true);
ok(!caps.has("timeouts"));
caps = fromJSON({ strictFileInteractability: true }, true);
ok(!caps.has("strictFileInteractability"));
caps = fromJSON({ webSocketUrl: true }, true);
ok(!caps.has("webSocketUrl"));
// Mozilla specific capabilities
caps = fromJSON({ "moz:accessibilityChecks": true }, true);
ok(!caps.has("moz:accessibilityChecks"));
caps = fromJSON({ "moz:webdriverClick": true }, true);
equal(undefined, caps.get("moz:webdriverClick"));
// capability is always populated with null if remote agent is not listening
caps = fromJSON({}, true);
equal(null, caps.get("moz:debuggerAddress"));
caps = fromJSON({ "moz:debuggerAddress": "foo" }, true);
equal(null, caps.get("moz:debuggerAddress"));
caps = fromJSON({ "moz:debuggerAddress": true }, true);
equal(null, caps.get("moz:debuggerAddress"));
// Extension capabilities
caps = fromJSON({ "webauthn:virtualAuthenticators": true }, true);
ok(!caps.has("webauthn:virtualAuthenticators"));
caps = fromJSON({ "webauthn:extension:uvm": true }, true);
ok(!caps.has("webauthn:extension:uvm"));
caps = fromJSON({ "webauthn:extension:prf": true }, true);
ok(!caps.has("webauthn:extension:prf"));
caps = fromJSON({ "webauthn:extension:largeBlob": true }, true);
ok(!caps.has("webauthn:extension:largeBlob"));
caps = fromJSON({ "webauthn:extension:credBlob": true }, true);
ok(!caps.has("webauthn:extension:credBlob"));
});
add_task(function test_mergeCapabilities() { add_task(function test_mergeCapabilities() {
// Shadowed values. // Shadowed values.
Assert.throws( Assert.throws(

View file

@ -13,10 +13,12 @@ const { getWebDriverSessionById, WebDriverSession } =
); );
function createSession(options = {}) { function createSession(options = {}) {
const { capabilities = {}, connection, isHttp = false } = options; const { capabilities = {}, connection, isBidi = false } = options;
const flags = new Set(); const flags = new Set();
if (isHttp) { if (isBidi) {
flags.add("bidi");
} else {
flags.add("http"); flags.add("http");
} }
@ -24,17 +26,28 @@ function createSession(options = {}) {
} }
add_task(function test_WebDriverSession_ctor() { add_task(function test_WebDriverSession_ctor() {
// Missing WebDriver session flags
Assert.throws(() => new WebDriverSession({}), /TypeError/); Assert.throws(() => new WebDriverSession({}), /TypeError/);
// Session needs to be either HTTP or BiDi
for (const flags of [[], ["bidi", "http"]]) {
Assert.throws(
() => new WebDriverSession({}, new Set(flags)),
/SessionNotCreatedError:/
);
}
// Session id and path // Session id and path
let session = createSession(); let session = createSession();
equal(typeof session.id, "string"); equal(typeof session.id, "string");
equal(session.path, `/session/${session.id}`); equal(session.path, `/session/${session.id}`);
// Sets HTTP flag // Sets HTTP and BiDi flags correctly.
session = createSession({ isHttp: true }); session = createSession({ isBidi: false });
equal(session.bidi, false);
equal(session.http, true); equal(session.http, true);
session = createSession({ isHttp: false }); session = createSession({ isBidi: true });
equal(session.bidi, true);
equal(session.http, false); equal(session.http, false);
// Sets capabilities based on session configuration flag. // Sets capabilities based on session configuration flag.
@ -49,20 +62,21 @@ add_task(function test_WebDriverSession_ctor() {
}; };
// HTTP session // HTTP session
session = createSession({ isHttp: true, capabilities }); session = createSession({ capabilities, isBidi: false });
equal(session.acceptInsecureCerts, true); equal(session.acceptInsecureCerts, true);
equal(session.pageLoadStrategy, "eager"); equal(session.pageLoadStrategy, "eager");
equal(session.strictFileInteractability, true); equal(session.strictFileInteractability, true);
equal(session.timeouts.script, 1000); equal(session.timeouts.script, 1000);
equal(session.userPromptHandler.toJSON(), "ignore"); equal(session.userPromptHandler.toJSON(), "ignore");
// BiDi session (uses default values for HTTP only capabilities) // BiDi session
session = createSession({ isHttp: false, capabilities }); session = createSession({ capabilities, isBidi: true });
equal(session.acceptInsecureCerts, true); equal(session.acceptInsecureCerts, true);
equal(session.pageLoadStrategy, "normal");
equal(session.strictFileInteractability, false);
equal(session.timeouts.script, 30000);
equal(session.userPromptHandler.toJSON(), "dismiss and notify"); equal(session.userPromptHandler.toJSON(), "dismiss and notify");
equal(session.pageLoadStrategy, undefined);
equal(session.strictFileInteractability, undefined);
equal(session.timeouts, undefined);
}); });
add_task(function test_WebDriverSession_destroy() { add_task(function test_WebDriverSession_destroy() {

View file

@ -60,11 +60,37 @@ export class WebDriverBiDi {
return this.#session; return this.#session;
} }
#newSessionAlgorithm(session, flags) {
if (!this.#agent.running) {
// With the Remote Agent not running WebDriver BiDi is not supported.
return;
}
if (flags.has(lazy.WebDriverSession.SESSION_FLAG_BIDI)) {
// It's already a WebDriver BiDi session.
return;
}
const webSocketUrl = session.capabilities.get("webSocketUrl");
if (webSocketUrl === undefined) {
return;
}
// Start listening for BiDi connections.
this.#agent.server.registerPathHandler(session.path, session);
lazy.logger.debug(`Registered session handler: ${session.path}`);
session.capabilities.set("webSocketUrl", `${this.address}${session.path}`);
session.bidi = true;
flags.add("bidi");
}
/** /**
* Add a new connection that is not yet attached to a WebDriver session. * Add a new connection that is not yet attached to a WebDriver session.
* *
* @param {WebDriverBiDiConnection} connection * @param {WebDriverBiDiConnection} connection
* The connection without an accociated WebDriver session. * The connection without an associated WebDriver session.
*/ */
addSessionlessConnection(connection) { addSessionlessConnection(connection) {
this.#sessionlessConnections.add(connection); this.#sessionlessConnections.add(connection);
@ -79,7 +105,7 @@ export class WebDriverBiDi {
* @param {Set} flags * @param {Set} flags
* Session configuration flags. * Session configuration flags.
* @param {WebDriverBiDiConnection=} sessionlessConnection * @param {WebDriverBiDiConnection=} sessionlessConnection
* Optional connection that is not yet accociated with a WebDriver * Optional connection that is not yet associated with a WebDriver
* session, and has to be associated with the new WebDriver session. * session, and has to be associated with the new WebDriver session.
* *
* @returns {Object<string, Capabilities>} * @returns {Object<string, Capabilities>}
@ -95,19 +121,21 @@ export class WebDriverBiDi {
); );
} }
const session = new lazy.WebDriverSession( this.#session = new lazy.WebDriverSession(
capabilities, capabilities,
flags, flags,
sessionlessConnection sessionlessConnection
); );
// When the Remote Agent is listening, and a BiDi WebSocket connection // Run new session steps for WebDriver BiDi.
// has been requested, register a path handler for the session. this.#newSessionAlgorithm(this.#session, flags);
let webSocketUrl = null;
if ( if (sessionlessConnection) {
this.#agent.running && // Connection is now registered with a WebDriver session
(session.capabilities.get("webSocketUrl") || sessionlessConnection) this.#sessionlessConnections.delete(sessionlessConnection);
) { }
if (this.#session.bidi) {
// Creating a WebDriver BiDi session too early can cause issues with // Creating a WebDriver BiDi session too early can cause issues with
// clients in not being able to find any available browsing context. // clients in not being able to find any available browsing context.
// Also when closing the application while it's still starting up can // Also when closing the application while it's still starting up can
@ -115,23 +143,7 @@ export class WebDriverBiDi {
// once the initial application window has finished initializing. // once the initial application window has finished initializing.
lazy.logger.debug(`Waiting for initial application window`); lazy.logger.debug(`Waiting for initial application window`);
await this.#agent.browserStartupFinished; await this.#agent.browserStartupFinished;
this.#agent.server.registerPathHandler(session.path, session);
webSocketUrl = `${this.address}${session.path}`;
lazy.logger.debug(`Registered session handler: ${session.path}`);
if (sessionlessConnection) {
// Remove temporary session-less connection
this.#sessionlessConnections.delete(sessionlessConnection);
} }
}
// Also update the webSocketUrl capability to contain the session URL if
// a path handler has been registered. Otherwise set its value to null.
session.capabilities.set("webSocketUrl", webSocketUrl);
this.#session = session;
return { return {
sessionId: this.#session.id, sessionId: this.#session.id,

View file

@ -14,8 +14,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
"chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
quit: "chrome://remote/content/shared/Browser.sys.mjs", quit: "chrome://remote/content/shared/Browser.sys.mjs",
RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs",
WEBDRIVER_CLASSIC_CAPABILITIES: WebDriverSession: "chrome://remote/content/shared/webdriver/Session.sys.mjs",
"chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
}); });
ChromeUtils.defineLazyGetter(lazy, "logger", () => ChromeUtils.defineLazyGetter(lazy, "logger", () =>
@ -23,6 +22,8 @@ ChromeUtils.defineLazyGetter(lazy, "logger", () =>
); );
export class WebDriverBiDiConnection extends WebSocketConnection { export class WebDriverBiDiConnection extends WebSocketConnection {
#sessionConfigFlags;
/** /**
* @param {WebSocket} webSocket * @param {WebSocket} webSocket
* The WebSocket server connection to wrap. * The WebSocket server connection to wrap.
@ -34,6 +35,10 @@ export class WebDriverBiDiConnection extends WebSocketConnection {
// Each connection has only a single associated WebDriver session. // Each connection has only a single associated WebDriver session.
this.session = null; this.session = null;
this.#sessionConfigFlags = new Set([
lazy.WebDriverSession.SESSION_FLAG_BIDI,
]);
} }
/** /**
@ -180,25 +185,11 @@ export class WebDriverBiDiConnection extends WebSocketConnection {
if (module === "session" && command === "new") { if (module === "session" && command === "new") {
const processedCapabilities = lazy.processCapabilities(params); const processedCapabilities = lazy.processCapabilities(params);
const flags = new Set();
result = await lazy.RemoteAgent.webDriverBiDi.createSession( result = await lazy.RemoteAgent.webDriverBiDi.createSession(
processedCapabilities, processedCapabilities,
flags, this.#sessionConfigFlags,
this this
); );
// The Capabilities class sets up default values also for capabilities
// which are not relevant for BiDi, we want to remove those from the payload.
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") { } else if (module === "session" && command === "status") {
result = lazy.RemoteAgent.webDriverBiDi.getSessionReadinessStatus(); result = lazy.RemoteAgent.webDriverBiDi.getSessionReadinessStatus();
} else { } else {

View file

@ -1,8 +1,13 @@
[DEFAULT] [DEFAULT]
tags = "wd" tags = "wd"
subsuite = "remote" subsuite = "remote"
args = [
"--remote-debugging-port",
]
support-files = ["head.js"] support-files = ["head.js"]
["browser_RemoteValue.js"] ["browser_RemoteValue.js"]
["browser_RemoteValueDOM.js"] ["browser_RemoteValueDOM.js"]
["browser_WebDriverBiDi.js"]

View file

@ -0,0 +1,126 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { RemoteAgent: RemoteAgentModule } = ChromeUtils.importESModule(
"chrome://remote/content/components/RemoteAgent.sys.mjs"
);
const { WebDriverBiDi } = ChromeUtils.importESModule(
"chrome://remote/content/webdriver-bidi/WebDriverBiDi.sys.mjs"
);
async function runBiDiTest(test, options = {}) {
const { capabilities = {}, connection, isBidi = false } = options;
const flags = new Set();
if (isBidi) {
flags.add("bidi");
} else {
flags.add("http");
}
const wdBiDi = new WebDriverBiDi(RemoteAgentModule);
await wdBiDi.createSession(capabilities, flags, connection);
await test(wdBiDi);
wdBiDi.deleteSession();
}
add_task(async function test_createSession() {
// Missing WebDriver session flags
const bidi = new WebDriverBiDi(RemoteAgent);
await Assert.rejects(bidi.createSession({}), /TypeError/);
// Session needs to be either HTTP or BiDi
for (const flags of [[], ["bidi", "http"]]) {
await Assert.rejects(
bidi.createSession({}, new Set(flags)),
/SessionNotCreatedError:/
);
}
// Session id and path
await runBiDiTest(wdBiDi => {
is(typeof wdBiDi.session.id, "string");
is(wdBiDi.session.path, `/session/${wdBiDi.session.id}`);
});
// Sets HTTP and BiDi flags correctly.
await runBiDiTest(
wdBiDi => {
is(wdBiDi.session.bidi, false);
is(wdBiDi.session.http, true);
},
{ isBidi: false }
);
await runBiDiTest(
wdBiDi => {
is(wdBiDi.session.bidi, true);
is(wdBiDi.session.http, false);
},
{ isBidi: true }
);
// Sets capabilities based on session configuration flag.
const capabilities = {
acceptInsecureCerts: true,
unhandledPromptBehavior: "ignore",
// HTTP only
pageLoadStrategy: "eager",
strictFileInteractability: true,
timeouts: { script: 1000 },
// Bug 1731730: Requires matching for session.new command.
// webSocketUrl: false,
};
// HTTP session (no webSocketUrl)
await runBiDiTest(
wdBiDi => {
is(wdBiDi.session.bidi, false);
is(wdBiDi.session.acceptInsecureCerts, true);
is(wdBiDi.session.pageLoadStrategy, "eager");
is(wdBiDi.session.strictFileInteractability, true);
is(wdBiDi.session.timeouts.script, 1000);
is(wdBiDi.session.userPromptHandler.toJSON(), "ignore");
is(wdBiDi.session.webSocketUrl, undefined);
},
{ capabilities, isBidi: false }
);
// HTTP session (with webSocketUrl)
capabilities.webSocketUrl = true;
await runBiDiTest(
wdBiDi => {
is(wdBiDi.session.bidi, true);
is(wdBiDi.session.acceptInsecureCerts, true);
is(wdBiDi.session.pageLoadStrategy, "eager");
is(wdBiDi.session.strictFileInteractability, true);
is(wdBiDi.session.timeouts.script, 1000);
is(wdBiDi.session.userPromptHandler.toJSON(), "ignore");
is(
wdBiDi.session.webSocketUrl,
`ws://127.0.0.1:9222/session/${wdBiDi.session.id}`
);
},
{ capabilities, isBidi: false }
);
// BiDi session
await runBiDiTest(
wdBiDi => {
is(wdBiDi.session.bidi, true);
is(wdBiDi.session.acceptInsecureCerts, true);
is(wdBiDi.session.userPromptHandler.toJSON(), "dismiss and notify");
is(wdBiDi.session.pageLoadStrategy, undefined);
is(wdBiDi.session.strictFileInteractability, undefined);
is(wdBiDi.session.timeouts, undefined);
is(wdBiDi.session.webSocketUrl, undefined);
},
{ capabilities, isBidi: true }
);
});

View file

@ -49,3 +49,12 @@ async def test_proxy(
) )
assert response == {"type": "string", "value": page_content} assert response == {"type": "string", "value": page_content}
@pytest.mark.parametrize("match_type", ["alwaysMatch", "firstMatch"])
async def test_websocket_url(new_session, match_capabilities, match_type):
capabilities = match_capabilities(match_type, "webSocketUrl", True)
bidi_session = await new_session(capabilities=capabilities)
assert bidi_session.capabilities.get("webSocketUrl") is None

View file

@ -26,7 +26,8 @@ async def test_capability_type(new_session, add_browser_capabilities):
("browserVersion", str), ("browserVersion", str),
("platformName", str), ("platformName", str),
("proxy", dict), ("proxy", dict),
("setWindowRect", bool), ("unhandledPromptBehavior", str),
("userAgent", str),
] ]
assert isinstance(bidi_session.capabilities, dict) assert isinstance(bidi_session.capabilities, dict)
@ -70,7 +71,6 @@ async def test_ignore_non_spec_fields_in_capabilities(
("pageLoadStrategy", "none"), ("pageLoadStrategy", "none"),
("strictFileInteractability", True), ("strictFileInteractability", True),
("timeouts", {"script": 500}), ("timeouts", {"script": 500}),
("unhandledPromptBehavior", "accept"),
], ],
) )
async def test_with_webdriver_classic_capabilities( async def test_with_webdriver_classic_capabilities(