diff options
Diffstat (limited to 'remote')
1123 files changed, 202652 insertions, 0 deletions
diff --git a/remote/.gitignore b/remote/.gitignore new file mode 100644 index 0000000000..fa0a34f63d --- /dev/null +++ b/remote/.gitignore @@ -0,0 +1,23 @@ +test/puppeteer/**/.wireit +test/puppeteer/**/*.tsbuildinfo +test/puppeteer/**/lib +test/puppeteer/.github +test/puppeteer/.husky +test/puppeteer/coverage/ +test/puppeteer/.devcontainer/ +test/puppeteer/docker/ +test/puppeteer/docs/puppeteer-core.api.json +test/puppeteer/docs/puppeteer.api.json +test/puppeteer/experimental/ +test/puppeteer/node_modules/ +test/puppeteer/package-lock.json +test/puppeteer/packages/ng-schematics/test/build +test/puppeteer/packages/puppeteer-core/src/generated +test/puppeteer/test/installation/puppeteer*.tgz +test/puppeteer/src/generated +test/puppeteer/test/**/build +test/puppeteer/test/output-firefox +test/puppeteer/test/output-chromium +test/puppeteer/tools/internal/ +test/puppeteer/tools/mocha-runner/bin/ +test/puppeteer/website diff --git a/remote/README.md b/remote/README.md new file mode 100644 index 0000000000..d9b8a73467 --- /dev/null +++ b/remote/README.md @@ -0,0 +1,74 @@ +The Firefox remote agent is a low-level debugging interface based +on the CDP protocol. + +With it, you can inspect the state and control execution of documents +running in web content, instrument Gecko in interesting ways, +simulate user interaction for automation purposes, and debug +JavaScript execution. + +This component provides an experimental and partial implementation +of a remote devtools interface using the CDP protocol and transport +layer. + +See https://firefox-source-docs.mozilla.org/remote/ for documentation. + +It is available in Firefox and is started this way: + + % ./mach run --remote-debugging-port + + +Puppeteer +========= +Puppeteer is a Node library which provides a high-level API to control Chrome, +Chromium, and Firefox over the Chrome DevTools Protocol. Puppeteer runs headless +by default, but can be configured to run full (non-headless) browsers. + +To verify that our implementation of the CDP protocol is valid we do not only +run xpcshell and browser-chrome mochitests in Firefox CI but also the Puppeteer +unit tests. + +Expectation Data +---------------- + +With the tests coming from upstream, it is not guaranteed that they +all pass in Gecko-based browsers. For this reason it is necessary to +provide metadata about the expected results of each test. This is +provided in a manifest file under `test/puppeteer-expected.json`. + +For each test of the Puppeteer unit test suite an equivalent entry will exist +in this manifest file. By default tests are expected to `PASS`. + +Tests that are intermittent may be marked with multiple statuses using +a list of possibilities e.g. for a test that usually passes, but +intermittently fails: + + "Page.click should click the button (click.spec.ts)": [ + "PASS", "FAIL" + ], + +Disabling Tests +--------------- + +Tests are disabled by using the manifest file `test/puppeteer-expected.json`. +For example, if a test is unstable, it can be disabled using `SKIP`: + + "Workers Page.workers (worker.spec.ts)": [ + "SKIP" + ], + +For intermittents it's generally preferable to give the test multiple +expectations rather than disable it. + +Autogenerating Expectation Data +------------------------------- + +After changing some code it may be necessary to update the expectation +data for the relevant tests. This can of course be done manually, but +`mach` is able to automate the process: + + mach puppeteer-test --write-results + +By default it writes the output to `test/puppeteer-expected.json`. + +Given that the unit tests run in Firefox CI only for Linux it is advised to +download the expectation data (available as artifact) from the TaskCluster job. diff --git a/remote/cdp/CDP.sys.mjs b/remote/cdp/CDP.sys.mjs new file mode 100644 index 0000000000..0307c71149 --- /dev/null +++ b/remote/cdp/CDP.sys.mjs @@ -0,0 +1,145 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + JSONHandler: "chrome://remote/content/cdp/JSONHandler.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + RecommendedPreferences: + "chrome://remote/content/shared/RecommendedPreferences.sys.mjs", + TargetList: "chrome://remote/content/cdp/targets/TargetList.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.CDP) +); +ChromeUtils.defineLazyGetter(lazy, "textEncoder", () => new TextEncoder()); + +// Map of CDP-specific preferences that should be set via +// RecommendedPreferences. +const RECOMMENDED_PREFS = new Map([ + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + [ + "browser.contentblocking.features.standard", + "-tp,tpPrivate,cookieBehavior0,-cm,-fp", + ], + // Accept all cookies (see behavior definitions in nsICookieService.idl) + ["network.cookie.cookieBehavior", 0], +]); + +/** + * Entry class for the Chrome DevTools Protocol support. + * + * It holds the list of available targets (tabs, main browser), and also + * sets up the necessary handlers for the HTTP server. + * + * @see https://chromedevtools.github.io/devtools-protocol + */ +export class CDP { + /** + * Creates a new instance of the CDP class. + * + * @param {RemoteAgent} agent + * Reference to the Remote Agent instance. + */ + constructor(agent) { + this.agent = agent; + this.targetList = null; + + this._running = false; + this._activePortPath; + } + + get address() { + const mainTarget = this.targetList.getMainProcessTarget(); + return mainTarget.wsDebuggerURL; + } + + get mainTargetPath() { + const mainTarget = this.targetList.getMainProcessTarget(); + return mainTarget.path; + } + + /** + * Starts the CDP support. + */ + async start() { + if (this._running) { + return; + } + + // Note: Ideally this would only be set at the end of the method. However + // since start() is async, we prefer to set the flag early in order to + // avoid potential race conditions. + this._running = true; + + lazy.RecommendedPreferences.applyPreferences(RECOMMENDED_PREFS); + + // Starting CDP too early can cause issues with clients in not being able + // to find any available target. Also when closing the application while + // it's still starting up can cause shutdown hangs. As such CDP will be + // started when the initial application window has finished initializing. + lazy.logger.debug(`Waiting for initial application window`); + await this.agent.browserStartupFinished; + + this.agent.server.registerPrefixHandler("/", new lazy.JSONHandler(this)); + + this.targetList = new lazy.TargetList(); + this.targetList.on("target-created", (eventName, target) => { + this.agent.server.registerPathHandler(target.path, target); + }); + this.targetList.on("target-destroyed", (eventName, target) => { + this.agent.server.registerPathHandler(target.path, null); + }); + + await this.targetList.watchForTargets(); + + Cu.printStderr(`DevTools listening on ${this.address}\n`); + + // Write connection details to DevToolsActivePort file within the profile. + this._activePortPath = PathUtils.join( + PathUtils.profileDir, + "DevToolsActivePort" + ); + + const data = `${this.agent.port}\n${this.mainTargetPath}`; + try { + await IOUtils.write(this._activePortPath, lazy.textEncoder.encode(data)); + } catch (e) { + lazy.logger.warn( + `Failed to create ${this._activePortPath} (${e.message})` + ); + } + } + + /** + * Stops the CDP support. + */ + async stop() { + if (!this._running) { + return; + } + + try { + await IOUtils.remove(this._activePortPath); + } catch (e) { + lazy.logger.warn( + `Failed to remove ${this._activePortPath} (${e.message})` + ); + } + + try { + this.targetList?.destructor(); + this.targetList = null; + + lazy.RecommendedPreferences.restorePreferences(RECOMMENDED_PREFS); + } catch (e) { + lazy.logger.error("Failed to stop protocol", e); + } finally { + this._running = false; + } + } +} diff --git a/remote/cdp/CDPConnection.sys.mjs b/remote/cdp/CDPConnection.sys.mjs new file mode 100644 index 0000000000..1d2eb3e77c --- /dev/null +++ b/remote/cdp/CDPConnection.sys.mjs @@ -0,0 +1,288 @@ +/* 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/. */ + +import { WebSocketConnection } from "chrome://remote/content/shared/WebSocketConnection.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + UnknownMethodError: "chrome://remote/content/cdp/Error.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.CDP) +); + +export class CDPConnection extends WebSocketConnection { + /** + * @param {WebSocket} webSocket + * The WebSocket server connection to wrap. + * @param {Connection} httpdConnection + * Reference to the httpd.js's connection needed for clean-up. + */ + constructor(webSocket, httpdConnection) { + super(webSocket, httpdConnection); + + this.sessions = new Map(); + this.defaultSession = null; + } + + /** + * Register a new Session to forward the messages to. + * + * A session without any `id` attribute will be considered to be the + * default one, to which messages without `sessionId` attribute are + * forwarded to. Only one such session can be registered. + * + * @param {Session} session + * The session to register. + */ + registerSession(session) { + // CDP is not compatible with Fission by default, check the appropriate + // preferences are set to ensure compatibility. + if ( + Services.prefs.getIntPref("fission.webContentIsolationStrategy") !== 0 || + Services.prefs.getBoolPref("fission.bfcacheInParent") + ) { + lazy.logger.warn( + `Invalid browser preferences for CDP. Set "fission.webContentIsolationStrategy"` + + `to 0 and "fission.bfcacheInParent" to false before Firefox starts.` + ); + } + + if (!session.id) { + if (this.defaultSession) { + throw new Error( + "Default session is already set on Connection, " + + "can't register another one." + ); + } + this.defaultSession = session; + } + + this.sessions.set(session.id, session); + } + + /** + * Send an error back to the CDP client. + * + * @param {number} id + * Id of the packet which lead to an error. + * @param {Error} err + * Error object with `message` and `stack` attributes. + * @param {string=} sessionId + * Id of the session used to send this packet. Falls back to the + * default session if not specified. + */ + sendError(id, err, sessionId) { + const error = { + message: err.message, + data: err.stack, + }; + + this.send({ id, error, sessionId }); + } + + /** + * Send an event coming from a Domain to the CDP client. + * + * @param {string} method + * The event name. This is composed by a domain name, a dot character + * followed by the event name, e.g. `Target.targetCreated`. + * @param {object} params + * A JSON-serializable object, which is the payload of this event. + * @param {string=} sessionId + * The sessionId from which this packet is emitted. Falls back to the + * default session if not specified. + */ + sendEvent(method, params, sessionId) { + this.send({ method, params, sessionId }); + + if (Services.profiler?.IsActive()) { + ChromeUtils.addProfilerMarker( + "CDP: Event", + { category: "Remote-Protocol" }, + method + ); + } + + // When a client attaches to a secondary target via + // `Target.attachToTarget`, we should emit an event back with the + // result including the `sessionId` attribute of this secondary target's + // session. `Target.attachToTarget` creates the secondary session and + // returns the session ID. + if (sessionId) { + // receivedMessageFromTarget is expected to send a raw CDP packet + // in the `message` property and it to be already serialized to a + // string + this.send({ + method: "Target.receivedMessageFromTarget", + params: { sessionId, message: JSON.stringify({ method, params }) }, + }); + } + } + + /** + * Interpret a given CDP packet for a given Session. + * + * @param {string} sessionId + * ID of the session for which we should execute a command. + * @param {string} message + * The stringified JSON payload of the CDP packet, which is about + * executing a Domain's function. + */ + sendMessageToTarget(sessionId, message) { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session '${sessionId}' doesn't exist.`); + } + // `message` is received from `Target.sendMessageToTarget` where the + // message attribute is a stringified JSON payload which represent a CDP + // packet. + const packet = JSON.parse(message); + + // The CDP packet sent by the client shouldn't have a sessionId attribute + // as it is passed as another argument of `Target.sendMessageToTarget`. + // Set it here in order to reuse the codepath of flatten session, where + // the client sends CDP packets with a `sessionId` attribute instead + // of going through the old and probably deprecated + // `Target.sendMessageToTarget` API. + packet.sessionId = sessionId; + this.onPacket(packet); + } + + /** + * Send the result of a call to a Domain's function back to the CDP client. + * + * @param {number} id + * The request id being sent by the client to call the domain's method. + * @param {object} result + * A JSON-serializable object, which is the actual result. + * @param {string=} sessionId + * The sessionId from which this packet is emitted. Falls back to the + * default session if not specified. + */ + sendResult(id, result, sessionId) { + result = typeof result != "undefined" ? result : {}; + this.send({ id, result, sessionId }); + + // When a client attaches to a secondary target via + // `Target.attachToTarget`, and it executes a command via + // `Target.sendMessageToTarget`, we should emit an event back with the + // result including the `sessionId` attribute of this secondary target's + // session. `Target.attachToTarget` creates the secondary session and + // returns the session ID. + if (sessionId) { + // receivedMessageFromTarget is expected to send a raw CDP packet + // in the `message` property and it to be already serialized to a + // string + this.send({ + method: "Target.receivedMessageFromTarget", + params: { sessionId, message: JSON.stringify({ id, result }) }, + }); + } + } + + // Transport hooks + + /** + * Called by the `transport` when the connection is closed. + */ + onConnectionClose() { + // Cleanup all the registered sessions. + for (const session of this.sessions.values()) { + session.destructor(); + } + this.sessions.clear(); + + super.onConnectionClose(); + } + + /** + * Receive a packet from the WebSocket layer. + * + * This packet is sent by a CDP client and is meant to execute + * a particular function on a given Domain. + * + * @param {object} packet + * JSON-serializable object sent by the client. + */ + async onPacket(packet) { + super.onPacket(packet); + + const { id, method, params, sessionId } = packet; + const startTime = Cu.now(); + + try { + // First check for mandatory field in the packets + if (typeof id == "undefined") { + throw new TypeError("Message missing 'id' field"); + } + if (typeof method == "undefined") { + throw new TypeError("Message missing 'method' field"); + } + + // Extract the domain name and the method name out of `method` attribute + const { domain, command } = splitMethod(method); + + // If a `sessionId` field is passed, retrieve the session to which we + // should forward this packet. Otherwise send it to the default session. + let session; + if (!sessionId) { + if (!this.defaultSession) { + throw new Error("Connection is missing a default Session."); + } + session = this.defaultSession; + } else { + session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session '${sessionId}' doesn't exists.`); + } + } + + // Bug 1600317 - Workaround to deny internal methods to be called + if (command.startsWith("_")) { + throw new lazy.UnknownMethodError(command); + } + + // Finally, instruct the targeted session to execute the command + const result = await session.execute(id, domain, command, params); + this.sendResult(id, result, sessionId); + } catch (e) { + this.sendError(id, e, packet.sessionId); + } + + if (Services.profiler?.IsActive()) { + ChromeUtils.addProfilerMarker( + "CDP: Command", + { startTime, category: "Remote-Protocol" }, + `${method} (${id})` + ); + } + } +} + +/** + * Splits a CDP method into domain and command components. + * + * @param {string} method + * Name of the method to split, e.g. "Browser.getVersion". + * + * @returns {Object<string, string>} + * Object with the domain ("Browser") and command ("getVersion") + * as properties. + */ +export function splitMethod(method) { + const parts = method.split("."); + + if (parts.length != 2 || !parts[0].length || !parts[1].length) { + throw new TypeError(`Invalid method format: '${method}'`); + } + + return { + domain: parts[0], + command: parts[1], + }; +} diff --git a/remote/cdp/Error.sys.mjs b/remote/cdp/Error.sys.mjs new file mode 100644 index 0000000000..b047285649 --- /dev/null +++ b/remote/cdp/Error.sys.mjs @@ -0,0 +1,130 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.CDP) +); + +export class RemoteAgentError extends Error { + constructor(message = "", cause = undefined) { + cause = cause || message; + super(cause); + + this.name = this.constructor.name; + this.message = message; + this.cause = cause; + + this.notify(); + } + + notify() { + console.error(this); + lazy.logger.error(this.toString({ stack: true })); + } + + toString({ stack = false } = {}) { + return RemoteAgentError.format(this, { stack }); + } + + static format(e, { stack = false } = {}) { + return formatError(e, { stack }); + } + + /** + * Takes a serialised CDP error and reconstructs it + * as a RemoteAgentError. + * + * The error must be of this form: + * + * {"message": "TypeError: foo is not a function\n + * execute@chrome://remote/content/cdp/sessions/Session.jsm:73:39\n + * onMessage@chrome://remote/content/cdp/sessions/TabSession.jsm:65:20"} + * + * This approach has the notable deficiency that it cannot deal + * with causes to errors because of the unstructured nature of CDP + * errors. A possible future improvement would be to extend the + * error serialisation to include discrete fields for each data + * property. + * + * @param {object} json + * CDP error encoded as a JSON object, which must have a + * "message" field, where the first line will make out the error + * message and the subsequent lines the stacktrace. + * + * @returns {RemoteAgentError} + */ + static fromJSON(json) { + const [message, ...stack] = json.message.split("\n"); + const err = new RemoteAgentError(); + err.message = message.slice(0, -1); + err.stack = stack.map(s => s.trim()).join("\n"); + err.cause = null; + return err; + } +} + +/** + * A fatal error that it is not possible to recover from + * or send back to the client. + * + * Constructing this error will force the application to quit. + */ +export class FatalError extends RemoteAgentError { + constructor(...args) { + super(...args); + this.quit(); + } + + notify() { + lazy.logger.fatal(this.toString({ stack: true })); + } + + quit(mode = Ci.nsIAppStartup.eForceQuit) { + Services.startup.quit(mode); + } +} + +/** When an operation is not yet implemented. */ +export class UnsupportedError extends RemoteAgentError {} + +/** The requested remote method does not exist. */ +export class UnknownMethodError extends RemoteAgentError { + constructor(domain, command = null) { + if (command) { + super(`${domain}.${command}`); + } else { + super(domain); + } + } +} + +function formatError(error, { stack = false } = {}) { + const els = []; + + els.push(error.name); + if (error.message) { + els.push(": "); + els.push(error.message); + } + + if (stack && error.stack) { + els.push(":\n"); + + const stack = error.stack.trim().split("\n"); + els.push(stack.map(line => `\t${line}`).join("\n")); + + if (error.cause) { + els.push("\n"); + els.push("caused by: " + formatError(error.cause, { stack })); + } + } + + return els.join(""); +} diff --git a/remote/cdp/JSONHandler.sys.mjs b/remote/cdp/JSONHandler.sys.mjs new file mode 100644 index 0000000000..5cb81d6a9a --- /dev/null +++ b/remote/cdp/JSONHandler.sys.mjs @@ -0,0 +1,266 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + HTTP_404: "chrome://remote/content/server/httpd.sys.mjs", + HTTP_405: "chrome://remote/content/server/httpd.sys.mjs", + HTTP_500: "chrome://remote/content/server/httpd.sys.mjs", + Protocol: "chrome://remote/content/cdp/Protocol.sys.mjs", + RemoteAgentError: "chrome://remote/content/cdp/Error.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +export class JSONHandler { + constructor(cdp) { + this.cdp = cdp; + this.routes = { + "/json/version": { + handler: this.getVersion.bind(this), + }, + + "/json/protocol": { + handler: this.getProtocol.bind(this), + }, + + "/json/list": { + handler: this.getTargetList.bind(this), + }, + + "/json": { + handler: this.getTargetList.bind(this), + }, + + // PUT only - /json/new?{url} + "/json/new": { + handler: this.newTarget.bind(this), + method: "PUT", + }, + + // /json/activate/{targetId} + "/json/activate": { + handler: this.activateTarget.bind(this), + parameter: true, + }, + + // /json/close/{targetId} + "/json/close": { + handler: this.closeTarget.bind(this), + parameter: true, + }, + }; + } + + getVersion() { + const mainProcessTarget = this.cdp.targetList.getMainProcessTarget(); + + const { userAgent } = Cc[ + "@mozilla.org/network/protocol;1?name=http" + ].getService(Ci.nsIHttpProtocolHandler); + + return { + body: { + Browser: `${Services.appinfo.name}/${Services.appinfo.version}`, + "Protocol-Version": "1.3", + "User-Agent": userAgent, + "V8-Version": "1.0", + "WebKit-Version": "1.0", + webSocketDebuggerUrl: mainProcessTarget.toJSON().webSocketDebuggerUrl, + }, + }; + } + + getProtocol() { + return { body: lazy.Protocol.Description }; + } + + getTargetList() { + return { body: [...this.cdp.targetList].filter(x => x.type !== "browser") }; + } + + /** HTTP copy of Target.createTarget() */ + async newTarget(url) { + const onTarget = this.cdp.targetList.once("target-created"); + + // Open new tab + const tab = await lazy.TabManager.addTab({ + focus: true, + }); + + // Get the newly created target + const target = await onTarget; + if (tab.linkedBrowser != target.browser) { + throw new Error( + "Unexpected tab opened: " + tab.linkedBrowser.currentURI.spec + ); + } + + const returnJson = target.toJSON(); + + // Load URL if given, otherwise stay on about:blank + if (url) { + let validURL; + try { + validURL = Services.io.newURI(url); + } catch { + // If we failed to parse given URL, return now since we already loaded about:blank + return { body: returnJson }; + } + + target.browsingContext.loadURI(validURL, { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }); + + // Force the URL in the returned target JSON to match given + // even if loading/will fail (matches Chromium behavior) + returnJson.url = url; + } + + return { body: returnJson }; + } + + /** HTTP copy of Target.activateTarget() */ + async activateTarget(targetId) { + // Try to get the target from given id + const target = this.cdp.targetList.getById(targetId); + + if (!target) { + return { + status: lazy.HTTP_404, + body: `No such target id: ${targetId}`, + json: false, + }; + } + + // Select the tab (this endpoint does not show the window) + await lazy.TabManager.selectTab(target.tab); + + return { body: "Target activated", json: false }; + } + + /** HTTP copy of Target.closeTarget() */ + async closeTarget(targetId) { + // Try to get the target from given id + const target = this.cdp.targetList.getById(targetId); + + if (!target) { + return { + status: lazy.HTTP_404, + body: `No such target id: ${targetId}`, + json: false, + }; + } + + // Remove the tab + await lazy.TabManager.removeTab(target.tab); + + return { body: "Target is closing", json: false }; + } + + // nsIHttpRequestHandler + + async handle(request, response) { + // Mark request as async so we can execute async routes and return values + response.processAsync(); + + // Run a provided route (function) with an argument + const runRoute = async (route, data) => { + try { + // Run the route to get data to return + const { + status = { code: 200, description: "OK" }, + json = true, + body, + } = await route(data); + + // Stringify into returnable JSON if wanted + const payload = json + ? JSON.stringify(body, null, lazy.Log.verbose ? "\t" : null) + : body; + + // Handle HTTP response + response.setStatusLine( + request.httpVersion, + status.code, + status.description + ); + response.setHeader("Content-Type", "application/json"); + response.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); + response.write(payload); + } catch (e) { + new lazy.RemoteAgentError(e).notify(); + + // Mark as 500 as an error has occured internally + response.setStatusLine( + request.httpVersion, + lazy.HTTP_500.code, + lazy.HTTP_500.description + ); + } + }; + + // Trim trailing slashes to conform with expected routes + const path = request.path.replace(/\/+$/, ""); + + let route; + for (const _route in this.routes) { + // Prefixed/parameter route (/path/{parameter}) + if (path.startsWith(_route + "/") && this.routes[_route].parameter) { + route = _route; + break; + } + + // Regular route (/path/example) + if (path === _route) { + route = _route; + break; + } + } + + if (!route) { + // Route does not exist + response.setStatusLine( + request.httpVersion, + lazy.HTTP_404.code, + lazy.HTTP_404.description + ); + response.write("Unknown command: " + path.replace("/json/", "")); + + return response.finish(); + } + + const { handler, method, parameter } = this.routes[route]; + + // If only one valid method for route, check method matches + if (method && request.method !== method) { + response.setStatusLine( + request.httpVersion, + lazy.HTTP_405.code, + lazy.HTTP_405.description + ); + response.write( + `Using unsafe HTTP verb ${request.method} to invoke ${route}. This action supports only PUT verb.` + ); + return response.finish(); + } + + if (parameter) { + await runRoute(handler, path.split("/").pop()); + } else { + await runRoute(handler, request.queryString); + } + + // Send response + return response.finish(); + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIHttpRequestHandler"]); + } +} diff --git a/remote/cdp/Protocol.sys.mjs b/remote/cdp/Protocol.sys.mjs new file mode 100644 index 0000000000..f239c82b42 --- /dev/null +++ b/remote/cdp/Protocol.sys.mjs @@ -0,0 +1,17358 @@ +/* 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/. */ + +// The `Description` below is imported from Chromium Code. + +// TODO(ato): We send back a description of the protocol +// when the user makes the initial HTTP request, +// but the following is pure fiction. +const Description = { + "domains": [ + { + "domain": "Accessibility", + "experimental": true, + "dependencies": [ + "DOM" + ], + "types": [ + { + "id": "AXNodeId", + "description": "Unique accessibility node identifier.", + "type": "string" + }, + { + "id": "AXValueType", + "description": "Enum of possible property types.", + "type": "string", + "enum": [ + "boolean", + "tristate", + "booleanOrUndefined", + "idref", + "idrefList", + "integer", + "node", + "nodeList", + "number", + "string", + "computedString", + "token", + "tokenList", + "domRelation", + "role", + "internalRole", + "valueUndefined" + ] + }, + { + "id": "AXValueSourceType", + "description": "Enum of possible property sources.", + "type": "string", + "enum": [ + "attribute", + "implicit", + "style", + "contents", + "placeholder", + "relatedElement" + ] + }, + { + "id": "AXValueNativeSourceType", + "description": "Enum of possible native property sources (as a subtype of a particular AXValueSourceType).", + "type": "string", + "enum": [ + "figcaption", + "label", + "labelfor", + "labelwrapped", + "legend", + "tablecaption", + "title", + "other" + ] + }, + { + "id": "AXValueSource", + "description": "A single source for a computed AX property.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "What type of source this is.", + "$ref": "AXValueSourceType" + }, + { + "name": "value", + "description": "The value of this property source.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "attribute", + "description": "The name of the relevant attribute, if any.", + "optional": true, + "type": "string" + }, + { + "name": "attributeValue", + "description": "The value of the relevant attribute, if any.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "superseded", + "description": "Whether this source is superseded by a higher priority source.", + "optional": true, + "type": "boolean" + }, + { + "name": "nativeSource", + "description": "The native markup source for this value, e.g. a <label> element.", + "optional": true, + "$ref": "AXValueNativeSourceType" + }, + { + "name": "nativeSourceValue", + "description": "The value, such as a node or node list, of the native source.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "invalid", + "description": "Whether the value for this property is invalid.", + "optional": true, + "type": "boolean" + }, + { + "name": "invalidReason", + "description": "Reason for the value being invalid, if it is.", + "optional": true, + "type": "string" + } + ] + }, + { + "id": "AXRelatedNode", + "type": "object", + "properties": [ + { + "name": "backendDOMNodeId", + "description": "The BackendNodeId of the related DOM node.", + "$ref": "DOM.BackendNodeId" + }, + { + "name": "idref", + "description": "The IDRef value provided, if any.", + "optional": true, + "type": "string" + }, + { + "name": "text", + "description": "The text alternative of this node in the current context.", + "optional": true, + "type": "string" + } + ] + }, + { + "id": "AXProperty", + "type": "object", + "properties": [ + { + "name": "name", + "description": "The name of this property.", + "$ref": "AXPropertyName" + }, + { + "name": "value", + "description": "The value of this property.", + "$ref": "AXValue" + } + ] + }, + { + "id": "AXValue", + "description": "A single computed AX property.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "The type of this value.", + "$ref": "AXValueType" + }, + { + "name": "value", + "description": "The computed value of this property.", + "optional": true, + "type": "any" + }, + { + "name": "relatedNodes", + "description": "One or more related nodes, if applicable.", + "optional": true, + "type": "array", + "items": { + "$ref": "AXRelatedNode" + } + }, + { + "name": "sources", + "description": "The sources which contributed to the computation of this property.", + "optional": true, + "type": "array", + "items": { + "$ref": "AXValueSource" + } + } + ] + }, + { + "id": "AXPropertyName", + "description": "Values of AXProperty name: from 'busy' to 'roledescription' - states which apply to every AX\nnode, from 'live' to 'root' - attributes which apply to nodes in live regions, from\n'autocomplete' to 'valuetext' - attributes which apply to widgets, from 'checked' to 'selected'\n- states which apply to widgets, from 'activedescendant' to 'owns' - relationships between\nelements other than parent/child/sibling.", + "type": "string", + "enum": [ + "busy", + "disabled", + "editable", + "focusable", + "focused", + "hidden", + "hiddenRoot", + "invalid", + "keyshortcuts", + "settable", + "roledescription", + "live", + "atomic", + "relevant", + "root", + "autocomplete", + "hasPopup", + "level", + "multiselectable", + "orientation", + "multiline", + "readonly", + "required", + "valuemin", + "valuemax", + "valuetext", + "checked", + "expanded", + "modal", + "pressed", + "selected", + "activedescendant", + "controls", + "describedby", + "details", + "errormessage", + "flowto", + "labelledby", + "owns" + ] + }, + { + "id": "AXNode", + "description": "A node in the accessibility tree.", + "type": "object", + "properties": [ + { + "name": "nodeId", + "description": "Unique identifier for this node.", + "$ref": "AXNodeId" + }, + { + "name": "ignored", + "description": "Whether this node is ignored for accessibility", + "type": "boolean" + }, + { + "name": "ignoredReasons", + "description": "Collection of reasons why this node is hidden.", + "optional": true, + "type": "array", + "items": { + "$ref": "AXProperty" + } + }, + { + "name": "role", + "description": "This `Node`'s role, whether explicit or implicit.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "name", + "description": "The accessible name for this `Node`.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "description", + "description": "The accessible description for this `Node`.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "value", + "description": "The value for this `Node`.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "properties", + "description": "All other properties", + "optional": true, + "type": "array", + "items": { + "$ref": "AXProperty" + } + }, + { + "name": "childIds", + "description": "IDs for each of this node's child nodes.", + "optional": true, + "type": "array", + "items": { + "$ref": "AXNodeId" + } + }, + { + "name": "backendDOMNodeId", + "description": "The backend ID for the associated DOM node, if any.", + "optional": true, + "$ref": "DOM.BackendNodeId" + } + ] + } + ], + "commands": [ + { + "name": "disable", + "description": "Disables the accessibility domain." + }, + { + "name": "enable", + "description": "Enables the accessibility domain which causes `AXNodeId`s to remain consistent between method calls.\nThis turns on accessibility for the page, which can impact performance until accessibility is disabled." + }, + { + "name": "getPartialAXTree", + "description": "Fetches the accessibility node and partial accessibility tree for this DOM node, if it exists.", + "experimental": true, + "parameters": [ + { + "name": "nodeId", + "description": "Identifier of the node to get the partial accessibility tree for.", + "optional": true, + "$ref": "DOM.NodeId" + }, + { + "name": "backendNodeId", + "description": "Identifier of the backend node to get the partial accessibility tree for.", + "optional": true, + "$ref": "DOM.BackendNodeId" + }, + { + "name": "objectId", + "description": "JavaScript object id of the node wrapper to get the partial accessibility tree for.", + "optional": true, + "$ref": "Runtime.RemoteObjectId" + }, + { + "name": "fetchRelatives", + "description": "Whether to fetch this nodes ancestors, siblings and children. Defaults to true.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "nodes", + "description": "The `Accessibility.AXNode` for this DOM node, if it exists, plus its ancestors, siblings and\nchildren, if requested.", + "type": "array", + "items": { + "$ref": "AXNode" + } + } + ] + }, + { + "name": "getFullAXTree", + "description": "Fetches the entire accessibility tree", + "experimental": true, + "returns": [ + { + "name": "nodes", + "type": "array", + "items": { + "$ref": "AXNode" + } + } + ] + } + ] + }, + { + "domain": "Animation", + "experimental": true, + "dependencies": [ + "Runtime", + "DOM" + ], + "types": [ + { + "id": "Animation", + "description": "Animation instance.", + "type": "object", + "properties": [ + { + "name": "id", + "description": "`Animation`'s id.", + "type": "string" + }, + { + "name": "name", + "description": "`Animation`'s name.", + "type": "string" + }, + { + "name": "pausedState", + "description": "`Animation`'s internal paused state.", + "type": "boolean" + }, + { + "name": "playState", + "description": "`Animation`'s play state.", + "type": "string" + }, + { + "name": "playbackRate", + "description": "`Animation`'s playback rate.", + "type": "number" + }, + { + "name": "startTime", + "description": "`Animation`'s start time.", + "type": "number" + }, + { + "name": "currentTime", + "description": "`Animation`'s current time.", + "type": "number" + }, + { + "name": "type", + "description": "Animation type of `Animation`.", + "type": "string", + "enum": [ + "CSSTransition", + "CSSAnimation", + "WebAnimation" + ] + }, + { + "name": "source", + "description": "`Animation`'s source animation node.", + "optional": true, + "$ref": "AnimationEffect" + }, + { + "name": "cssId", + "description": "A unique ID for `Animation` representing the sources that triggered this CSS\nanimation/transition.", + "optional": true, + "type": "string" + } + ] + }, + { + "id": "AnimationEffect", + "description": "AnimationEffect instance", + "type": "object", + "properties": [ + { + "name": "delay", + "description": "`AnimationEffect`'s delay.", + "type": "number" + }, + { + "name": "endDelay", + "description": "`AnimationEffect`'s end delay.", + "type": "number" + }, + { + "name": "iterationStart", + "description": "`AnimationEffect`'s iteration start.", + "type": "number" + }, + { + "name": "iterations", + "description": "`AnimationEffect`'s iterations.", + "type": "number" + }, + { + "name": "duration", + "description": "`AnimationEffect`'s iteration duration.", + "type": "number" + }, + { + "name": "direction", + "description": "`AnimationEffect`'s playback direction.", + "type": "string" + }, + { + "name": "fill", + "description": "`AnimationEffect`'s fill mode.", + "type": "string" + }, + { + "name": "backendNodeId", + "description": "`AnimationEffect`'s target node.", + "optional": true, + "$ref": "DOM.BackendNodeId" + }, + { + "name": "keyframesRule", + "description": "`AnimationEffect`'s keyframes.", + "optional": true, + "$ref": "KeyframesRule" + }, + { + "name": "easing", + "description": "`AnimationEffect`'s timing function.", + "type": "string" + } + ] + }, + { + "id": "KeyframesRule", + "description": "Keyframes Rule", + "type": "object", + "properties": [ + { + "name": "name", + "description": "CSS keyframed animation's name.", + "optional": true, + "type": "string" + }, + { + "name": "keyframes", + "description": "List of animation keyframes.", + "type": "array", + "items": { + "$ref": "KeyframeStyle" + } + } + ] + }, + { + "id": "KeyframeStyle", + "description": "Keyframe Style", + "type": "object", + "properties": [ + { + "name": "offset", + "description": "Keyframe's time offset.", + "type": "string" + }, + { + "name": "easing", + "description": "`AnimationEffect`'s timing function.", + "type": "string" + } + ] + } + ], + "commands": [ + { + "name": "disable", + "description": "Disables animation domain notifications." + }, + { + "name": "enable", + "description": "Enables animation domain notifications." + }, + { + "name": "getCurrentTime", + "description": "Returns the current time of the an animation.", + "parameters": [ + { + "name": "id", + "description": "Id of animation.", + "type": "string" + } + ], + "returns": [ + { + "name": "currentTime", + "description": "Current time of the page.", + "type": "number" + } + ] + }, + { + "name": "getPlaybackRate", + "description": "Gets the playback rate of the document timeline.", + "returns": [ + { + "name": "playbackRate", + "description": "Playback rate for animations on page.", + "type": "number" + } + ] + }, + { + "name": "releaseAnimations", + "description": "Releases a set of animations to no longer be manipulated.", + "parameters": [ + { + "name": "animations", + "description": "List of animation ids to seek.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "resolveAnimation", + "description": "Gets the remote object of the Animation.", + "parameters": [ + { + "name": "animationId", + "description": "Animation id.", + "type": "string" + } + ], + "returns": [ + { + "name": "remoteObject", + "description": "Corresponding remote object.", + "$ref": "Runtime.RemoteObject" + } + ] + }, + { + "name": "seekAnimations", + "description": "Seek a set of animations to a particular time within each animation.", + "parameters": [ + { + "name": "animations", + "description": "List of animation ids to seek.", + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "currentTime", + "description": "Set the current time of each animation.", + "type": "number" + } + ] + }, + { + "name": "setPaused", + "description": "Sets the paused state of a set of animations.", + "parameters": [ + { + "name": "animations", + "description": "Animations to set the pause state of.", + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "paused", + "description": "Paused state to set to.", + "type": "boolean" + } + ] + }, + { + "name": "setPlaybackRate", + "description": "Sets the playback rate of the document timeline.", + "parameters": [ + { + "name": "playbackRate", + "description": "Playback rate for animations on page", + "type": "number" + } + ] + }, + { + "name": "setTiming", + "description": "Sets the timing of an animation node.", + "parameters": [ + { + "name": "animationId", + "description": "Animation id.", + "type": "string" + }, + { + "name": "duration", + "description": "Duration of the animation.", + "type": "number" + }, + { + "name": "delay", + "description": "Delay of the animation.", + "type": "number" + } + ] + } + ], + "events": [ + { + "name": "animationCanceled", + "description": "Event for when an animation has been cancelled.", + "parameters": [ + { + "name": "id", + "description": "Id of the animation that was cancelled.", + "type": "string" + } + ] + }, + { + "name": "animationCreated", + "description": "Event for each animation that has been created.", + "parameters": [ + { + "name": "id", + "description": "Id of the animation that was created.", + "type": "string" + } + ] + }, + { + "name": "animationStarted", + "description": "Event for animation that has been started.", + "parameters": [ + { + "name": "animation", + "description": "Animation that was started.", + "$ref": "Animation" + } + ] + } + ] + }, + { + "domain": "ApplicationCache", + "experimental": true, + "types": [ + { + "id": "ApplicationCacheResource", + "description": "Detailed application cache resource information.", + "type": "object", + "properties": [ + { + "name": "url", + "description": "Resource url.", + "type": "string" + }, + { + "name": "size", + "description": "Resource size.", + "type": "integer" + }, + { + "name": "type", + "description": "Resource type.", + "type": "string" + } + ] + }, + { + "id": "ApplicationCache", + "description": "Detailed application cache information.", + "type": "object", + "properties": [ + { + "name": "manifestURL", + "description": "Manifest URL.", + "type": "string" + }, + { + "name": "size", + "description": "Application cache size.", + "type": "number" + }, + { + "name": "creationTime", + "description": "Application cache creation time.", + "type": "number" + }, + { + "name": "updateTime", + "description": "Application cache update time.", + "type": "number" + }, + { + "name": "resources", + "description": "Application cache resources.", + "type": "array", + "items": { + "$ref": "ApplicationCacheResource" + } + } + ] + }, + { + "id": "FrameWithManifest", + "description": "Frame identifier - manifest URL pair.", + "type": "object", + "properties": [ + { + "name": "frameId", + "description": "Frame identifier.", + "$ref": "Page.FrameId" + }, + { + "name": "manifestURL", + "description": "Manifest URL.", + "type": "string" + }, + { + "name": "status", + "description": "Application cache status.", + "type": "integer" + } + ] + } + ], + "commands": [ + { + "name": "enable", + "description": "Enables application cache domain notifications." + }, + { + "name": "getApplicationCacheForFrame", + "description": "Returns relevant application cache data for the document in given frame.", + "parameters": [ + { + "name": "frameId", + "description": "Identifier of the frame containing document whose application cache is retrieved.", + "$ref": "Page.FrameId" + } + ], + "returns": [ + { + "name": "applicationCache", + "description": "Relevant application cache data for the document in given frame.", + "$ref": "ApplicationCache" + } + ] + }, + { + "name": "getFramesWithManifests", + "description": "Returns array of frame identifiers with manifest urls for each frame containing a document\nassociated with some application cache.", + "returns": [ + { + "name": "frameIds", + "description": "Array of frame identifiers with manifest urls for each frame containing a document\nassociated with some application cache.", + "type": "array", + "items": { + "$ref": "FrameWithManifest" + } + } + ] + }, + { + "name": "getManifestForFrame", + "description": "Returns manifest URL for document in the given frame.", + "parameters": [ + { + "name": "frameId", + "description": "Identifier of the frame containing document whose manifest is retrieved.", + "$ref": "Page.FrameId" + } + ], + "returns": [ + { + "name": "manifestURL", + "description": "Manifest URL for document in the given frame.", + "type": "string" + } + ] + } + ], + "events": [ + { + "name": "applicationCacheStatusUpdated", + "parameters": [ + { + "name": "frameId", + "description": "Identifier of the frame containing document whose application cache updated status.", + "$ref": "Page.FrameId" + }, + { + "name": "manifestURL", + "description": "Manifest URL.", + "type": "string" + }, + { + "name": "status", + "description": "Updated application cache status.", + "type": "integer" + } + ] + }, + { + "name": "networkStateUpdated", + "parameters": [ + { + "name": "isNowOnline", + "type": "boolean" + } + ] + } + ] + }, + { + "domain": "Audits", + "description": "Audits domain allows investigation of page violations and possible improvements.", + "experimental": true, + "dependencies": [ + "Network" + ], + "commands": [ + { + "name": "getEncodedResponse", + "description": "Returns the response body and size if it were re-encoded with the specified settings. Only\napplies to images.", + "parameters": [ + { + "name": "requestId", + "description": "Identifier of the network request to get content for.", + "$ref": "Network.RequestId" + }, + { + "name": "encoding", + "description": "The encoding to use.", + "type": "string", + "enum": [ + "webp", + "jpeg", + "png" + ] + }, + { + "name": "quality", + "description": "The quality of the encoding (0-1). (defaults to 1)", + "optional": true, + "type": "number" + }, + { + "name": "sizeOnly", + "description": "Whether to only return the size information (defaults to false).", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "body", + "description": "The encoded body as a base64 string. Omitted if sizeOnly is true.", + "optional": true, + "type": "binary" + }, + { + "name": "originalSize", + "description": "Size before re-encoding.", + "type": "integer" + }, + { + "name": "encodedSize", + "description": "Size after re-encoding.", + "type": "integer" + } + ] + } + ] + }, + { + "domain": "Browser", + "description": "The Browser domain defines methods and events for browser managing.", + "types": [ + { + "id": "WindowID", + "experimental": true, + "type": "integer" + }, + { + "id": "WindowState", + "description": "The state of the browser window.", + "experimental": true, + "type": "string", + "enum": [ + "normal", + "minimized", + "maximized", + "fullscreen" + ] + }, + { + "id": "Bounds", + "description": "Browser window bounds information", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "left", + "description": "The offset from the left edge of the screen to the window in pixels.", + "optional": true, + "type": "integer" + }, + { + "name": "top", + "description": "The offset from the top edge of the screen to the window in pixels.", + "optional": true, + "type": "integer" + }, + { + "name": "width", + "description": "The window width in pixels.", + "optional": true, + "type": "integer" + }, + { + "name": "height", + "description": "The window height in pixels.", + "optional": true, + "type": "integer" + }, + { + "name": "windowState", + "description": "The window state. Default to normal.", + "optional": true, + "$ref": "WindowState" + } + ] + }, + { + "id": "PermissionType", + "experimental": true, + "type": "string", + "enum": [ + "accessibilityEvents", + "audioCapture", + "backgroundSync", + "backgroundFetch", + "clipboardRead", + "clipboardWrite", + "durableStorage", + "flash", + "geolocation", + "midi", + "midiSysex", + "notifications", + "paymentHandler", + "protectedMediaIdentifier", + "sensors", + "videoCapture" + ] + }, + { + "id": "Bucket", + "description": "Chrome histogram bucket.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "low", + "description": "Minimum value (inclusive).", + "type": "integer" + }, + { + "name": "high", + "description": "Maximum value (exclusive).", + "type": "integer" + }, + { + "name": "count", + "description": "Number of samples.", + "type": "integer" + } + ] + }, + { + "id": "Histogram", + "description": "Chrome histogram.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "name", + "description": "Name.", + "type": "string" + }, + { + "name": "sum", + "description": "Sum of sample values.", + "type": "integer" + }, + { + "name": "count", + "description": "Total number of samples.", + "type": "integer" + }, + { + "name": "buckets", + "description": "Buckets.", + "type": "array", + "items": { + "$ref": "Bucket" + } + } + ] + } + ], + "commands": [ + { + "name": "grantPermissions", + "description": "Grant specific permissions to the given origin and reject all others.", + "experimental": true, + "parameters": [ + { + "name": "origin", + "type": "string" + }, + { + "name": "permissions", + "type": "array", + "items": { + "$ref": "PermissionType" + } + }, + { + "name": "browserContextId", + "description": "BrowserContext to override permissions. When omitted, default browser context is used.", + "optional": true, + "$ref": "Target.BrowserContextID" + } + ] + }, + { + "name": "resetPermissions", + "description": "Reset all permission management for all origins.", + "experimental": true, + "parameters": [ + { + "name": "browserContextId", + "description": "BrowserContext to reset permissions. When omitted, default browser context is used.", + "optional": true, + "$ref": "Target.BrowserContextID" + } + ] + }, + { + "name": "close", + "description": "Close browser gracefully." + }, + { + "name": "crash", + "description": "Crashes browser on the main thread.", + "experimental": true + }, + { + "name": "getVersion", + "description": "Returns version information.", + "returns": [ + { + "name": "protocolVersion", + "description": "Protocol version.", + "type": "string" + }, + { + "name": "product", + "description": "Product name.", + "type": "string" + }, + { + "name": "revision", + "description": "Product revision.", + "type": "string" + }, + { + "name": "userAgent", + "description": "User-Agent.", + "type": "string" + }, + { + "name": "jsVersion", + "description": "V8 version.", + "type": "string" + } + ] + }, + { + "name": "getBrowserCommandLine", + "description": "Returns the command line switches for the browser process if, and only if\n--enable-automation is on the commandline.", + "experimental": true, + "returns": [ + { + "name": "arguments", + "description": "Commandline parameters", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "getHistograms", + "description": "Get Chrome histograms.", + "experimental": true, + "parameters": [ + { + "name": "query", + "description": "Requested substring in name. Only histograms which have query as a\nsubstring in their name are extracted. An empty or absent query returns\nall histograms.", + "optional": true, + "type": "string" + }, + { + "name": "delta", + "description": "If true, retrieve delta since last call.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "histograms", + "description": "Histograms.", + "type": "array", + "items": { + "$ref": "Histogram" + } + } + ] + }, + { + "name": "getHistogram", + "description": "Get a Chrome histogram by name.", + "experimental": true, + "parameters": [ + { + "name": "name", + "description": "Requested histogram name.", + "type": "string" + }, + { + "name": "delta", + "description": "If true, retrieve delta since last call.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "histogram", + "description": "Histogram.", + "$ref": "Histogram" + } + ] + }, + { + "name": "getWindowBounds", + "description": "Get position and size of the browser window.", + "experimental": true, + "parameters": [ + { + "name": "windowId", + "description": "Browser window id.", + "$ref": "WindowID" + } + ], + "returns": [ + { + "name": "bounds", + "description": "Bounds information of the window. When window state is 'minimized', the restored window\nposition and size are returned.", + "$ref": "Bounds" + } + ] + }, + { + "name": "getWindowForTarget", + "description": "Get the browser window that contains the devtools target.", + "experimental": true, + "parameters": [ + { + "name": "targetId", + "description": "Devtools agent host id. If called as a part of the session, associated targetId is used.", + "optional": true, + "$ref": "Target.TargetID" + } + ], + "returns": [ + { + "name": "windowId", + "description": "Browser window id.", + "$ref": "WindowID" + }, + { + "name": "bounds", + "description": "Bounds information of the window. When window state is 'minimized', the restored window\nposition and size are returned.", + "$ref": "Bounds" + } + ] + }, + { + "name": "setWindowBounds", + "description": "Set position and/or size of the browser window.", + "experimental": true, + "parameters": [ + { + "name": "windowId", + "description": "Browser window id.", + "$ref": "WindowID" + }, + { + "name": "bounds", + "description": "New window bounds. The 'minimized', 'maximized' and 'fullscreen' states cannot be combined\nwith 'left', 'top', 'width' or 'height'. Leaves unspecified fields unchanged.", + "$ref": "Bounds" + } + ] + }, + { + "name": "setDockTile", + "description": "Set dock tile details, platform-specific.", + "experimental": true, + "parameters": [ + { + "name": "badgeLabel", + "optional": true, + "type": "string" + }, + { + "name": "image", + "description": "Png encoded image.", + "optional": true, + "type": "binary" + } + ] + } + ] + }, + { + "domain": "CSS", + "description": "This domain exposes CSS read/write operations. All CSS objects (stylesheets, rules, and styles)\nhave an associated `id` used in subsequent operations on the related object. Each object type has\na specific `id` structure, and those are not interchangeable between objects of different kinds.\nCSS objects can be loaded using the `get*ForNode()` calls (which accept a DOM node id). A client\ncan also keep track of stylesheets via the `styleSheetAdded`/`styleSheetRemoved` events and\nsubsequently load the required stylesheet contents using the `getStyleSheet[Text]()` methods.", + "experimental": true, + "dependencies": [ + "DOM" + ], + "types": [ + { + "id": "StyleSheetId", + "type": "string" + }, + { + "id": "StyleSheetOrigin", + "description": "Stylesheet type: \"injected\" for stylesheets injected via extension, \"user-agent\" for user-agent\nstylesheets, \"inspector\" for stylesheets created by the inspector (i.e. those holding the \"via\ninspector\" rules), \"regular\" for regular stylesheets.", + "type": "string", + "enum": [ + "injected", + "user-agent", + "inspector", + "regular" + ] + }, + { + "id": "PseudoElementMatches", + "description": "CSS rule collection for a single pseudo style.", + "type": "object", + "properties": [ + { + "name": "pseudoType", + "description": "Pseudo element type.", + "$ref": "DOM.PseudoType" + }, + { + "name": "matches", + "description": "Matches of CSS rules applicable to the pseudo style.", + "type": "array", + "items": { + "$ref": "RuleMatch" + } + } + ] + }, + { + "id": "InheritedStyleEntry", + "description": "Inherited CSS rule collection from ancestor node.", + "type": "object", + "properties": [ + { + "name": "inlineStyle", + "description": "The ancestor node's inline style, if any, in the style inheritance chain.", + "optional": true, + "$ref": "CSSStyle" + }, + { + "name": "matchedCSSRules", + "description": "Matches of CSS rules matching the ancestor node in the style inheritance chain.", + "type": "array", + "items": { + "$ref": "RuleMatch" + } + } + ] + }, + { + "id": "RuleMatch", + "description": "Match data for a CSS rule.", + "type": "object", + "properties": [ + { + "name": "rule", + "description": "CSS rule in the match.", + "$ref": "CSSRule" + }, + { + "name": "matchingSelectors", + "description": "Matching selector indices in the rule's selectorList selectors (0-based).", + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + { + "id": "Value", + "description": "Data for a simple selector (these are delimited by commas in a selector list).", + "type": "object", + "properties": [ + { + "name": "text", + "description": "Value text.", + "type": "string" + }, + { + "name": "range", + "description": "Value range in the underlying resource (if available).", + "optional": true, + "$ref": "SourceRange" + } + ] + }, + { + "id": "SelectorList", + "description": "Selector list data.", + "type": "object", + "properties": [ + { + "name": "selectors", + "description": "Selectors in the list.", + "type": "array", + "items": { + "$ref": "Value" + } + }, + { + "name": "text", + "description": "Rule selector text.", + "type": "string" + } + ] + }, + { + "id": "CSSStyleSheetHeader", + "description": "CSS stylesheet metainformation.", + "type": "object", + "properties": [ + { + "name": "styleSheetId", + "description": "The stylesheet identifier.", + "$ref": "StyleSheetId" + }, + { + "name": "frameId", + "description": "Owner frame identifier.", + "$ref": "Page.FrameId" + }, + { + "name": "sourceURL", + "description": "Stylesheet resource URL.", + "type": "string" + }, + { + "name": "sourceMapURL", + "description": "URL of source map associated with the stylesheet (if any).", + "optional": true, + "type": "string" + }, + { + "name": "origin", + "description": "Stylesheet origin.", + "$ref": "StyleSheetOrigin" + }, + { + "name": "title", + "description": "Stylesheet title.", + "type": "string" + }, + { + "name": "ownerNode", + "description": "The backend id for the owner node of the stylesheet.", + "optional": true, + "$ref": "DOM.BackendNodeId" + }, + { + "name": "disabled", + "description": "Denotes whether the stylesheet is disabled.", + "type": "boolean" + }, + { + "name": "hasSourceURL", + "description": "Whether the sourceURL field value comes from the sourceURL comment.", + "optional": true, + "type": "boolean" + }, + { + "name": "isInline", + "description": "Whether this stylesheet is created for STYLE tag by parser. This flag is not set for\ndocument.written STYLE tags.", + "type": "boolean" + }, + { + "name": "startLine", + "description": "Line offset of the stylesheet within the resource (zero based).", + "type": "number" + }, + { + "name": "startColumn", + "description": "Column offset of the stylesheet within the resource (zero based).", + "type": "number" + }, + { + "name": "length", + "description": "Size of the content (in characters).", + "type": "number" + } + ] + }, + { + "id": "CSSRule", + "description": "CSS rule representation.", + "type": "object", + "properties": [ + { + "name": "styleSheetId", + "description": "The css style sheet identifier (absent for user agent stylesheet and user-specified\nstylesheet rules) this rule came from.", + "optional": true, + "$ref": "StyleSheetId" + }, + { + "name": "selectorList", + "description": "Rule selector data.", + "$ref": "SelectorList" + }, + { + "name": "origin", + "description": "Parent stylesheet's origin.", + "$ref": "StyleSheetOrigin" + }, + { + "name": "style", + "description": "Associated style declaration.", + "$ref": "CSSStyle" + }, + { + "name": "media", + "description": "Media list array (for rules involving media queries). The array enumerates media queries\nstarting with the innermost one, going outwards.", + "optional": true, + "type": "array", + "items": { + "$ref": "CSSMedia" + } + } + ] + }, + { + "id": "RuleUsage", + "description": "CSS coverage information.", + "type": "object", + "properties": [ + { + "name": "styleSheetId", + "description": "The css style sheet identifier (absent for user agent stylesheet and user-specified\nstylesheet rules) this rule came from.", + "$ref": "StyleSheetId" + }, + { + "name": "startOffset", + "description": "Offset of the start of the rule (including selector) from the beginning of the stylesheet.", + "type": "number" + }, + { + "name": "endOffset", + "description": "Offset of the end of the rule body from the beginning of the stylesheet.", + "type": "number" + }, + { + "name": "used", + "description": "Indicates whether the rule was actually used by some element in the page.", + "type": "boolean" + } + ] + }, + { + "id": "SourceRange", + "description": "Text range within a resource. All numbers are zero-based.", + "type": "object", + "properties": [ + { + "name": "startLine", + "description": "Start line of range.", + "type": "integer" + }, + { + "name": "startColumn", + "description": "Start column of range (inclusive).", + "type": "integer" + }, + { + "name": "endLine", + "description": "End line of range", + "type": "integer" + }, + { + "name": "endColumn", + "description": "End column of range (exclusive).", + "type": "integer" + } + ] + }, + { + "id": "ShorthandEntry", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Shorthand name.", + "type": "string" + }, + { + "name": "value", + "description": "Shorthand value.", + "type": "string" + }, + { + "name": "important", + "description": "Whether the property has \"!important\" annotation (implies `false` if absent).", + "optional": true, + "type": "boolean" + } + ] + }, + { + "id": "CSSComputedStyleProperty", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Computed style property name.", + "type": "string" + }, + { + "name": "value", + "description": "Computed style property value.", + "type": "string" + } + ] + }, + { + "id": "CSSStyle", + "description": "CSS style representation.", + "type": "object", + "properties": [ + { + "name": "styleSheetId", + "description": "The css style sheet identifier (absent for user agent stylesheet and user-specified\nstylesheet rules) this rule came from.", + "optional": true, + "$ref": "StyleSheetId" + }, + { + "name": "cssProperties", + "description": "CSS properties in the style.", + "type": "array", + "items": { + "$ref": "CSSProperty" + } + }, + { + "name": "shorthandEntries", + "description": "Computed values for all shorthands found in the style.", + "type": "array", + "items": { + "$ref": "ShorthandEntry" + } + }, + { + "name": "cssText", + "description": "Style declaration text (if available).", + "optional": true, + "type": "string" + }, + { + "name": "range", + "description": "Style declaration range in the enclosing stylesheet (if available).", + "optional": true, + "$ref": "SourceRange" + } + ] + }, + { + "id": "CSSProperty", + "description": "CSS property declaration data.", + "type": "object", + "properties": [ + { + "name": "name", + "description": "The property name.", + "type": "string" + }, + { + "name": "value", + "description": "The property value.", + "type": "string" + }, + { + "name": "important", + "description": "Whether the property has \"!important\" annotation (implies `false` if absent).", + "optional": true, + "type": "boolean" + }, + { + "name": "implicit", + "description": "Whether the property is implicit (implies `false` if absent).", + "optional": true, + "type": "boolean" + }, + { + "name": "text", + "description": "The full property text as specified in the style.", + "optional": true, + "type": "string" + }, + { + "name": "parsedOk", + "description": "Whether the property is understood by the browser (implies `true` if absent).", + "optional": true, + "type": "boolean" + }, + { + "name": "disabled", + "description": "Whether the property is disabled by the user (present for source-based properties only).", + "optional": true, + "type": "boolean" + }, + { + "name": "range", + "description": "The entire property range in the enclosing style declaration (if available).", + "optional": true, + "$ref": "SourceRange" + } + ] + }, + { + "id": "CSSMedia", + "description": "CSS media rule descriptor.", + "type": "object", + "properties": [ + { + "name": "text", + "description": "Media query text.", + "type": "string" + }, + { + "name": "source", + "description": "Source of the media query: \"mediaRule\" if specified by a @media rule, \"importRule\" if\nspecified by an @import rule, \"linkedSheet\" if specified by a \"media\" attribute in a linked\nstylesheet's LINK tag, \"inlineSheet\" if specified by a \"media\" attribute in an inline\nstylesheet's STYLE tag.", + "type": "string", + "enum": [ + "mediaRule", + "importRule", + "linkedSheet", + "inlineSheet" + ] + }, + { + "name": "sourceURL", + "description": "URL of the document containing the media query description.", + "optional": true, + "type": "string" + }, + { + "name": "range", + "description": "The associated rule (@media or @import) header range in the enclosing stylesheet (if\navailable).", + "optional": true, + "$ref": "SourceRange" + }, + { + "name": "styleSheetId", + "description": "Identifier of the stylesheet containing this object (if exists).", + "optional": true, + "$ref": "StyleSheetId" + }, + { + "name": "mediaList", + "description": "Array of media queries.", + "optional": true, + "type": "array", + "items": { + "$ref": "MediaQuery" + } + } + ] + }, + { + "id": "MediaQuery", + "description": "Media query descriptor.", + "type": "object", + "properties": [ + { + "name": "expressions", + "description": "Array of media query expressions.", + "type": "array", + "items": { + "$ref": "MediaQueryExpression" + } + }, + { + "name": "active", + "description": "Whether the media query condition is satisfied.", + "type": "boolean" + } + ] + }, + { + "id": "MediaQueryExpression", + "description": "Media query expression descriptor.", + "type": "object", + "properties": [ + { + "name": "value", + "description": "Media query expression value.", + "type": "number" + }, + { + "name": "unit", + "description": "Media query expression units.", + "type": "string" + }, + { + "name": "feature", + "description": "Media query expression feature.", + "type": "string" + }, + { + "name": "valueRange", + "description": "The associated range of the value text in the enclosing stylesheet (if available).", + "optional": true, + "$ref": "SourceRange" + }, + { + "name": "computedLength", + "description": "Computed length of media query expression (if applicable).", + "optional": true, + "type": "number" + } + ] + }, + { + "id": "PlatformFontUsage", + "description": "Information about amount of glyphs that were rendered with given font.", + "type": "object", + "properties": [ + { + "name": "familyName", + "description": "Font's family name reported by platform.", + "type": "string" + }, + { + "name": "isCustomFont", + "description": "Indicates if the font was downloaded or resolved locally.", + "type": "boolean" + }, + { + "name": "glyphCount", + "description": "Amount of glyphs that were rendered with this font.", + "type": "number" + } + ] + }, + { + "id": "FontFace", + "description": "Properties of a web font: https://www.w3.org/TR/2008/REC-CSS2-20080411/fonts.html#font-descriptions", + "type": "object", + "properties": [ + { + "name": "fontFamily", + "description": "The font-family.", + "type": "string" + }, + { + "name": "fontStyle", + "description": "The font-style.", + "type": "string" + }, + { + "name": "fontVariant", + "description": "The font-variant.", + "type": "string" + }, + { + "name": "fontWeight", + "description": "The font-weight.", + "type": "string" + }, + { + "name": "fontStretch", + "description": "The font-stretch.", + "type": "string" + }, + { + "name": "unicodeRange", + "description": "The unicode-range.", + "type": "string" + }, + { + "name": "src", + "description": "The src.", + "type": "string" + }, + { + "name": "platformFontFamily", + "description": "The resolved platform font family", + "type": "string" + } + ] + }, + { + "id": "CSSKeyframesRule", + "description": "CSS keyframes rule representation.", + "type": "object", + "properties": [ + { + "name": "animationName", + "description": "Animation name.", + "$ref": "Value" + }, + { + "name": "keyframes", + "description": "List of keyframes.", + "type": "array", + "items": { + "$ref": "CSSKeyframeRule" + } + } + ] + }, + { + "id": "CSSKeyframeRule", + "description": "CSS keyframe rule representation.", + "type": "object", + "properties": [ + { + "name": "styleSheetId", + "description": "The css style sheet identifier (absent for user agent stylesheet and user-specified\nstylesheet rules) this rule came from.", + "optional": true, + "$ref": "StyleSheetId" + }, + { + "name": "origin", + "description": "Parent stylesheet's origin.", + "$ref": "StyleSheetOrigin" + }, + { + "name": "keyText", + "description": "Associated key text.", + "$ref": "Value" + }, + { + "name": "style", + "description": "Associated style declaration.", + "$ref": "CSSStyle" + } + ] + }, + { + "id": "StyleDeclarationEdit", + "description": "A descriptor of operation to mutate style declaration text.", + "type": "object", + "properties": [ + { + "name": "styleSheetId", + "description": "The css style sheet identifier.", + "$ref": "StyleSheetId" + }, + { + "name": "range", + "description": "The range of the style text in the enclosing stylesheet.", + "$ref": "SourceRange" + }, + { + "name": "text", + "description": "New style text.", + "type": "string" + } + ] + } + ], + "commands": [ + { + "name": "addRule", + "description": "Inserts a new rule with the given `ruleText` in a stylesheet with given `styleSheetId`, at the\nposition specified by `location`.", + "parameters": [ + { + "name": "styleSheetId", + "description": "The css style sheet identifier where a new rule should be inserted.", + "$ref": "StyleSheetId" + }, + { + "name": "ruleText", + "description": "The text of a new rule.", + "type": "string" + }, + { + "name": "location", + "description": "Text position of a new rule in the target style sheet.", + "$ref": "SourceRange" + } + ], + "returns": [ + { + "name": "rule", + "description": "The newly created rule.", + "$ref": "CSSRule" + } + ] + }, + { + "name": "collectClassNames", + "description": "Returns all class names from specified stylesheet.", + "parameters": [ + { + "name": "styleSheetId", + "$ref": "StyleSheetId" + } + ], + "returns": [ + { + "name": "classNames", + "description": "Class name list.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "createStyleSheet", + "description": "Creates a new special \"via-inspector\" stylesheet in the frame with given `frameId`.", + "parameters": [ + { + "name": "frameId", + "description": "Identifier of the frame where \"via-inspector\" stylesheet should be created.", + "$ref": "Page.FrameId" + } + ], + "returns": [ + { + "name": "styleSheetId", + "description": "Identifier of the created \"via-inspector\" stylesheet.", + "$ref": "StyleSheetId" + } + ] + }, + { + "name": "disable", + "description": "Disables the CSS agent for the given page." + }, + { + "name": "enable", + "description": "Enables the CSS agent for the given page. Clients should not assume that the CSS agent has been\nenabled until the result of this command is received." + }, + { + "name": "forcePseudoState", + "description": "Ensures that the given node will have specified pseudo-classes whenever its style is computed by\nthe browser.", + "parameters": [ + { + "name": "nodeId", + "description": "The element id for which to force the pseudo state.", + "$ref": "DOM.NodeId" + }, + { + "name": "forcedPseudoClasses", + "description": "Element pseudo classes to force when computing the element's style.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "getBackgroundColors", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to get background colors for.", + "$ref": "DOM.NodeId" + } + ], + "returns": [ + { + "name": "backgroundColors", + "description": "The range of background colors behind this element, if it contains any visible text. If no\nvisible text is present, this will be undefined. In the case of a flat background color,\nthis will consist of simply that color. In the case of a gradient, this will consist of each\nof the color stops. For anything more complicated, this will be an empty array. Images will\nbe ignored (as if the image had failed to load).", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "computedFontSize", + "description": "The computed font size for this node, as a CSS computed value string (e.g. '12px').", + "optional": true, + "type": "string" + }, + { + "name": "computedFontWeight", + "description": "The computed font weight for this node, as a CSS computed value string (e.g. 'normal' or\n'100').", + "optional": true, + "type": "string" + }, + { + "name": "computedBodyFontSize", + "description": "The computed font size for the document body, as a computed CSS value string (e.g. '16px').", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "getComputedStyleForNode", + "description": "Returns the computed style for a DOM node identified by `nodeId`.", + "parameters": [ + { + "name": "nodeId", + "$ref": "DOM.NodeId" + } + ], + "returns": [ + { + "name": "computedStyle", + "description": "Computed style for the specified DOM node.", + "type": "array", + "items": { + "$ref": "CSSComputedStyleProperty" + } + } + ] + }, + { + "name": "getInlineStylesForNode", + "description": "Returns the styles defined inline (explicitly in the \"style\" attribute and implicitly, using DOM\nattributes) for a DOM node identified by `nodeId`.", + "parameters": [ + { + "name": "nodeId", + "$ref": "DOM.NodeId" + } + ], + "returns": [ + { + "name": "inlineStyle", + "description": "Inline style for the specified DOM node.", + "optional": true, + "$ref": "CSSStyle" + }, + { + "name": "attributesStyle", + "description": "Attribute-defined element style (e.g. resulting from \"width=20 height=100%\").", + "optional": true, + "$ref": "CSSStyle" + } + ] + }, + { + "name": "getMatchedStylesForNode", + "description": "Returns requested styles for a DOM node identified by `nodeId`.", + "parameters": [ + { + "name": "nodeId", + "$ref": "DOM.NodeId" + } + ], + "returns": [ + { + "name": "inlineStyle", + "description": "Inline style for the specified DOM node.", + "optional": true, + "$ref": "CSSStyle" + }, + { + "name": "attributesStyle", + "description": "Attribute-defined element style (e.g. resulting from \"width=20 height=100%\").", + "optional": true, + "$ref": "CSSStyle" + }, + { + "name": "matchedCSSRules", + "description": "CSS rules matching this node, from all applicable stylesheets.", + "optional": true, + "type": "array", + "items": { + "$ref": "RuleMatch" + } + }, + { + "name": "pseudoElements", + "description": "Pseudo style matches for this node.", + "optional": true, + "type": "array", + "items": { + "$ref": "PseudoElementMatches" + } + }, + { + "name": "inherited", + "description": "A chain of inherited styles (from the immediate node parent up to the DOM tree root).", + "optional": true, + "type": "array", + "items": { + "$ref": "InheritedStyleEntry" + } + }, + { + "name": "cssKeyframesRules", + "description": "A list of CSS keyframed animations matching this node.", + "optional": true, + "type": "array", + "items": { + "$ref": "CSSKeyframesRule" + } + } + ] + }, + { + "name": "getMediaQueries", + "description": "Returns all media queries parsed by the rendering engine.", + "returns": [ + { + "name": "medias", + "type": "array", + "items": { + "$ref": "CSSMedia" + } + } + ] + }, + { + "name": "getPlatformFontsForNode", + "description": "Requests information about platform fonts which we used to render child TextNodes in the given\nnode.", + "parameters": [ + { + "name": "nodeId", + "$ref": "DOM.NodeId" + } + ], + "returns": [ + { + "name": "fonts", + "description": "Usage statistics for every employed platform font.", + "type": "array", + "items": { + "$ref": "PlatformFontUsage" + } + } + ] + }, + { + "name": "getStyleSheetText", + "description": "Returns the current textual content for a stylesheet.", + "parameters": [ + { + "name": "styleSheetId", + "$ref": "StyleSheetId" + } + ], + "returns": [ + { + "name": "text", + "description": "The stylesheet text.", + "type": "string" + } + ] + }, + { + "name": "setEffectivePropertyValueForNode", + "description": "Find a rule with the given active property for the given node and set the new value for this\nproperty", + "parameters": [ + { + "name": "nodeId", + "description": "The element id for which to set property.", + "$ref": "DOM.NodeId" + }, + { + "name": "propertyName", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + }, + { + "name": "setKeyframeKey", + "description": "Modifies the keyframe rule key text.", + "parameters": [ + { + "name": "styleSheetId", + "$ref": "StyleSheetId" + }, + { + "name": "range", + "$ref": "SourceRange" + }, + { + "name": "keyText", + "type": "string" + } + ], + "returns": [ + { + "name": "keyText", + "description": "The resulting key text after modification.", + "$ref": "Value" + } + ] + }, + { + "name": "setMediaText", + "description": "Modifies the rule selector.", + "parameters": [ + { + "name": "styleSheetId", + "$ref": "StyleSheetId" + }, + { + "name": "range", + "$ref": "SourceRange" + }, + { + "name": "text", + "type": "string" + } + ], + "returns": [ + { + "name": "media", + "description": "The resulting CSS media rule after modification.", + "$ref": "CSSMedia" + } + ] + }, + { + "name": "setRuleSelector", + "description": "Modifies the rule selector.", + "parameters": [ + { + "name": "styleSheetId", + "$ref": "StyleSheetId" + }, + { + "name": "range", + "$ref": "SourceRange" + }, + { + "name": "selector", + "type": "string" + } + ], + "returns": [ + { + "name": "selectorList", + "description": "The resulting selector list after modification.", + "$ref": "SelectorList" + } + ] + }, + { + "name": "setStyleSheetText", + "description": "Sets the new stylesheet text.", + "parameters": [ + { + "name": "styleSheetId", + "$ref": "StyleSheetId" + }, + { + "name": "text", + "type": "string" + } + ], + "returns": [ + { + "name": "sourceMapURL", + "description": "URL of source map associated with script (if any).", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "setStyleTexts", + "description": "Applies specified style edits one after another in the given order.", + "parameters": [ + { + "name": "edits", + "type": "array", + "items": { + "$ref": "StyleDeclarationEdit" + } + } + ], + "returns": [ + { + "name": "styles", + "description": "The resulting styles after modification.", + "type": "array", + "items": { + "$ref": "CSSStyle" + } + } + ] + }, + { + "name": "startRuleUsageTracking", + "description": "Enables the selector recording." + }, + { + "name": "stopRuleUsageTracking", + "description": "Stop tracking rule usage and return the list of rules that were used since last call to\n`takeCoverageDelta` (or since start of coverage instrumentation)", + "returns": [ + { + "name": "ruleUsage", + "type": "array", + "items": { + "$ref": "RuleUsage" + } + } + ] + }, + { + "name": "takeCoverageDelta", + "description": "Obtain list of rules that became used since last call to this method (or since start of coverage\ninstrumentation)", + "returns": [ + { + "name": "coverage", + "type": "array", + "items": { + "$ref": "RuleUsage" + } + } + ] + } + ], + "events": [ + { + "name": "fontsUpdated", + "description": "Fires whenever a web font is updated. A non-empty font parameter indicates a successfully loaded\nweb font", + "parameters": [ + { + "name": "font", + "description": "The web font that has loaded.", + "optional": true, + "$ref": "FontFace" + } + ] + }, + { + "name": "mediaQueryResultChanged", + "description": "Fires whenever a MediaQuery result changes (for example, after a browser window has been\nresized.) The current implementation considers only viewport-dependent media features." + }, + { + "name": "styleSheetAdded", + "description": "Fired whenever an active document stylesheet is added.", + "parameters": [ + { + "name": "header", + "description": "Added stylesheet metainfo.", + "$ref": "CSSStyleSheetHeader" + } + ] + }, + { + "name": "styleSheetChanged", + "description": "Fired whenever a stylesheet is changed as a result of the client operation.", + "parameters": [ + { + "name": "styleSheetId", + "$ref": "StyleSheetId" + } + ] + }, + { + "name": "styleSheetRemoved", + "description": "Fired whenever an active document stylesheet is removed.", + "parameters": [ + { + "name": "styleSheetId", + "description": "Identifier of the removed stylesheet.", + "$ref": "StyleSheetId" + } + ] + } + ] + }, + { + "domain": "CacheStorage", + "experimental": true, + "types": [ + { + "id": "CacheId", + "description": "Unique identifier of the Cache object.", + "type": "string" + }, + { + "id": "CachedResponseType", + "description": "type of HTTP response cached", + "type": "string", + "enum": [ + "basic", + "cors", + "default", + "error", + "opaqueResponse", + "opaqueRedirect" + ] + }, + { + "id": "DataEntry", + "description": "Data entry.", + "type": "object", + "properties": [ + { + "name": "requestURL", + "description": "Request URL.", + "type": "string" + }, + { + "name": "requestMethod", + "description": "Request method.", + "type": "string" + }, + { + "name": "requestHeaders", + "description": "Request headers", + "type": "array", + "items": { + "$ref": "Header" + } + }, + { + "name": "responseTime", + "description": "Number of seconds since epoch.", + "type": "number" + }, + { + "name": "responseStatus", + "description": "HTTP response status code.", + "type": "integer" + }, + { + "name": "responseStatusText", + "description": "HTTP response status text.", + "type": "string" + }, + { + "name": "responseType", + "description": "HTTP response type", + "$ref": "CachedResponseType" + }, + { + "name": "responseHeaders", + "description": "Response headers", + "type": "array", + "items": { + "$ref": "Header" + } + } + ] + }, + { + "id": "Cache", + "description": "Cache identifier.", + "type": "object", + "properties": [ + { + "name": "cacheId", + "description": "An opaque unique id of the cache.", + "$ref": "CacheId" + }, + { + "name": "securityOrigin", + "description": "Security origin of the cache.", + "type": "string" + }, + { + "name": "cacheName", + "description": "The name of the cache.", + "type": "string" + } + ] + }, + { + "id": "Header", + "type": "object", + "properties": [ + { + "name": "name", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + }, + { + "id": "CachedResponse", + "description": "Cached response", + "type": "object", + "properties": [ + { + "name": "body", + "description": "Entry content, base64-encoded.", + "type": "binary" + } + ] + } + ], + "commands": [ + { + "name": "deleteCache", + "description": "Deletes a cache.", + "parameters": [ + { + "name": "cacheId", + "description": "Id of cache for deletion.", + "$ref": "CacheId" + } + ] + }, + { + "name": "deleteEntry", + "description": "Deletes a cache entry.", + "parameters": [ + { + "name": "cacheId", + "description": "Id of cache where the entry will be deleted.", + "$ref": "CacheId" + }, + { + "name": "request", + "description": "URL spec of the request.", + "type": "string" + } + ] + }, + { + "name": "requestCacheNames", + "description": "Requests cache names.", + "parameters": [ + { + "name": "securityOrigin", + "description": "Security origin.", + "type": "string" + } + ], + "returns": [ + { + "name": "caches", + "description": "Caches for the security origin.", + "type": "array", + "items": { + "$ref": "Cache" + } + } + ] + }, + { + "name": "requestCachedResponse", + "description": "Fetches cache entry.", + "parameters": [ + { + "name": "cacheId", + "description": "Id of cache that contains the enty.", + "$ref": "CacheId" + }, + { + "name": "requestURL", + "description": "URL spec of the request.", + "type": "string" + } + ], + "returns": [ + { + "name": "response", + "description": "Response read from the cache.", + "$ref": "CachedResponse" + } + ] + }, + { + "name": "requestEntries", + "description": "Requests data from cache.", + "parameters": [ + { + "name": "cacheId", + "description": "ID of cache to get entries from.", + "$ref": "CacheId" + }, + { + "name": "skipCount", + "description": "Number of records to skip.", + "type": "integer" + }, + { + "name": "pageSize", + "description": "Number of records to fetch.", + "type": "integer" + } + ], + "returns": [ + { + "name": "cacheDataEntries", + "description": "Array of object store data entries.", + "type": "array", + "items": { + "$ref": "DataEntry" + } + }, + { + "name": "hasMore", + "description": "If true, there are more entries to fetch in the given range.", + "type": "boolean" + } + ] + } + ] + }, + { + "domain": "DOM", + "description": "This domain exposes DOM read/write operations. Each DOM Node is represented with its mirror object\nthat has an `id`. This `id` can be used to get additional information on the Node, resolve it into\nthe JavaScript object wrapper, etc. It is important that client receives DOM events only for the\nnodes that are known to the client. Backend keeps track of the nodes that were sent to the client\nand never sends the same node twice. It is client's responsibility to collect information about\nthe nodes that were sent to the client.<p>Note that `iframe` owner elements will return\ncorresponding document elements as their child nodes.</p>", + "dependencies": [ + "Runtime" + ], + "types": [ + { + "id": "NodeId", + "description": "Unique DOM node identifier.", + "type": "integer" + }, + { + "id": "BackendNodeId", + "description": "Unique DOM node identifier used to reference a node that may not have been pushed to the\nfront-end.", + "type": "integer" + }, + { + "id": "BackendNode", + "description": "Backend node with a friendly name.", + "type": "object", + "properties": [ + { + "name": "nodeType", + "description": "`Node`'s nodeType.", + "type": "integer" + }, + { + "name": "nodeName", + "description": "`Node`'s nodeName.", + "type": "string" + }, + { + "name": "backendNodeId", + "$ref": "BackendNodeId" + } + ] + }, + { + "id": "PseudoType", + "description": "Pseudo element type.", + "type": "string", + "enum": [ + "first-line", + "first-letter", + "before", + "after", + "backdrop", + "selection", + "first-line-inherited", + "scrollbar", + "scrollbar-thumb", + "scrollbar-button", + "scrollbar-track", + "scrollbar-track-piece", + "scrollbar-corner", + "resizer", + "input-list-button" + ] + }, + { + "id": "ShadowRootType", + "description": "Shadow root type.", + "type": "string", + "enum": [ + "user-agent", + "open", + "closed" + ] + }, + { + "id": "Node", + "description": "DOM interaction is implemented in terms of mirror objects that represent the actual DOM nodes.\nDOMNode is a base node mirror type.", + "type": "object", + "properties": [ + { + "name": "nodeId", + "description": "Node identifier that is passed into the rest of the DOM messages as the `nodeId`. Backend\nwill only push node with given `id` once. It is aware of all requested nodes and will only\nfire DOM events for nodes known to the client.", + "$ref": "NodeId" + }, + { + "name": "parentId", + "description": "The id of the parent node if any.", + "optional": true, + "$ref": "NodeId" + }, + { + "name": "backendNodeId", + "description": "The BackendNodeId for this node.", + "$ref": "BackendNodeId" + }, + { + "name": "nodeType", + "description": "`Node`'s nodeType.", + "type": "integer" + }, + { + "name": "nodeName", + "description": "`Node`'s nodeName.", + "type": "string" + }, + { + "name": "localName", + "description": "`Node`'s localName.", + "type": "string" + }, + { + "name": "nodeValue", + "description": "`Node`'s nodeValue.", + "type": "string" + }, + { + "name": "childNodeCount", + "description": "Child count for `Container` nodes.", + "optional": true, + "type": "integer" + }, + { + "name": "children", + "description": "Child nodes of this node when requested with children.", + "optional": true, + "type": "array", + "items": { + "$ref": "Node" + } + }, + { + "name": "attributes", + "description": "Attributes of the `Element` node in the form of flat array `[name1, value1, name2, value2]`.", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "documentURL", + "description": "Document URL that `Document` or `FrameOwner` node points to.", + "optional": true, + "type": "string" + }, + { + "name": "baseURL", + "description": "Base URL that `Document` or `FrameOwner` node uses for URL completion.", + "optional": true, + "type": "string" + }, + { + "name": "publicId", + "description": "`DocumentType`'s publicId.", + "optional": true, + "type": "string" + }, + { + "name": "systemId", + "description": "`DocumentType`'s systemId.", + "optional": true, + "type": "string" + }, + { + "name": "internalSubset", + "description": "`DocumentType`'s internalSubset.", + "optional": true, + "type": "string" + }, + { + "name": "xmlVersion", + "description": "`Document`'s XML version in case of XML documents.", + "optional": true, + "type": "string" + }, + { + "name": "name", + "description": "`Attr`'s name.", + "optional": true, + "type": "string" + }, + { + "name": "value", + "description": "`Attr`'s value.", + "optional": true, + "type": "string" + }, + { + "name": "pseudoType", + "description": "Pseudo element type for this node.", + "optional": true, + "$ref": "PseudoType" + }, + { + "name": "shadowRootType", + "description": "Shadow root type.", + "optional": true, + "$ref": "ShadowRootType" + }, + { + "name": "frameId", + "description": "Frame ID for frame owner elements.", + "optional": true, + "$ref": "Page.FrameId" + }, + { + "name": "contentDocument", + "description": "Content document for frame owner elements.", + "optional": true, + "$ref": "Node" + }, + { + "name": "shadowRoots", + "description": "Shadow root list for given element host.", + "optional": true, + "type": "array", + "items": { + "$ref": "Node" + } + }, + { + "name": "templateContent", + "description": "Content document fragment for template elements.", + "optional": true, + "$ref": "Node" + }, + { + "name": "pseudoElements", + "description": "Pseudo elements associated with this node.", + "optional": true, + "type": "array", + "items": { + "$ref": "Node" + } + }, + { + "name": "importedDocument", + "description": "Import document for the HTMLImport links.", + "optional": true, + "$ref": "Node" + }, + { + "name": "distributedNodes", + "description": "Distributed nodes for given insertion point.", + "optional": true, + "type": "array", + "items": { + "$ref": "BackendNode" + } + }, + { + "name": "isSVG", + "description": "Whether the node is SVG.", + "optional": true, + "type": "boolean" + } + ] + }, + { + "id": "RGBA", + "description": "A structure holding an RGBA color.", + "type": "object", + "properties": [ + { + "name": "r", + "description": "The red component, in the [0-255] range.", + "type": "integer" + }, + { + "name": "g", + "description": "The green component, in the [0-255] range.", + "type": "integer" + }, + { + "name": "b", + "description": "The blue component, in the [0-255] range.", + "type": "integer" + }, + { + "name": "a", + "description": "The alpha component, in the [0-1] range (default: 1).", + "optional": true, + "type": "number" + } + ] + }, + { + "id": "Quad", + "description": "An array of quad vertices, x immediately followed by y for each point, points clock-wise.", + "type": "array", + "items": { + "type": "number" + } + }, + { + "id": "BoxModel", + "description": "Box model.", + "type": "object", + "properties": [ + { + "name": "content", + "description": "Content box", + "$ref": "Quad" + }, + { + "name": "padding", + "description": "Padding box", + "$ref": "Quad" + }, + { + "name": "border", + "description": "Border box", + "$ref": "Quad" + }, + { + "name": "margin", + "description": "Margin box", + "$ref": "Quad" + }, + { + "name": "width", + "description": "Node width", + "type": "integer" + }, + { + "name": "height", + "description": "Node height", + "type": "integer" + }, + { + "name": "shapeOutside", + "description": "Shape outside coordinates", + "optional": true, + "$ref": "ShapeOutsideInfo" + } + ] + }, + { + "id": "ShapeOutsideInfo", + "description": "CSS Shape Outside details.", + "type": "object", + "properties": [ + { + "name": "bounds", + "description": "Shape bounds", + "$ref": "Quad" + }, + { + "name": "shape", + "description": "Shape coordinate details", + "type": "array", + "items": { + "type": "any" + } + }, + { + "name": "marginShape", + "description": "Margin shape bounds", + "type": "array", + "items": { + "type": "any" + } + } + ] + }, + { + "id": "Rect", + "description": "Rectangle.", + "type": "object", + "properties": [ + { + "name": "x", + "description": "X coordinate", + "type": "number" + }, + { + "name": "y", + "description": "Y coordinate", + "type": "number" + }, + { + "name": "width", + "description": "Rectangle width", + "type": "number" + }, + { + "name": "height", + "description": "Rectangle height", + "type": "number" + } + ] + } + ], + "commands": [ + { + "name": "collectClassNamesFromSubtree", + "description": "Collects class names for the node with given id and all of it's child nodes.", + "experimental": true, + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to collect class names.", + "$ref": "NodeId" + } + ], + "returns": [ + { + "name": "classNames", + "description": "Class name list.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "copyTo", + "description": "Creates a deep copy of the specified node and places it into the target container before the\ngiven anchor.", + "experimental": true, + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to copy.", + "$ref": "NodeId" + }, + { + "name": "targetNodeId", + "description": "Id of the element to drop the copy into.", + "$ref": "NodeId" + }, + { + "name": "insertBeforeNodeId", + "description": "Drop the copy before this node (if absent, the copy becomes the last child of\n`targetNodeId`).", + "optional": true, + "$ref": "NodeId" + } + ], + "returns": [ + { + "name": "nodeId", + "description": "Id of the node clone.", + "$ref": "NodeId" + } + ] + }, + { + "name": "describeNode", + "description": "Describes node given its id, does not require domain to be enabled. Does not start tracking any\nobjects, can be used for automation.", + "parameters": [ + { + "name": "nodeId", + "description": "Identifier of the node.", + "optional": true, + "$ref": "NodeId" + }, + { + "name": "backendNodeId", + "description": "Identifier of the backend node.", + "optional": true, + "$ref": "BackendNodeId" + }, + { + "name": "objectId", + "description": "JavaScript object id of the node wrapper.", + "optional": true, + "$ref": "Runtime.RemoteObjectId" + }, + { + "name": "depth", + "description": "The maximum depth at which children should be retrieved, defaults to 1. Use -1 for the\nentire subtree or provide an integer larger than 0.", + "optional": true, + "type": "integer" + }, + { + "name": "pierce", + "description": "Whether or not iframes and shadow roots should be traversed when returning the subtree\n(default is false).", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "node", + "description": "Node description.", + "$ref": "Node" + } + ] + }, + { + "name": "disable", + "description": "Disables DOM agent for the given page." + }, + { + "name": "discardSearchResults", + "description": "Discards search results from the session with the given id. `getSearchResults` should no longer\nbe called for that search.", + "experimental": true, + "parameters": [ + { + "name": "searchId", + "description": "Unique search session identifier.", + "type": "string" + } + ] + }, + { + "name": "enable", + "description": "Enables DOM agent for the given page." + }, + { + "name": "focus", + "description": "Focuses the given element.", + "parameters": [ + { + "name": "nodeId", + "description": "Identifier of the node.", + "optional": true, + "$ref": "NodeId" + }, + { + "name": "backendNodeId", + "description": "Identifier of the backend node.", + "optional": true, + "$ref": "BackendNodeId" + }, + { + "name": "objectId", + "description": "JavaScript object id of the node wrapper.", + "optional": true, + "$ref": "Runtime.RemoteObjectId" + } + ] + }, + { + "name": "getAttributes", + "description": "Returns attributes for the specified node.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to retrieve attibutes for.", + "$ref": "NodeId" + } + ], + "returns": [ + { + "name": "attributes", + "description": "An interleaved array of node attribute names and values.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "getBoxModel", + "description": "Returns boxes for the given node.", + "parameters": [ + { + "name": "nodeId", + "description": "Identifier of the node.", + "optional": true, + "$ref": "NodeId" + }, + { + "name": "backendNodeId", + "description": "Identifier of the backend node.", + "optional": true, + "$ref": "BackendNodeId" + }, + { + "name": "objectId", + "description": "JavaScript object id of the node wrapper.", + "optional": true, + "$ref": "Runtime.RemoteObjectId" + } + ], + "returns": [ + { + "name": "model", + "description": "Box model for the node.", + "$ref": "BoxModel" + } + ] + }, + { + "name": "getContentQuads", + "description": "Returns quads that describe node position on the page. This method\nmight return multiple quads for inline nodes.", + "experimental": true, + "parameters": [ + { + "name": "nodeId", + "description": "Identifier of the node.", + "optional": true, + "$ref": "NodeId" + }, + { + "name": "backendNodeId", + "description": "Identifier of the backend node.", + "optional": true, + "$ref": "BackendNodeId" + }, + { + "name": "objectId", + "description": "JavaScript object id of the node wrapper.", + "optional": true, + "$ref": "Runtime.RemoteObjectId" + } + ], + "returns": [ + { + "name": "quads", + "description": "Quads that describe node layout relative to viewport.", + "type": "array", + "items": { + "$ref": "Quad" + } + } + ] + }, + { + "name": "getDocument", + "description": "Returns the root DOM node (and optionally the subtree) to the caller.", + "parameters": [ + { + "name": "depth", + "description": "The maximum depth at which children should be retrieved, defaults to 1. Use -1 for the\nentire subtree or provide an integer larger than 0.", + "optional": true, + "type": "integer" + }, + { + "name": "pierce", + "description": "Whether or not iframes and shadow roots should be traversed when returning the subtree\n(default is false).", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "root", + "description": "Resulting node.", + "$ref": "Node" + } + ] + }, + { + "name": "getFlattenedDocument", + "description": "Returns the root DOM node (and optionally the subtree) to the caller.", + "parameters": [ + { + "name": "depth", + "description": "The maximum depth at which children should be retrieved, defaults to 1. Use -1 for the\nentire subtree or provide an integer larger than 0.", + "optional": true, + "type": "integer" + }, + { + "name": "pierce", + "description": "Whether or not iframes and shadow roots should be traversed when returning the subtree\n(default is false).", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "nodes", + "description": "Resulting node.", + "type": "array", + "items": { + "$ref": "Node" + } + } + ] + }, + { + "name": "getNodeForLocation", + "description": "Returns node id at given location. Depending on whether DOM domain is enabled, nodeId is\neither returned or not.", + "experimental": true, + "parameters": [ + { + "name": "x", + "description": "X coordinate.", + "type": "integer" + }, + { + "name": "y", + "description": "Y coordinate.", + "type": "integer" + }, + { + "name": "includeUserAgentShadowDOM", + "description": "False to skip to the nearest non-UA shadow root ancestor (default: false).", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "backendNodeId", + "description": "Resulting node.", + "$ref": "BackendNodeId" + }, + { + "name": "nodeId", + "description": "Id of the node at given coordinates, only when enabled.", + "optional": true, + "$ref": "NodeId" + } + ] + }, + { + "name": "getOuterHTML", + "description": "Returns node's HTML markup.", + "parameters": [ + { + "name": "nodeId", + "description": "Identifier of the node.", + "optional": true, + "$ref": "NodeId" + }, + { + "name": "backendNodeId", + "description": "Identifier of the backend node.", + "optional": true, + "$ref": "BackendNodeId" + }, + { + "name": "objectId", + "description": "JavaScript object id of the node wrapper.", + "optional": true, + "$ref": "Runtime.RemoteObjectId" + } + ], + "returns": [ + { + "name": "outerHTML", + "description": "Outer HTML markup.", + "type": "string" + } + ] + }, + { + "name": "getRelayoutBoundary", + "description": "Returns the id of the nearest ancestor that is a relayout boundary.", + "experimental": true, + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node.", + "$ref": "NodeId" + } + ], + "returns": [ + { + "name": "nodeId", + "description": "Relayout boundary node id for the given node.", + "$ref": "NodeId" + } + ] + }, + { + "name": "getSearchResults", + "description": "Returns search results from given `fromIndex` to given `toIndex` from the search with the given\nidentifier.", + "experimental": true, + "parameters": [ + { + "name": "searchId", + "description": "Unique search session identifier.", + "type": "string" + }, + { + "name": "fromIndex", + "description": "Start index of the search result to be returned.", + "type": "integer" + }, + { + "name": "toIndex", + "description": "End index of the search result to be returned.", + "type": "integer" + } + ], + "returns": [ + { + "name": "nodeIds", + "description": "Ids of the search result nodes.", + "type": "array", + "items": { + "$ref": "NodeId" + } + } + ] + }, + { + "name": "hideHighlight", + "description": "Hides any highlight.", + "redirect": "Overlay" + }, + { + "name": "highlightNode", + "description": "Highlights DOM node.", + "redirect": "Overlay" + }, + { + "name": "highlightRect", + "description": "Highlights given rectangle.", + "redirect": "Overlay" + }, + { + "name": "markUndoableState", + "description": "Marks last undoable state.", + "experimental": true + }, + { + "name": "moveTo", + "description": "Moves node into the new container, places it before the given anchor.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to move.", + "$ref": "NodeId" + }, + { + "name": "targetNodeId", + "description": "Id of the element to drop the moved node into.", + "$ref": "NodeId" + }, + { + "name": "insertBeforeNodeId", + "description": "Drop node before this one (if absent, the moved node becomes the last child of\n`targetNodeId`).", + "optional": true, + "$ref": "NodeId" + } + ], + "returns": [ + { + "name": "nodeId", + "description": "New id of the moved node.", + "$ref": "NodeId" + } + ] + }, + { + "name": "performSearch", + "description": "Searches for a given string in the DOM tree. Use `getSearchResults` to access search results or\n`cancelSearch` to end this search session.", + "experimental": true, + "parameters": [ + { + "name": "query", + "description": "Plain text or query selector or XPath search query.", + "type": "string" + }, + { + "name": "includeUserAgentShadowDOM", + "description": "True to search in user agent shadow DOM.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "searchId", + "description": "Unique search session identifier.", + "type": "string" + }, + { + "name": "resultCount", + "description": "Number of search results.", + "type": "integer" + } + ] + }, + { + "name": "pushNodeByPathToFrontend", + "description": "Requests that the node is sent to the caller given its path. // FIXME, use XPath", + "experimental": true, + "parameters": [ + { + "name": "path", + "description": "Path to node in the proprietary format.", + "type": "string" + } + ], + "returns": [ + { + "name": "nodeId", + "description": "Id of the node for given path.", + "$ref": "NodeId" + } + ] + }, + { + "name": "pushNodesByBackendIdsToFrontend", + "description": "Requests that a batch of nodes is sent to the caller given their backend node ids.", + "experimental": true, + "parameters": [ + { + "name": "backendNodeIds", + "description": "The array of backend node ids.", + "type": "array", + "items": { + "$ref": "BackendNodeId" + } + } + ], + "returns": [ + { + "name": "nodeIds", + "description": "The array of ids of pushed nodes that correspond to the backend ids specified in\nbackendNodeIds.", + "type": "array", + "items": { + "$ref": "NodeId" + } + } + ] + }, + { + "name": "querySelector", + "description": "Executes `querySelector` on a given node.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to query upon.", + "$ref": "NodeId" + }, + { + "name": "selector", + "description": "Selector string.", + "type": "string" + } + ], + "returns": [ + { + "name": "nodeId", + "description": "Query selector result.", + "$ref": "NodeId" + } + ] + }, + { + "name": "querySelectorAll", + "description": "Executes `querySelectorAll` on a given node.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to query upon.", + "$ref": "NodeId" + }, + { + "name": "selector", + "description": "Selector string.", + "type": "string" + } + ], + "returns": [ + { + "name": "nodeIds", + "description": "Query selector result.", + "type": "array", + "items": { + "$ref": "NodeId" + } + } + ] + }, + { + "name": "redo", + "description": "Re-does the last undone action.", + "experimental": true + }, + { + "name": "removeAttribute", + "description": "Removes attribute with given name from an element with given id.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the element to remove attribute from.", + "$ref": "NodeId" + }, + { + "name": "name", + "description": "Name of the attribute to remove.", + "type": "string" + } + ] + }, + { + "name": "removeNode", + "description": "Removes node with given id.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to remove.", + "$ref": "NodeId" + } + ] + }, + { + "name": "requestChildNodes", + "description": "Requests that children of the node with given id are returned to the caller in form of\n`setChildNodes` events where not only immediate children are retrieved, but all children down to\nthe specified depth.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to get children for.", + "$ref": "NodeId" + }, + { + "name": "depth", + "description": "The maximum depth at which children should be retrieved, defaults to 1. Use -1 for the\nentire subtree or provide an integer larger than 0.", + "optional": true, + "type": "integer" + }, + { + "name": "pierce", + "description": "Whether or not iframes and shadow roots should be traversed when returning the sub-tree\n(default is false).", + "optional": true, + "type": "boolean" + } + ] + }, + { + "name": "requestNode", + "description": "Requests that the node is sent to the caller given the JavaScript node object reference. All\nnodes that form the path from the node to the root are also sent to the client as a series of\n`setChildNodes` notifications.", + "parameters": [ + { + "name": "objectId", + "description": "JavaScript object id to convert into node.", + "$ref": "Runtime.RemoteObjectId" + } + ], + "returns": [ + { + "name": "nodeId", + "description": "Node id for given object.", + "$ref": "NodeId" + } + ] + }, + { + "name": "resolveNode", + "description": "Resolves the JavaScript node object for a given NodeId or BackendNodeId.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to resolve.", + "optional": true, + "$ref": "NodeId" + }, + { + "name": "backendNodeId", + "description": "Backend identifier of the node to resolve.", + "optional": true, + "$ref": "DOM.BackendNodeId" + }, + { + "name": "objectGroup", + "description": "Symbolic group name that can be used to release multiple objects.", + "optional": true, + "type": "string" + } + ], + "returns": [ + { + "name": "object", + "description": "JavaScript object wrapper for given node.", + "$ref": "Runtime.RemoteObject" + } + ] + }, + { + "name": "setAttributeValue", + "description": "Sets attribute for an element with given id.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the element to set attribute for.", + "$ref": "NodeId" + }, + { + "name": "name", + "description": "Attribute name.", + "type": "string" + }, + { + "name": "value", + "description": "Attribute value.", + "type": "string" + } + ] + }, + { + "name": "setAttributesAsText", + "description": "Sets attributes on element with given id. This method is useful when user edits some existing\nattribute value and types in several attribute name/value pairs.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the element to set attributes for.", + "$ref": "NodeId" + }, + { + "name": "text", + "description": "Text with a number of attributes. Will parse this text using HTML parser.", + "type": "string" + }, + { + "name": "name", + "description": "Attribute name to replace with new attributes derived from text in case text parsed\nsuccessfully.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "setFileInputFiles", + "description": "Sets files for the given file input element.", + "parameters": [ + { + "name": "files", + "description": "Array of file paths to set.", + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "nodeId", + "description": "Identifier of the node.", + "optional": true, + "$ref": "NodeId" + }, + { + "name": "backendNodeId", + "description": "Identifier of the backend node.", + "optional": true, + "$ref": "BackendNodeId" + }, + { + "name": "objectId", + "description": "JavaScript object id of the node wrapper.", + "optional": true, + "$ref": "Runtime.RemoteObjectId" + } + ] + }, + { + "name": "setInspectedNode", + "description": "Enables console to refer to the node with given id via $x (see Command Line API for more details\n$x functions).", + "experimental": true, + "parameters": [ + { + "name": "nodeId", + "description": "DOM node id to be accessible by means of $x command line API.", + "$ref": "NodeId" + } + ] + }, + { + "name": "setNodeName", + "description": "Sets node name for a node with given id.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to set name for.", + "$ref": "NodeId" + }, + { + "name": "name", + "description": "New node's name.", + "type": "string" + } + ], + "returns": [ + { + "name": "nodeId", + "description": "New node's id.", + "$ref": "NodeId" + } + ] + }, + { + "name": "setNodeValue", + "description": "Sets node value for a node with given id.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to set value for.", + "$ref": "NodeId" + }, + { + "name": "value", + "description": "New node's value.", + "type": "string" + } + ] + }, + { + "name": "setOuterHTML", + "description": "Sets node HTML markup, returns new node id.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to set markup for.", + "$ref": "NodeId" + }, + { + "name": "outerHTML", + "description": "Outer HTML markup to set.", + "type": "string" + } + ] + }, + { + "name": "undo", + "description": "Undoes the last performed action.", + "experimental": true + }, + { + "name": "getFrameOwner", + "description": "Returns iframe node that owns iframe with the given domain.", + "experimental": true, + "parameters": [ + { + "name": "frameId", + "$ref": "Page.FrameId" + } + ], + "returns": [ + { + "name": "backendNodeId", + "description": "Resulting node.", + "$ref": "BackendNodeId" + }, + { + "name": "nodeId", + "description": "Id of the node at given coordinates, only when enabled.", + "optional": true, + "$ref": "NodeId" + } + ] + } + ], + "events": [ + { + "name": "attributeModified", + "description": "Fired when `Element`'s attribute is modified.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node that has changed.", + "$ref": "NodeId" + }, + { + "name": "name", + "description": "Attribute name.", + "type": "string" + }, + { + "name": "value", + "description": "Attribute value.", + "type": "string" + } + ] + }, + { + "name": "attributeRemoved", + "description": "Fired when `Element`'s attribute is removed.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node that has changed.", + "$ref": "NodeId" + }, + { + "name": "name", + "description": "A ttribute name.", + "type": "string" + } + ] + }, + { + "name": "characterDataModified", + "description": "Mirrors `DOMCharacterDataModified` event.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node that has changed.", + "$ref": "NodeId" + }, + { + "name": "characterData", + "description": "New text value.", + "type": "string" + } + ] + }, + { + "name": "childNodeCountUpdated", + "description": "Fired when `Container`'s child node count has changed.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node that has changed.", + "$ref": "NodeId" + }, + { + "name": "childNodeCount", + "description": "New node count.", + "type": "integer" + } + ] + }, + { + "name": "childNodeInserted", + "description": "Mirrors `DOMNodeInserted` event.", + "parameters": [ + { + "name": "parentNodeId", + "description": "Id of the node that has changed.", + "$ref": "NodeId" + }, + { + "name": "previousNodeId", + "description": "If of the previous siblint.", + "$ref": "NodeId" + }, + { + "name": "node", + "description": "Inserted node data.", + "$ref": "Node" + } + ] + }, + { + "name": "childNodeRemoved", + "description": "Mirrors `DOMNodeRemoved` event.", + "parameters": [ + { + "name": "parentNodeId", + "description": "Parent id.", + "$ref": "NodeId" + }, + { + "name": "nodeId", + "description": "Id of the node that has been removed.", + "$ref": "NodeId" + } + ] + }, + { + "name": "distributedNodesUpdated", + "description": "Called when distrubution is changed.", + "experimental": true, + "parameters": [ + { + "name": "insertionPointId", + "description": "Insertion point where distrubuted nodes were updated.", + "$ref": "NodeId" + }, + { + "name": "distributedNodes", + "description": "Distributed nodes for given insertion point.", + "type": "array", + "items": { + "$ref": "BackendNode" + } + } + ] + }, + { + "name": "documentUpdated", + "description": "Fired when `Document` has been totally updated. Node ids are no longer valid." + }, + { + "name": "inlineStyleInvalidated", + "description": "Fired when `Element`'s inline style is modified via a CSS property modification.", + "experimental": true, + "parameters": [ + { + "name": "nodeIds", + "description": "Ids of the nodes for which the inline styles have been invalidated.", + "type": "array", + "items": { + "$ref": "NodeId" + } + } + ] + }, + { + "name": "pseudoElementAdded", + "description": "Called when a pseudo element is added to an element.", + "experimental": true, + "parameters": [ + { + "name": "parentId", + "description": "Pseudo element's parent element id.", + "$ref": "NodeId" + }, + { + "name": "pseudoElement", + "description": "The added pseudo element.", + "$ref": "Node" + } + ] + }, + { + "name": "pseudoElementRemoved", + "description": "Called when a pseudo element is removed from an element.", + "experimental": true, + "parameters": [ + { + "name": "parentId", + "description": "Pseudo element's parent element id.", + "$ref": "NodeId" + }, + { + "name": "pseudoElementId", + "description": "The removed pseudo element id.", + "$ref": "NodeId" + } + ] + }, + { + "name": "setChildNodes", + "description": "Fired when backend wants to provide client with the missing DOM structure. This happens upon\nmost of the calls requesting node ids.", + "parameters": [ + { + "name": "parentId", + "description": "Parent node id to populate with children.", + "$ref": "NodeId" + }, + { + "name": "nodes", + "description": "Child nodes array.", + "type": "array", + "items": { + "$ref": "Node" + } + } + ] + }, + { + "name": "shadowRootPopped", + "description": "Called when shadow root is popped from the element.", + "experimental": true, + "parameters": [ + { + "name": "hostId", + "description": "Host element id.", + "$ref": "NodeId" + }, + { + "name": "rootId", + "description": "Shadow root id.", + "$ref": "NodeId" + } + ] + }, + { + "name": "shadowRootPushed", + "description": "Called when shadow root is pushed into the element.", + "experimental": true, + "parameters": [ + { + "name": "hostId", + "description": "Host element id.", + "$ref": "NodeId" + }, + { + "name": "root", + "description": "Shadow root.", + "$ref": "Node" + } + ] + } + ] + }, + { + "domain": "DOMDebugger", + "description": "DOM debugging allows setting breakpoints on particular DOM operations and events. JavaScript\nexecution will stop on these operations as if there was a regular breakpoint set.", + "dependencies": [ + "DOM", + "Debugger", + "Runtime" + ], + "types": [ + { + "id": "DOMBreakpointType", + "description": "DOM breakpoint type.", + "type": "string", + "enum": [ + "subtree-modified", + "attribute-modified", + "node-removed" + ] + }, + { + "id": "EventListener", + "description": "Object event listener.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "`EventListener`'s type.", + "type": "string" + }, + { + "name": "useCapture", + "description": "`EventListener`'s useCapture.", + "type": "boolean" + }, + { + "name": "passive", + "description": "`EventListener`'s passive flag.", + "type": "boolean" + }, + { + "name": "once", + "description": "`EventListener`'s once flag.", + "type": "boolean" + }, + { + "name": "scriptId", + "description": "Script id of the handler code.", + "$ref": "Runtime.ScriptId" + }, + { + "name": "lineNumber", + "description": "Line number in the script (0-based).", + "type": "integer" + }, + { + "name": "columnNumber", + "description": "Column number in the script (0-based).", + "type": "integer" + }, + { + "name": "handler", + "description": "Event handler function value.", + "optional": true, + "$ref": "Runtime.RemoteObject" + }, + { + "name": "originalHandler", + "description": "Event original handler function value.", + "optional": true, + "$ref": "Runtime.RemoteObject" + }, + { + "name": "backendNodeId", + "description": "Node the listener is added to (if any).", + "optional": true, + "$ref": "DOM.BackendNodeId" + } + ] + } + ], + "commands": [ + { + "name": "getEventListeners", + "description": "Returns event listeners of the given object.", + "parameters": [ + { + "name": "objectId", + "description": "Identifier of the object to return listeners for.", + "$ref": "Runtime.RemoteObjectId" + }, + { + "name": "depth", + "description": "The maximum depth at which Node children should be retrieved, defaults to 1. Use -1 for the\nentire subtree or provide an integer larger than 0.", + "optional": true, + "type": "integer" + }, + { + "name": "pierce", + "description": "Whether or not iframes and shadow roots should be traversed when returning the subtree\n(default is false). Reports listeners for all contexts if pierce is enabled.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "listeners", + "description": "Array of relevant listeners.", + "type": "array", + "items": { + "$ref": "EventListener" + } + } + ] + }, + { + "name": "removeDOMBreakpoint", + "description": "Removes DOM breakpoint that was set using `setDOMBreakpoint`.", + "parameters": [ + { + "name": "nodeId", + "description": "Identifier of the node to remove breakpoint from.", + "$ref": "DOM.NodeId" + }, + { + "name": "type", + "description": "Type of the breakpoint to remove.", + "$ref": "DOMBreakpointType" + } + ] + }, + { + "name": "removeEventListenerBreakpoint", + "description": "Removes breakpoint on particular DOM event.", + "parameters": [ + { + "name": "eventName", + "description": "Event name.", + "type": "string" + }, + { + "name": "targetName", + "description": "EventTarget interface name.", + "experimental": true, + "optional": true, + "type": "string" + } + ] + }, + { + "name": "removeInstrumentationBreakpoint", + "description": "Removes breakpoint on particular native event.", + "experimental": true, + "parameters": [ + { + "name": "eventName", + "description": "Instrumentation name to stop on.", + "type": "string" + } + ] + }, + { + "name": "removeXHRBreakpoint", + "description": "Removes breakpoint from XMLHttpRequest.", + "parameters": [ + { + "name": "url", + "description": "Resource URL substring.", + "type": "string" + } + ] + }, + { + "name": "setDOMBreakpoint", + "description": "Sets breakpoint on particular operation with DOM.", + "parameters": [ + { + "name": "nodeId", + "description": "Identifier of the node to set breakpoint on.", + "$ref": "DOM.NodeId" + }, + { + "name": "type", + "description": "Type of the operation to stop upon.", + "$ref": "DOMBreakpointType" + } + ] + }, + { + "name": "setEventListenerBreakpoint", + "description": "Sets breakpoint on particular DOM event.", + "parameters": [ + { + "name": "eventName", + "description": "DOM Event name to stop on (any DOM event will do).", + "type": "string" + }, + { + "name": "targetName", + "description": "EventTarget interface name to stop on. If equal to `\"*\"` or not provided, will stop on any\nEventTarget.", + "experimental": true, + "optional": true, + "type": "string" + } + ] + }, + { + "name": "setInstrumentationBreakpoint", + "description": "Sets breakpoint on particular native event.", + "experimental": true, + "parameters": [ + { + "name": "eventName", + "description": "Instrumentation name to stop on.", + "type": "string" + } + ] + }, + { + "name": "setXHRBreakpoint", + "description": "Sets breakpoint on XMLHttpRequest.", + "parameters": [ + { + "name": "url", + "description": "Resource URL substring. All XHRs having this substring in the URL will get stopped upon.", + "type": "string" + } + ] + } + ] + }, + { + "domain": "DOMSnapshot", + "description": "This domain facilitates obtaining document snapshots with DOM, layout, and style information.", + "experimental": true, + "dependencies": [ + "CSS", + "DOM", + "DOMDebugger", + "Page" + ], + "types": [ + { + "id": "DOMNode", + "description": "A Node in the DOM tree.", + "type": "object", + "properties": [ + { + "name": "nodeType", + "description": "`Node`'s nodeType.", + "type": "integer" + }, + { + "name": "nodeName", + "description": "`Node`'s nodeName.", + "type": "string" + }, + { + "name": "nodeValue", + "description": "`Node`'s nodeValue.", + "type": "string" + }, + { + "name": "textValue", + "description": "Only set for textarea elements, contains the text value.", + "optional": true, + "type": "string" + }, + { + "name": "inputValue", + "description": "Only set for input elements, contains the input's associated text value.", + "optional": true, + "type": "string" + }, + { + "name": "inputChecked", + "description": "Only set for radio and checkbox input elements, indicates if the element has been checked", + "optional": true, + "type": "boolean" + }, + { + "name": "optionSelected", + "description": "Only set for option elements, indicates if the element has been selected", + "optional": true, + "type": "boolean" + }, + { + "name": "backendNodeId", + "description": "`Node`'s id, corresponds to DOM.Node.backendNodeId.", + "$ref": "DOM.BackendNodeId" + }, + { + "name": "childNodeIndexes", + "description": "The indexes of the node's child nodes in the `domNodes` array returned by `getSnapshot`, if\nany.", + "optional": true, + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "attributes", + "description": "Attributes of an `Element` node.", + "optional": true, + "type": "array", + "items": { + "$ref": "NameValue" + } + }, + { + "name": "pseudoElementIndexes", + "description": "Indexes of pseudo elements associated with this node in the `domNodes` array returned by\n`getSnapshot`, if any.", + "optional": true, + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "layoutNodeIndex", + "description": "The index of the node's related layout tree node in the `layoutTreeNodes` array returned by\n`getSnapshot`, if any.", + "optional": true, + "type": "integer" + }, + { + "name": "documentURL", + "description": "Document URL that `Document` or `FrameOwner` node points to.", + "optional": true, + "type": "string" + }, + { + "name": "baseURL", + "description": "Base URL that `Document` or `FrameOwner` node uses for URL completion.", + "optional": true, + "type": "string" + }, + { + "name": "contentLanguage", + "description": "Only set for documents, contains the document's content language.", + "optional": true, + "type": "string" + }, + { + "name": "documentEncoding", + "description": "Only set for documents, contains the document's character set encoding.", + "optional": true, + "type": "string" + }, + { + "name": "publicId", + "description": "`DocumentType` node's publicId.", + "optional": true, + "type": "string" + }, + { + "name": "systemId", + "description": "`DocumentType` node's systemId.", + "optional": true, + "type": "string" + }, + { + "name": "frameId", + "description": "Frame ID for frame owner elements and also for the document node.", + "optional": true, + "$ref": "Page.FrameId" + }, + { + "name": "contentDocumentIndex", + "description": "The index of a frame owner element's content document in the `domNodes` array returned by\n`getSnapshot`, if any.", + "optional": true, + "type": "integer" + }, + { + "name": "pseudoType", + "description": "Type of a pseudo element node.", + "optional": true, + "$ref": "DOM.PseudoType" + }, + { + "name": "shadowRootType", + "description": "Shadow root type.", + "optional": true, + "$ref": "DOM.ShadowRootType" + }, + { + "name": "isClickable", + "description": "Whether this DOM node responds to mouse clicks. This includes nodes that have had click\nevent listeners attached via JavaScript as well as anchor tags that naturally navigate when\nclicked.", + "optional": true, + "type": "boolean" + }, + { + "name": "eventListeners", + "description": "Details of the node's event listeners, if any.", + "optional": true, + "type": "array", + "items": { + "$ref": "DOMDebugger.EventListener" + } + }, + { + "name": "currentSourceURL", + "description": "The selected url for nodes with a srcset attribute.", + "optional": true, + "type": "string" + }, + { + "name": "originURL", + "description": "The url of the script (if any) that generates this node.", + "optional": true, + "type": "string" + }, + { + "name": "scrollOffsetX", + "description": "Scroll offsets, set when this node is a Document.", + "optional": true, + "type": "number" + }, + { + "name": "scrollOffsetY", + "optional": true, + "type": "number" + } + ] + }, + { + "id": "InlineTextBox", + "description": "Details of post layout rendered text positions. The exact layout should not be regarded as\nstable and may change between versions.", + "type": "object", + "properties": [ + { + "name": "boundingBox", + "description": "The bounding box in document coordinates. Note that scroll offset of the document is ignored.", + "$ref": "DOM.Rect" + }, + { + "name": "startCharacterIndex", + "description": "The starting index in characters, for this post layout textbox substring. Characters that\nwould be represented as a surrogate pair in UTF-16 have length 2.", + "type": "integer" + }, + { + "name": "numCharacters", + "description": "The number of characters in this post layout textbox substring. Characters that would be\nrepresented as a surrogate pair in UTF-16 have length 2.", + "type": "integer" + } + ] + }, + { + "id": "LayoutTreeNode", + "description": "Details of an element in the DOM tree with a LayoutObject.", + "type": "object", + "properties": [ + { + "name": "domNodeIndex", + "description": "The index of the related DOM node in the `domNodes` array returned by `getSnapshot`.", + "type": "integer" + }, + { + "name": "boundingBox", + "description": "The bounding box in document coordinates. Note that scroll offset of the document is ignored.", + "$ref": "DOM.Rect" + }, + { + "name": "layoutText", + "description": "Contents of the LayoutText, if any.", + "optional": true, + "type": "string" + }, + { + "name": "inlineTextNodes", + "description": "The post-layout inline text nodes, if any.", + "optional": true, + "type": "array", + "items": { + "$ref": "InlineTextBox" + } + }, + { + "name": "styleIndex", + "description": "Index into the `computedStyles` array returned by `getSnapshot`.", + "optional": true, + "type": "integer" + }, + { + "name": "paintOrder", + "description": "Global paint order index, which is determined by the stacking order of the nodes. Nodes\nthat are painted together will have the same index. Only provided if includePaintOrder in\ngetSnapshot was true.", + "optional": true, + "type": "integer" + }, + { + "name": "isStackingContext", + "description": "Set to true to indicate the element begins a new stacking context.", + "optional": true, + "type": "boolean" + } + ] + }, + { + "id": "ComputedStyle", + "description": "A subset of the full ComputedStyle as defined by the request whitelist.", + "type": "object", + "properties": [ + { + "name": "properties", + "description": "Name/value pairs of computed style properties.", + "type": "array", + "items": { + "$ref": "NameValue" + } + } + ] + }, + { + "id": "NameValue", + "description": "A name/value pair.", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Attribute/property name.", + "type": "string" + }, + { + "name": "value", + "description": "Attribute/property value.", + "type": "string" + } + ] + }, + { + "id": "StringIndex", + "description": "Index of the string in the strings table.", + "type": "integer" + }, + { + "id": "ArrayOfStrings", + "description": "Index of the string in the strings table.", + "type": "array", + "items": { + "$ref": "StringIndex" + } + }, + { + "id": "RareStringData", + "description": "Data that is only present on rare nodes.", + "type": "object", + "properties": [ + { + "name": "index", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "value", + "type": "array", + "items": { + "$ref": "StringIndex" + } + } + ] + }, + { + "id": "RareBooleanData", + "type": "object", + "properties": [ + { + "name": "index", + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + { + "id": "RareIntegerData", + "type": "object", + "properties": [ + { + "name": "index", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "value", + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + { + "id": "Rectangle", + "type": "array", + "items": { + "type": "number" + } + }, + { + "id": "DocumentSnapshot", + "description": "Document snapshot.", + "type": "object", + "properties": [ + { + "name": "documentURL", + "description": "Document URL that `Document` or `FrameOwner` node points to.", + "$ref": "StringIndex" + }, + { + "name": "baseURL", + "description": "Base URL that `Document` or `FrameOwner` node uses for URL completion.", + "$ref": "StringIndex" + }, + { + "name": "contentLanguage", + "description": "Contains the document's content language.", + "$ref": "StringIndex" + }, + { + "name": "encodingName", + "description": "Contains the document's character set encoding.", + "$ref": "StringIndex" + }, + { + "name": "publicId", + "description": "`DocumentType` node's publicId.", + "$ref": "StringIndex" + }, + { + "name": "systemId", + "description": "`DocumentType` node's systemId.", + "$ref": "StringIndex" + }, + { + "name": "frameId", + "description": "Frame ID for frame owner elements and also for the document node.", + "$ref": "StringIndex" + }, + { + "name": "nodes", + "description": "A table with dom nodes.", + "$ref": "NodeTreeSnapshot" + }, + { + "name": "layout", + "description": "The nodes in the layout tree.", + "$ref": "LayoutTreeSnapshot" + }, + { + "name": "textBoxes", + "description": "The post-layout inline text nodes.", + "$ref": "TextBoxSnapshot" + }, + { + "name": "scrollOffsetX", + "description": "Scroll offsets.", + "optional": true, + "type": "number" + }, + { + "name": "scrollOffsetY", + "optional": true, + "type": "number" + } + ] + }, + { + "id": "NodeTreeSnapshot", + "description": "Table containing nodes.", + "type": "object", + "properties": [ + { + "name": "parentIndex", + "description": "Parent node index.", + "optional": true, + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "nodeType", + "description": "`Node`'s nodeType.", + "optional": true, + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "nodeName", + "description": "`Node`'s nodeName.", + "optional": true, + "type": "array", + "items": { + "$ref": "StringIndex" + } + }, + { + "name": "nodeValue", + "description": "`Node`'s nodeValue.", + "optional": true, + "type": "array", + "items": { + "$ref": "StringIndex" + } + }, + { + "name": "backendNodeId", + "description": "`Node`'s id, corresponds to DOM.Node.backendNodeId.", + "optional": true, + "type": "array", + "items": { + "$ref": "DOM.BackendNodeId" + } + }, + { + "name": "attributes", + "description": "Attributes of an `Element` node. Flatten name, value pairs.", + "optional": true, + "type": "array", + "items": { + "$ref": "ArrayOfStrings" + } + }, + { + "name": "textValue", + "description": "Only set for textarea elements, contains the text value.", + "optional": true, + "$ref": "RareStringData" + }, + { + "name": "inputValue", + "description": "Only set for input elements, contains the input's associated text value.", + "optional": true, + "$ref": "RareStringData" + }, + { + "name": "inputChecked", + "description": "Only set for radio and checkbox input elements, indicates if the element has been checked", + "optional": true, + "$ref": "RareBooleanData" + }, + { + "name": "optionSelected", + "description": "Only set for option elements, indicates if the element has been selected", + "optional": true, + "$ref": "RareBooleanData" + }, + { + "name": "contentDocumentIndex", + "description": "The index of the document in the list of the snapshot documents.", + "optional": true, + "$ref": "RareIntegerData" + }, + { + "name": "pseudoType", + "description": "Type of a pseudo element node.", + "optional": true, + "$ref": "RareStringData" + }, + { + "name": "isClickable", + "description": "Whether this DOM node responds to mouse clicks. This includes nodes that have had click\nevent listeners attached via JavaScript as well as anchor tags that naturally navigate when\nclicked.", + "optional": true, + "$ref": "RareBooleanData" + }, + { + "name": "currentSourceURL", + "description": "The selected url for nodes with a srcset attribute.", + "optional": true, + "$ref": "RareStringData" + }, + { + "name": "originURL", + "description": "The url of the script (if any) that generates this node.", + "optional": true, + "$ref": "RareStringData" + } + ] + }, + { + "id": "LayoutTreeSnapshot", + "description": "Details of an element in the DOM tree with a LayoutObject.", + "type": "object", + "properties": [ + { + "name": "nodeIndex", + "description": "The index of the related DOM node in the `domNodes` array returned by `getSnapshot`.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "styles", + "description": "Index into the `computedStyles` array returned by `captureSnapshot`.", + "type": "array", + "items": { + "$ref": "ArrayOfStrings" + } + }, + { + "name": "bounds", + "description": "The absolute position bounding box.", + "type": "array", + "items": { + "$ref": "Rectangle" + } + }, + { + "name": "text", + "description": "Contents of the LayoutText, if any.", + "type": "array", + "items": { + "$ref": "StringIndex" + } + }, + { + "name": "stackingContexts", + "description": "Stacking context information.", + "$ref": "RareBooleanData" + } + ] + }, + { + "id": "TextBoxSnapshot", + "description": "Details of post layout rendered text positions. The exact layout should not be regarded as\nstable and may change between versions.", + "type": "object", + "properties": [ + { + "name": "layoutIndex", + "description": "Intex of th elayout tree node that owns this box collection.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "bounds", + "description": "The absolute position bounding box.", + "type": "array", + "items": { + "$ref": "Rectangle" + } + }, + { + "name": "start", + "description": "The starting index in characters, for this post layout textbox substring. Characters that\nwould be represented as a surrogate pair in UTF-16 have length 2.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "name": "length", + "description": "The number of characters in this post layout textbox substring. Characters that would be\nrepresented as a surrogate pair in UTF-16 have length 2.", + "type": "array", + "items": { + "type": "integer" + } + } + ] + } + ], + "commands": [ + { + "name": "disable", + "description": "Disables DOM snapshot agent for the given page." + }, + { + "name": "enable", + "description": "Enables DOM snapshot agent for the given page." + }, + { + "name": "getSnapshot", + "description": "Returns a document snapshot, including the full DOM tree of the root node (including iframes,\ntemplate contents, and imported documents) in a flattened array, as well as layout and\nwhite-listed computed style information for the nodes. Shadow DOM in the returned DOM tree is\nflattened.", + "deprecated": true, + "parameters": [ + { + "name": "computedStyleWhitelist", + "description": "Whitelist of computed styles to return.", + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "includeEventListeners", + "description": "Whether or not to retrieve details of DOM listeners (default false).", + "optional": true, + "type": "boolean" + }, + { + "name": "includePaintOrder", + "description": "Whether to determine and include the paint order index of LayoutTreeNodes (default false).", + "optional": true, + "type": "boolean" + }, + { + "name": "includeUserAgentShadowTree", + "description": "Whether to include UA shadow tree in the snapshot (default false).", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "domNodes", + "description": "The nodes in the DOM tree. The DOMNode at index 0 corresponds to the root document.", + "type": "array", + "items": { + "$ref": "DOMNode" + } + }, + { + "name": "layoutTreeNodes", + "description": "The nodes in the layout tree.", + "type": "array", + "items": { + "$ref": "LayoutTreeNode" + } + }, + { + "name": "computedStyles", + "description": "Whitelisted ComputedStyle properties for each node in the layout tree.", + "type": "array", + "items": { + "$ref": "ComputedStyle" + } + } + ] + }, + { + "name": "captureSnapshot", + "description": "Returns a document snapshot, including the full DOM tree of the root node (including iframes,\ntemplate contents, and imported documents) in a flattened array, as well as layout and\nwhite-listed computed style information for the nodes. Shadow DOM in the returned DOM tree is\nflattened.", + "parameters": [ + { + "name": "computedStyles", + "description": "Whitelist of computed styles to return.", + "type": "array", + "items": { + "type": "string" + } + } + ], + "returns": [ + { + "name": "documents", + "description": "The nodes in the DOM tree. The DOMNode at index 0 corresponds to the root document.", + "type": "array", + "items": { + "$ref": "DocumentSnapshot" + } + }, + { + "name": "strings", + "description": "Shared string table that all string properties refer to with indexes.", + "type": "array", + "items": { + "type": "string" + } + } + ] + } + ] + }, + { + "domain": "DOMStorage", + "description": "Query and modify DOM storage.", + "experimental": true, + "types": [ + { + "id": "StorageId", + "description": "DOM Storage identifier.", + "type": "object", + "properties": [ + { + "name": "securityOrigin", + "description": "Security origin for the storage.", + "type": "string" + }, + { + "name": "isLocalStorage", + "description": "Whether the storage is local storage (not session storage).", + "type": "boolean" + } + ] + }, + { + "id": "Item", + "description": "DOM Storage item.", + "type": "array", + "items": { + "type": "string" + } + } + ], + "commands": [ + { + "name": "clear", + "parameters": [ + { + "name": "storageId", + "$ref": "StorageId" + } + ] + }, + { + "name": "disable", + "description": "Disables storage tracking, prevents storage events from being sent to the client." + }, + { + "name": "enable", + "description": "Enables storage tracking, storage events will now be delivered to the client." + }, + { + "name": "getDOMStorageItems", + "parameters": [ + { + "name": "storageId", + "$ref": "StorageId" + } + ], + "returns": [ + { + "name": "entries", + "type": "array", + "items": { + "$ref": "Item" + } + } + ] + }, + { + "name": "removeDOMStorageItem", + "parameters": [ + { + "name": "storageId", + "$ref": "StorageId" + }, + { + "name": "key", + "type": "string" + } + ] + }, + { + "name": "setDOMStorageItem", + "parameters": [ + { + "name": "storageId", + "$ref": "StorageId" + }, + { + "name": "key", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + } + ], + "events": [ + { + "name": "domStorageItemAdded", + "parameters": [ + { + "name": "storageId", + "$ref": "StorageId" + }, + { + "name": "key", + "type": "string" + }, + { + "name": "newValue", + "type": "string" + } + ] + }, + { + "name": "domStorageItemRemoved", + "parameters": [ + { + "name": "storageId", + "$ref": "StorageId" + }, + { + "name": "key", + "type": "string" + } + ] + }, + { + "name": "domStorageItemUpdated", + "parameters": [ + { + "name": "storageId", + "$ref": "StorageId" + }, + { + "name": "key", + "type": "string" + }, + { + "name": "oldValue", + "type": "string" + }, + { + "name": "newValue", + "type": "string" + } + ] + }, + { + "name": "domStorageItemsCleared", + "parameters": [ + { + "name": "storageId", + "$ref": "StorageId" + } + ] + } + ] + }, + { + "domain": "Database", + "experimental": true, + "types": [ + { + "id": "DatabaseId", + "description": "Unique identifier of Database object.", + "type": "string" + }, + { + "id": "Database", + "description": "Database object.", + "type": "object", + "properties": [ + { + "name": "id", + "description": "Database ID.", + "$ref": "DatabaseId" + }, + { + "name": "domain", + "description": "Database domain.", + "type": "string" + }, + { + "name": "name", + "description": "Database name.", + "type": "string" + }, + { + "name": "version", + "description": "Database version.", + "type": "string" + } + ] + }, + { + "id": "Error", + "description": "Database error.", + "type": "object", + "properties": [ + { + "name": "message", + "description": "Error message.", + "type": "string" + }, + { + "name": "code", + "description": "Error code.", + "type": "integer" + } + ] + } + ], + "commands": [ + { + "name": "disable", + "description": "Disables database tracking, prevents database events from being sent to the client." + }, + { + "name": "enable", + "description": "Enables database tracking, database events will now be delivered to the client." + }, + { + "name": "executeSQL", + "parameters": [ + { + "name": "databaseId", + "$ref": "DatabaseId" + }, + { + "name": "query", + "type": "string" + } + ], + "returns": [ + { + "name": "columnNames", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "values", + "optional": true, + "type": "array", + "items": { + "type": "any" + } + }, + { + "name": "sqlError", + "optional": true, + "$ref": "Error" + } + ] + }, + { + "name": "getDatabaseTableNames", + "parameters": [ + { + "name": "databaseId", + "$ref": "DatabaseId" + } + ], + "returns": [ + { + "name": "tableNames", + "type": "array", + "items": { + "type": "string" + } + } + ] + } + ], + "events": [ + { + "name": "addDatabase", + "parameters": [ + { + "name": "database", + "$ref": "Database" + } + ] + } + ] + }, + { + "domain": "DeviceOrientation", + "experimental": true, + "commands": [ + { + "name": "clearDeviceOrientationOverride", + "description": "Clears the overridden Device Orientation." + }, + { + "name": "setDeviceOrientationOverride", + "description": "Overrides the Device Orientation.", + "parameters": [ + { + "name": "alpha", + "description": "Mock alpha", + "type": "number" + }, + { + "name": "beta", + "description": "Mock beta", + "type": "number" + }, + { + "name": "gamma", + "description": "Mock gamma", + "type": "number" + } + ] + } + ] + }, + { + "domain": "Emulation", + "description": "This domain emulates different environments for the page.", + "dependencies": [ + "DOM", + "Page", + "Runtime" + ], + "types": [ + { + "id": "ScreenOrientation", + "description": "Screen orientation.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "Orientation type.", + "type": "string", + "enum": [ + "portraitPrimary", + "portraitSecondary", + "landscapePrimary", + "landscapeSecondary" + ] + }, + { + "name": "angle", + "description": "Orientation angle.", + "type": "integer" + } + ] + }, + { + "id": "VirtualTimePolicy", + "description": "advance: If the scheduler runs out of immediate work, the virtual time base may fast forward to\nallow the next delayed task (if any) to run; pause: The virtual time base may not advance;\npauseIfNetworkFetchesPending: The virtual time base may not advance if there are any pending\nresource fetches.", + "experimental": true, + "type": "string", + "enum": [ + "advance", + "pause", + "pauseIfNetworkFetchesPending" + ] + } + ], + "commands": [ + { + "name": "canEmulate", + "description": "Tells whether emulation is supported.", + "returns": [ + { + "name": "result", + "description": "True if emulation is supported.", + "type": "boolean" + } + ] + }, + { + "name": "clearDeviceMetricsOverride", + "description": "Clears the overriden device metrics." + }, + { + "name": "clearGeolocationOverride", + "description": "Clears the overriden Geolocation Position and Error." + }, + { + "name": "resetPageScaleFactor", + "description": "Requests that page scale factor is reset to initial values.", + "experimental": true + }, + { + "name": "setFocusEmulationEnabled", + "description": "Enables or disables simulating a focused and active page.", + "experimental": true, + "parameters": [ + { + "name": "enabled", + "description": "Whether to enable to disable focus emulation.", + "type": "boolean" + } + ] + }, + { + "name": "setCPUThrottlingRate", + "description": "Enables CPU throttling to emulate slow CPUs.", + "experimental": true, + "parameters": [ + { + "name": "rate", + "description": "Throttling rate as a slowdown factor (1 is no throttle, 2 is 2x slowdown, etc).", + "type": "number" + } + ] + }, + { + "name": "setDefaultBackgroundColorOverride", + "description": "Sets or clears an override of the default background color of the frame. This override is used\nif the content does not specify one.", + "parameters": [ + { + "name": "color", + "description": "RGBA of the default background color. If not specified, any existing override will be\ncleared.", + "optional": true, + "$ref": "DOM.RGBA" + } + ] + }, + { + "name": "setDeviceMetricsOverride", + "description": "Overrides the values of device screen dimensions (window.screen.width, window.screen.height,\nwindow.innerWidth, window.innerHeight, and \"device-width\"/\"device-height\"-related CSS media\nquery results).", + "parameters": [ + { + "name": "width", + "description": "Overriding width value in pixels (minimum 0, maximum 10000000). 0 disables the override.", + "type": "integer" + }, + { + "name": "height", + "description": "Overriding height value in pixels (minimum 0, maximum 10000000). 0 disables the override.", + "type": "integer" + }, + { + "name": "deviceScaleFactor", + "description": "Overriding device scale factor value. 0 disables the override.", + "type": "number" + }, + { + "name": "mobile", + "description": "Whether to emulate mobile device. This includes viewport meta tag, overlay scrollbars, text\nautosizing and more.", + "type": "boolean" + }, + { + "name": "scale", + "description": "Scale to apply to resulting view image.", + "experimental": true, + "optional": true, + "type": "number" + }, + { + "name": "screenWidth", + "description": "Overriding screen width value in pixels (minimum 0, maximum 10000000).", + "experimental": true, + "optional": true, + "type": "integer" + }, + { + "name": "screenHeight", + "description": "Overriding screen height value in pixels (minimum 0, maximum 10000000).", + "experimental": true, + "optional": true, + "type": "integer" + }, + { + "name": "positionX", + "description": "Overriding view X position on screen in pixels (minimum 0, maximum 10000000).", + "experimental": true, + "optional": true, + "type": "integer" + }, + { + "name": "positionY", + "description": "Overriding view Y position on screen in pixels (minimum 0, maximum 10000000).", + "experimental": true, + "optional": true, + "type": "integer" + }, + { + "name": "dontSetVisibleSize", + "description": "Do not set visible view size, rely upon explicit setVisibleSize call.", + "experimental": true, + "optional": true, + "type": "boolean" + }, + { + "name": "screenOrientation", + "description": "Screen orientation override.", + "optional": true, + "$ref": "ScreenOrientation" + }, + { + "name": "viewport", + "description": "If set, the visible area of the page will be overridden to this viewport. This viewport\nchange is not observed by the page, e.g. viewport-relative elements do not change positions.", + "experimental": true, + "optional": true, + "$ref": "Page.Viewport" + } + ] + }, + { + "name": "setScrollbarsHidden", + "experimental": true, + "parameters": [ + { + "name": "hidden", + "description": "Whether scrollbars should be always hidden.", + "type": "boolean" + } + ] + }, + { + "name": "setDocumentCookieDisabled", + "experimental": true, + "parameters": [ + { + "name": "disabled", + "description": "Whether document.coookie API should be disabled.", + "type": "boolean" + } + ] + }, + { + "name": "setEmitTouchEventsForMouse", + "experimental": true, + "parameters": [ + { + "name": "enabled", + "description": "Whether touch emulation based on mouse input should be enabled.", + "type": "boolean" + }, + { + "name": "configuration", + "description": "Touch/gesture events configuration. Default: current platform.", + "optional": true, + "type": "string", + "enum": [ + "mobile", + "desktop" + ] + } + ] + }, + { + "name": "setEmulatedMedia", + "description": "Emulates the given media for CSS media queries.", + "parameters": [ + { + "name": "media", + "description": "Media type to emulate. Empty string disables the override.", + "type": "string" + } + ] + }, + { + "name": "setGeolocationOverride", + "description": "Overrides the Geolocation Position or Error. Omitting any of the parameters emulates position\nunavailable.", + "parameters": [ + { + "name": "latitude", + "description": "Mock latitude", + "optional": true, + "type": "number" + }, + { + "name": "longitude", + "description": "Mock longitude", + "optional": true, + "type": "number" + }, + { + "name": "accuracy", + "description": "Mock accuracy", + "optional": true, + "type": "number" + } + ] + }, + { + "name": "setNavigatorOverrides", + "description": "Overrides value returned by the javascript navigator object.", + "experimental": true, + "deprecated": true, + "parameters": [ + { + "name": "platform", + "description": "The platform navigator.platform should return.", + "type": "string" + } + ] + }, + { + "name": "setPageScaleFactor", + "description": "Sets a specified page scale factor.", + "experimental": true, + "parameters": [ + { + "name": "pageScaleFactor", + "description": "Page scale factor.", + "type": "number" + } + ] + }, + { + "name": "setScriptExecutionDisabled", + "description": "Switches script execution in the page.", + "parameters": [ + { + "name": "value", + "description": "Whether script execution should be disabled in the page.", + "type": "boolean" + } + ] + }, + { + "name": "setTouchEmulationEnabled", + "description": "Enables touch on platforms which do not support them.", + "parameters": [ + { + "name": "enabled", + "description": "Whether the touch event emulation should be enabled.", + "type": "boolean" + }, + { + "name": "maxTouchPoints", + "description": "Maximum touch points supported. Defaults to one.", + "optional": true, + "type": "integer" + } + ] + }, + { + "name": "setVirtualTimePolicy", + "description": "Turns on virtual time for all frames (replacing real-time with a synthetic time source) and sets\nthe current virtual time policy. Note this supersedes any previous time budget.", + "experimental": true, + "parameters": [ + { + "name": "policy", + "$ref": "VirtualTimePolicy" + }, + { + "name": "budget", + "description": "If set, after this many virtual milliseconds have elapsed virtual time will be paused and a\nvirtualTimeBudgetExpired event is sent.", + "optional": true, + "type": "number" + }, + { + "name": "maxVirtualTimeTaskStarvationCount", + "description": "If set this specifies the maximum number of tasks that can be run before virtual is forced\nforwards to prevent deadlock.", + "optional": true, + "type": "integer" + }, + { + "name": "waitForNavigation", + "description": "If set the virtual time policy change should be deferred until any frame starts navigating.\nNote any previous deferred policy change is superseded.", + "optional": true, + "type": "boolean" + }, + { + "name": "initialVirtualTime", + "description": "If set, base::Time::Now will be overriden to initially return this value.", + "optional": true, + "$ref": "Network.TimeSinceEpoch" + } + ], + "returns": [ + { + "name": "virtualTimeTicksBase", + "description": "Absolute timestamp at which virtual time was first enabled (up time in milliseconds).", + "type": "number" + } + ] + }, + { + "name": "setVisibleSize", + "description": "Resizes the frame/viewport of the page. Note that this does not affect the frame's container\n(e.g. browser window). Can be used to produce screenshots of the specified size. Not supported\non Android.", + "experimental": true, + "deprecated": true, + "parameters": [ + { + "name": "width", + "description": "Frame width (DIP).", + "type": "integer" + }, + { + "name": "height", + "description": "Frame height (DIP).", + "type": "integer" + } + ] + }, + { + "name": "setUserAgentOverride", + "description": "Allows overriding user agent with the given string.", + "parameters": [ + { + "name": "userAgent", + "description": "User agent to use.", + "type": "string" + }, + { + "name": "acceptLanguage", + "description": "Browser langugage to emulate.", + "optional": true, + "type": "string" + }, + { + "name": "platform", + "description": "The platform navigator.platform should return.", + "optional": true, + "type": "string" + } + ] + } + ], + "events": [ + { + "name": "virtualTimeAdvanced", + "description": "Notification sent after the virtual time has advanced.", + "experimental": true, + "parameters": [ + { + "name": "virtualTimeElapsed", + "description": "The amount of virtual time that has elapsed in milliseconds since virtual time was first\nenabled.", + "type": "number" + } + ] + }, + { + "name": "virtualTimeBudgetExpired", + "description": "Notification sent after the virtual time budget for the current VirtualTimePolicy has run out.", + "experimental": true + }, + { + "name": "virtualTimePaused", + "description": "Notification sent after the virtual time has paused.", + "experimental": true, + "parameters": [ + { + "name": "virtualTimeElapsed", + "description": "The amount of virtual time that has elapsed in milliseconds since virtual time was first\nenabled.", + "type": "number" + } + ] + } + ] + }, + { + "domain": "HeadlessExperimental", + "description": "This domain provides experimental commands only supported in headless mode.", + "experimental": true, + "dependencies": [ + "Page", + "Runtime" + ], + "types": [ + { + "id": "ScreenshotParams", + "description": "Encoding options for a screenshot.", + "type": "object", + "properties": [ + { + "name": "format", + "description": "Image compression format (defaults to png).", + "optional": true, + "type": "string", + "enum": [ + "jpeg", + "png" + ] + }, + { + "name": "quality", + "description": "Compression quality from range [0..100] (jpeg only).", + "optional": true, + "type": "integer" + } + ] + } + ], + "commands": [ + { + "name": "beginFrame", + "description": "Sends a BeginFrame to the target and returns when the frame was completed. Optionally captures a\nscreenshot from the resulting frame. Requires that the target was created with enabled\nBeginFrameControl. Designed for use with --run-all-compositor-stages-before-draw, see also\nhttps://goo.gl/3zHXhB for more background.", + "parameters": [ + { + "name": "frameTimeTicks", + "description": "Timestamp of this BeginFrame in Renderer TimeTicks (milliseconds of uptime). If not set,\nthe current time will be used.", + "optional": true, + "type": "number" + }, + { + "name": "interval", + "description": "The interval between BeginFrames that is reported to the compositor, in milliseconds.\nDefaults to a 60 frames/second interval, i.e. about 16.666 milliseconds.", + "optional": true, + "type": "number" + }, + { + "name": "noDisplayUpdates", + "description": "Whether updates should not be committed and drawn onto the display. False by default. If\ntrue, only side effects of the BeginFrame will be run, such as layout and animations, but\nany visual updates may not be visible on the display or in screenshots.", + "optional": true, + "type": "boolean" + }, + { + "name": "screenshot", + "description": "If set, a screenshot of the frame will be captured and returned in the response. Otherwise,\nno screenshot will be captured. Note that capturing a screenshot can fail, for example,\nduring renderer initialization. In such a case, no screenshot data will be returned.", + "optional": true, + "$ref": "ScreenshotParams" + } + ], + "returns": [ + { + "name": "hasDamage", + "description": "Whether the BeginFrame resulted in damage and, thus, a new frame was committed to the\ndisplay. Reported for diagnostic uses, may be removed in the future.", + "type": "boolean" + }, + { + "name": "screenshotData", + "description": "Base64-encoded image data of the screenshot, if one was requested and successfully taken.", + "optional": true, + "type": "binary" + } + ] + }, + { + "name": "disable", + "description": "Disables headless events for the target." + }, + { + "name": "enable", + "description": "Enables headless events for the target." + } + ], + "events": [ + { + "name": "needsBeginFramesChanged", + "description": "Issued when the target starts or stops needing BeginFrames.", + "parameters": [ + { + "name": "needsBeginFrames", + "description": "True if BeginFrames are needed, false otherwise.", + "type": "boolean" + } + ] + } + ] + }, + { + "domain": "IO", + "description": "Input/Output operations for streams produced by DevTools.", + "types": [ + { + "id": "StreamHandle", + "description": "This is either obtained from another method or specifed as `blob:<uuid>` where\n`<uuid>` is an UUID of a Blob.", + "type": "string" + } + ], + "commands": [ + { + "name": "close", + "description": "Close the stream, discard any temporary backing storage.", + "parameters": [ + { + "name": "handle", + "description": "Handle of the stream to close.", + "$ref": "StreamHandle" + } + ] + }, + { + "name": "read", + "description": "Read a chunk of the stream", + "parameters": [ + { + "name": "handle", + "description": "Handle of the stream to read.", + "$ref": "StreamHandle" + }, + { + "name": "offset", + "description": "Seek to the specified offset before reading (if not specificed, proceed with offset\nfollowing the last read). Some types of streams may only support sequential reads.", + "optional": true, + "type": "integer" + }, + { + "name": "size", + "description": "Maximum number of bytes to read (left upon the agent discretion if not specified).", + "optional": true, + "type": "integer" + } + ], + "returns": [ + { + "name": "base64Encoded", + "description": "Set if the data is base64-encoded", + "optional": true, + "type": "boolean" + }, + { + "name": "data", + "description": "Data that were read.", + "type": "string" + }, + { + "name": "eof", + "description": "Set if the end-of-file condition occured while reading.", + "type": "boolean" + } + ] + }, + { + "name": "resolveBlob", + "description": "Return UUID of Blob object specified by a remote object id.", + "parameters": [ + { + "name": "objectId", + "description": "Object id of a Blob object wrapper.", + "$ref": "Runtime.RemoteObjectId" + } + ], + "returns": [ + { + "name": "uuid", + "description": "UUID of the specified Blob.", + "type": "string" + } + ] + } + ] + }, + { + "domain": "IndexedDB", + "experimental": true, + "dependencies": [ + "Runtime" + ], + "types": [ + { + "id": "DatabaseWithObjectStores", + "description": "Database with an array of object stores.", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Database name.", + "type": "string" + }, + { + "name": "version", + "description": "Database version.", + "type": "integer" + }, + { + "name": "objectStores", + "description": "Object stores in this database.", + "type": "array", + "items": { + "$ref": "ObjectStore" + } + } + ] + }, + { + "id": "ObjectStore", + "description": "Object store.", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Object store name.", + "type": "string" + }, + { + "name": "keyPath", + "description": "Object store key path.", + "$ref": "KeyPath" + }, + { + "name": "autoIncrement", + "description": "If true, object store has auto increment flag set.", + "type": "boolean" + }, + { + "name": "indexes", + "description": "Indexes in this object store.", + "type": "array", + "items": { + "$ref": "ObjectStoreIndex" + } + } + ] + }, + { + "id": "ObjectStoreIndex", + "description": "Object store index.", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Index name.", + "type": "string" + }, + { + "name": "keyPath", + "description": "Index key path.", + "$ref": "KeyPath" + }, + { + "name": "unique", + "description": "If true, index is unique.", + "type": "boolean" + }, + { + "name": "multiEntry", + "description": "If true, index allows multiple entries for a key.", + "type": "boolean" + } + ] + }, + { + "id": "Key", + "description": "Key.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "Key type.", + "type": "string", + "enum": [ + "number", + "string", + "date", + "array" + ] + }, + { + "name": "number", + "description": "Number value.", + "optional": true, + "type": "number" + }, + { + "name": "string", + "description": "String value.", + "optional": true, + "type": "string" + }, + { + "name": "date", + "description": "Date value.", + "optional": true, + "type": "number" + }, + { + "name": "array", + "description": "Array value.", + "optional": true, + "type": "array", + "items": { + "$ref": "Key" + } + } + ] + }, + { + "id": "KeyRange", + "description": "Key range.", + "type": "object", + "properties": [ + { + "name": "lower", + "description": "Lower bound.", + "optional": true, + "$ref": "Key" + }, + { + "name": "upper", + "description": "Upper bound.", + "optional": true, + "$ref": "Key" + }, + { + "name": "lowerOpen", + "description": "If true lower bound is open.", + "type": "boolean" + }, + { + "name": "upperOpen", + "description": "If true upper bound is open.", + "type": "boolean" + } + ] + }, + { + "id": "DataEntry", + "description": "Data entry.", + "type": "object", + "properties": [ + { + "name": "key", + "description": "Key object.", + "$ref": "Runtime.RemoteObject" + }, + { + "name": "primaryKey", + "description": "Primary key object.", + "$ref": "Runtime.RemoteObject" + }, + { + "name": "value", + "description": "Value object.", + "$ref": "Runtime.RemoteObject" + } + ] + }, + { + "id": "KeyPath", + "description": "Key path.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "Key path type.", + "type": "string", + "enum": [ + "null", + "string", + "array" + ] + }, + { + "name": "string", + "description": "String value.", + "optional": true, + "type": "string" + }, + { + "name": "array", + "description": "Array value.", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + } + ] + } + ], + "commands": [ + { + "name": "clearObjectStore", + "description": "Clears all entries from an object store.", + "parameters": [ + { + "name": "securityOrigin", + "description": "Security origin.", + "type": "string" + }, + { + "name": "databaseName", + "description": "Database name.", + "type": "string" + }, + { + "name": "objectStoreName", + "description": "Object store name.", + "type": "string" + } + ] + }, + { + "name": "deleteDatabase", + "description": "Deletes a database.", + "parameters": [ + { + "name": "securityOrigin", + "description": "Security origin.", + "type": "string" + }, + { + "name": "databaseName", + "description": "Database name.", + "type": "string" + } + ] + }, + { + "name": "deleteObjectStoreEntries", + "description": "Delete a range of entries from an object store", + "parameters": [ + { + "name": "securityOrigin", + "type": "string" + }, + { + "name": "databaseName", + "type": "string" + }, + { + "name": "objectStoreName", + "type": "string" + }, + { + "name": "keyRange", + "description": "Range of entry keys to delete", + "$ref": "KeyRange" + } + ] + }, + { + "name": "disable", + "description": "Disables events from backend." + }, + { + "name": "enable", + "description": "Enables events from backend." + }, + { + "name": "requestData", + "description": "Requests data from object store or index.", + "parameters": [ + { + "name": "securityOrigin", + "description": "Security origin.", + "type": "string" + }, + { + "name": "databaseName", + "description": "Database name.", + "type": "string" + }, + { + "name": "objectStoreName", + "description": "Object store name.", + "type": "string" + }, + { + "name": "indexName", + "description": "Index name, empty string for object store data requests.", + "type": "string" + }, + { + "name": "skipCount", + "description": "Number of records to skip.", + "type": "integer" + }, + { + "name": "pageSize", + "description": "Number of records to fetch.", + "type": "integer" + }, + { + "name": "keyRange", + "description": "Key range.", + "optional": true, + "$ref": "KeyRange" + } + ], + "returns": [ + { + "name": "objectStoreDataEntries", + "description": "Array of object store data entries.", + "type": "array", + "items": { + "$ref": "DataEntry" + } + }, + { + "name": "hasMore", + "description": "If true, there are more entries to fetch in the given range.", + "type": "boolean" + } + ] + }, + { + "name": "requestDatabase", + "description": "Requests database with given name in given frame.", + "parameters": [ + { + "name": "securityOrigin", + "description": "Security origin.", + "type": "string" + }, + { + "name": "databaseName", + "description": "Database name.", + "type": "string" + } + ], + "returns": [ + { + "name": "databaseWithObjectStores", + "description": "Database with an array of object stores.", + "$ref": "DatabaseWithObjectStores" + } + ] + }, + { + "name": "requestDatabaseNames", + "description": "Requests database names for given security origin.", + "parameters": [ + { + "name": "securityOrigin", + "description": "Security origin.", + "type": "string" + } + ], + "returns": [ + { + "name": "databaseNames", + "description": "Database names for origin.", + "type": "array", + "items": { + "type": "string" + } + } + ] + } + ] + }, + { + "domain": "Input", + "types": [ + { + "id": "TouchPoint", + "type": "object", + "properties": [ + { + "name": "x", + "description": "X coordinate of the event relative to the main frame's viewport in CSS pixels.", + "type": "number" + }, + { + "name": "y", + "description": "Y coordinate of the event relative to the main frame's viewport in CSS pixels. 0 refers to\nthe top of the viewport and Y increases as it proceeds towards the bottom of the viewport.", + "type": "number" + }, + { + "name": "radiusX", + "description": "X radius of the touch area (default: 1.0).", + "optional": true, + "type": "number" + }, + { + "name": "radiusY", + "description": "Y radius of the touch area (default: 1.0).", + "optional": true, + "type": "number" + }, + { + "name": "rotationAngle", + "description": "Rotation angle (default: 0.0).", + "optional": true, + "type": "number" + }, + { + "name": "force", + "description": "Force (default: 1.0).", + "optional": true, + "type": "number" + }, + { + "name": "id", + "description": "Identifier used to track touch sources between events, must be unique within an event.", + "optional": true, + "type": "number" + } + ] + }, + { + "id": "GestureSourceType", + "experimental": true, + "type": "string", + "enum": [ + "default", + "touch", + "mouse" + ] + }, + { + "id": "TimeSinceEpoch", + "description": "UTC time in seconds, counted from January 1, 1970.", + "type": "number" + } + ], + "commands": [ + { + "name": "dispatchKeyEvent", + "description": "Dispatches a key event to the page.", + "parameters": [ + { + "name": "type", + "description": "Type of the key event.", + "type": "string", + "enum": [ + "keyDown", + "keyUp", + "rawKeyDown", + "char" + ] + }, + { + "name": "modifiers", + "description": "Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8\n(default: 0).", + "optional": true, + "type": "integer" + }, + { + "name": "timestamp", + "description": "Time at which the event occurred.", + "optional": true, + "$ref": "TimeSinceEpoch" + }, + { + "name": "text", + "description": "Text as generated by processing a virtual key code with a keyboard layout. Not needed for\nfor `keyUp` and `rawKeyDown` events (default: \"\")", + "optional": true, + "type": "string" + }, + { + "name": "unmodifiedText", + "description": "Text that would have been generated by the keyboard if no modifiers were pressed (except for\nshift). Useful for shortcut (accelerator) key handling (default: \"\").", + "optional": true, + "type": "string" + }, + { + "name": "keyIdentifier", + "description": "Unique key identifier (e.g., 'U+0041') (default: \"\").", + "optional": true, + "type": "string" + }, + { + "name": "code", + "description": "Unique DOM defined string value for each physical key (e.g., 'KeyA') (default: \"\").", + "optional": true, + "type": "string" + }, + { + "name": "key", + "description": "Unique DOM defined string value describing the meaning of the key in the context of active\nmodifiers, keyboard layout, etc (e.g., 'AltGr') (default: \"\").", + "optional": true, + "type": "string" + }, + { + "name": "windowsVirtualKeyCode", + "description": "Windows virtual key code (default: 0).", + "optional": true, + "type": "integer" + }, + { + "name": "nativeVirtualKeyCode", + "description": "Native virtual key code (default: 0).", + "optional": true, + "type": "integer" + }, + { + "name": "autoRepeat", + "description": "Whether the event was generated from auto repeat (default: false).", + "optional": true, + "type": "boolean" + }, + { + "name": "isKeypad", + "description": "Whether the event was generated from the keypad (default: false).", + "optional": true, + "type": "boolean" + }, + { + "name": "isSystemKey", + "description": "Whether the event was a system key event (default: false).", + "optional": true, + "type": "boolean" + }, + { + "name": "location", + "description": "Whether the event was from the left or right side of the keyboard. 1=Left, 2=Right (default:\n0).", + "optional": true, + "type": "integer" + } + ] + }, + { + "name": "insertText", + "description": "This method emulates inserting text that doesn't come from a key press,\nfor example an emoji keyboard or an IME.", + "experimental": true, + "parameters": [ + { + "name": "text", + "description": "The text to insert.", + "type": "string" + } + ] + }, + { + "name": "dispatchMouseEvent", + "description": "Dispatches a mouse event to the page.", + "parameters": [ + { + "name": "type", + "description": "Type of the mouse event.", + "type": "string", + "enum": [ + "mousePressed", + "mouseReleased", + "mouseMoved", + "mouseWheel" + ] + }, + { + "name": "x", + "description": "X coordinate of the event relative to the main frame's viewport in CSS pixels.", + "type": "number" + }, + { + "name": "y", + "description": "Y coordinate of the event relative to the main frame's viewport in CSS pixels. 0 refers to\nthe top of the viewport and Y increases as it proceeds towards the bottom of the viewport.", + "type": "number" + }, + { + "name": "modifiers", + "description": "Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8\n(default: 0).", + "optional": true, + "type": "integer" + }, + { + "name": "timestamp", + "description": "Time at which the event occurred.", + "optional": true, + "$ref": "TimeSinceEpoch" + }, + { + "name": "button", + "description": "Mouse button (default: \"none\").", + "optional": true, + "type": "string", + "enum": [ + "none", + "left", + "middle", + "right" + ] + }, + { + "name": "clickCount", + "description": "Number of times the mouse button was clicked (default: 0).", + "optional": true, + "type": "integer" + }, + { + "name": "deltaX", + "description": "X delta in CSS pixels for mouse wheel event (default: 0).", + "optional": true, + "type": "number" + }, + { + "name": "deltaY", + "description": "Y delta in CSS pixels for mouse wheel event (default: 0).", + "optional": true, + "type": "number" + } + ] + }, + { + "name": "dispatchTouchEvent", + "description": "Dispatches a touch event to the page.", + "parameters": [ + { + "name": "type", + "description": "Type of the touch event. TouchEnd and TouchCancel must not contain any touch points, while\nTouchStart and TouchMove must contains at least one.", + "type": "string", + "enum": [ + "touchStart", + "touchEnd", + "touchMove", + "touchCancel" + ] + }, + { + "name": "touchPoints", + "description": "Active touch points on the touch device. One event per any changed point (compared to\nprevious touch event in a sequence) is generated, emulating pressing/moving/releasing points\none by one.", + "type": "array", + "items": { + "$ref": "TouchPoint" + } + }, + { + "name": "modifiers", + "description": "Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8\n(default: 0).", + "optional": true, + "type": "integer" + }, + { + "name": "timestamp", + "description": "Time at which the event occurred.", + "optional": true, + "$ref": "TimeSinceEpoch" + } + ] + }, + { + "name": "emulateTouchFromMouseEvent", + "description": "Emulates touch event from the mouse event parameters.", + "experimental": true, + "parameters": [ + { + "name": "type", + "description": "Type of the mouse event.", + "type": "string", + "enum": [ + "mousePressed", + "mouseReleased", + "mouseMoved", + "mouseWheel" + ] + }, + { + "name": "x", + "description": "X coordinate of the mouse pointer in DIP.", + "type": "integer" + }, + { + "name": "y", + "description": "Y coordinate of the mouse pointer in DIP.", + "type": "integer" + }, + { + "name": "button", + "description": "Mouse button.", + "type": "string", + "enum": [ + "none", + "left", + "middle", + "right" + ] + }, + { + "name": "timestamp", + "description": "Time at which the event occurred (default: current time).", + "optional": true, + "$ref": "TimeSinceEpoch" + }, + { + "name": "deltaX", + "description": "X delta in DIP for mouse wheel event (default: 0).", + "optional": true, + "type": "number" + }, + { + "name": "deltaY", + "description": "Y delta in DIP for mouse wheel event (default: 0).", + "optional": true, + "type": "number" + }, + { + "name": "modifiers", + "description": "Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8\n(default: 0).", + "optional": true, + "type": "integer" + }, + { + "name": "clickCount", + "description": "Number of times the mouse button was clicked (default: 0).", + "optional": true, + "type": "integer" + } + ] + }, + { + "name": "setIgnoreInputEvents", + "description": "Ignores input events (useful while auditing page).", + "parameters": [ + { + "name": "ignore", + "description": "Ignores input events processing when set to true.", + "type": "boolean" + } + ] + }, + { + "name": "synthesizePinchGesture", + "description": "Synthesizes a pinch gesture over a time period by issuing appropriate touch events.", + "experimental": true, + "parameters": [ + { + "name": "x", + "description": "X coordinate of the start of the gesture in CSS pixels.", + "type": "number" + }, + { + "name": "y", + "description": "Y coordinate of the start of the gesture in CSS pixels.", + "type": "number" + }, + { + "name": "scaleFactor", + "description": "Relative scale factor after zooming (>1.0 zooms in, <1.0 zooms out).", + "type": "number" + }, + { + "name": "relativeSpeed", + "description": "Relative pointer speed in pixels per second (default: 800).", + "optional": true, + "type": "integer" + }, + { + "name": "gestureSourceType", + "description": "Which type of input events to be generated (default: 'default', which queries the platform\nfor the preferred input type).", + "optional": true, + "$ref": "GestureSourceType" + } + ] + }, + { + "name": "synthesizeScrollGesture", + "description": "Synthesizes a scroll gesture over a time period by issuing appropriate touch events.", + "experimental": true, + "parameters": [ + { + "name": "x", + "description": "X coordinate of the start of the gesture in CSS pixels.", + "type": "number" + }, + { + "name": "y", + "description": "Y coordinate of the start of the gesture in CSS pixels.", + "type": "number" + }, + { + "name": "xDistance", + "description": "The distance to scroll along the X axis (positive to scroll left).", + "optional": true, + "type": "number" + }, + { + "name": "yDistance", + "description": "The distance to scroll along the Y axis (positive to scroll up).", + "optional": true, + "type": "number" + }, + { + "name": "xOverscroll", + "description": "The number of additional pixels to scroll back along the X axis, in addition to the given\ndistance.", + "optional": true, + "type": "number" + }, + { + "name": "yOverscroll", + "description": "The number of additional pixels to scroll back along the Y axis, in addition to the given\ndistance.", + "optional": true, + "type": "number" + }, + { + "name": "preventFling", + "description": "Prevent fling (default: true).", + "optional": true, + "type": "boolean" + }, + { + "name": "speed", + "description": "Swipe speed in pixels per second (default: 800).", + "optional": true, + "type": "integer" + }, + { + "name": "gestureSourceType", + "description": "Which type of input events to be generated (default: 'default', which queries the platform\nfor the preferred input type).", + "optional": true, + "$ref": "GestureSourceType" + }, + { + "name": "repeatCount", + "description": "The number of times to repeat the gesture (default: 0).", + "optional": true, + "type": "integer" + }, + { + "name": "repeatDelayMs", + "description": "The number of milliseconds delay between each repeat. (default: 250).", + "optional": true, + "type": "integer" + }, + { + "name": "interactionMarkerName", + "description": "The name of the interaction markers to generate, if not empty (default: \"\").", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "synthesizeTapGesture", + "description": "Synthesizes a tap gesture over a time period by issuing appropriate touch events.", + "experimental": true, + "parameters": [ + { + "name": "x", + "description": "X coordinate of the start of the gesture in CSS pixels.", + "type": "number" + }, + { + "name": "y", + "description": "Y coordinate of the start of the gesture in CSS pixels.", + "type": "number" + }, + { + "name": "duration", + "description": "Duration between touchdown and touchup events in ms (default: 50).", + "optional": true, + "type": "integer" + }, + { + "name": "tapCount", + "description": "Number of times to perform the tap (e.g. 2 for double tap, default: 1).", + "optional": true, + "type": "integer" + }, + { + "name": "gestureSourceType", + "description": "Which type of input events to be generated (default: 'default', which queries the platform\nfor the preferred input type).", + "optional": true, + "$ref": "GestureSourceType" + } + ] + } + ] + }, + { + "domain": "Inspector", + "experimental": true, + "commands": [ + { + "name": "disable", + "description": "Disables inspector domain notifications." + }, + { + "name": "enable", + "description": "Enables inspector domain notifications." + } + ], + "events": [ + { + "name": "detached", + "description": "Fired when remote debugging connection is about to be terminated. Contains detach reason.", + "parameters": [ + { + "name": "reason", + "description": "The reason why connection has been terminated.", + "type": "string" + } + ] + }, + { + "name": "targetCrashed", + "description": "Fired when debugging target has crashed" + }, + { + "name": "targetReloadedAfterCrash", + "description": "Fired when debugging target has reloaded after crash" + } + ] + }, + { + "domain": "LayerTree", + "experimental": true, + "dependencies": [ + "DOM" + ], + "types": [ + { + "id": "LayerId", + "description": "Unique Layer identifier.", + "type": "string" + }, + { + "id": "SnapshotId", + "description": "Unique snapshot identifier.", + "type": "string" + }, + { + "id": "ScrollRect", + "description": "Rectangle where scrolling happens on the main thread.", + "type": "object", + "properties": [ + { + "name": "rect", + "description": "Rectangle itself.", + "$ref": "DOM.Rect" + }, + { + "name": "type", + "description": "Reason for rectangle to force scrolling on the main thread", + "type": "string", + "enum": [ + "RepaintsOnScroll", + "TouchEventHandler", + "WheelEventHandler" + ] + } + ] + }, + { + "id": "StickyPositionConstraint", + "description": "Sticky position constraints.", + "type": "object", + "properties": [ + { + "name": "stickyBoxRect", + "description": "Layout rectangle of the sticky element before being shifted", + "$ref": "DOM.Rect" + }, + { + "name": "containingBlockRect", + "description": "Layout rectangle of the containing block of the sticky element", + "$ref": "DOM.Rect" + }, + { + "name": "nearestLayerShiftingStickyBox", + "description": "The nearest sticky layer that shifts the sticky box", + "optional": true, + "$ref": "LayerId" + }, + { + "name": "nearestLayerShiftingContainingBlock", + "description": "The nearest sticky layer that shifts the containing block", + "optional": true, + "$ref": "LayerId" + } + ] + }, + { + "id": "PictureTile", + "description": "Serialized fragment of layer picture along with its offset within the layer.", + "type": "object", + "properties": [ + { + "name": "x", + "description": "Offset from owning layer left boundary", + "type": "number" + }, + { + "name": "y", + "description": "Offset from owning layer top boundary", + "type": "number" + }, + { + "name": "picture", + "description": "Base64-encoded snapshot data.", + "type": "binary" + } + ] + }, + { + "id": "Layer", + "description": "Information about a compositing layer.", + "type": "object", + "properties": [ + { + "name": "layerId", + "description": "The unique id for this layer.", + "$ref": "LayerId" + }, + { + "name": "parentLayerId", + "description": "The id of parent (not present for root).", + "optional": true, + "$ref": "LayerId" + }, + { + "name": "backendNodeId", + "description": "The backend id for the node associated with this layer.", + "optional": true, + "$ref": "DOM.BackendNodeId" + }, + { + "name": "offsetX", + "description": "Offset from parent layer, X coordinate.", + "type": "number" + }, + { + "name": "offsetY", + "description": "Offset from parent layer, Y coordinate.", + "type": "number" + }, + { + "name": "width", + "description": "Layer width.", + "type": "number" + }, + { + "name": "height", + "description": "Layer height.", + "type": "number" + }, + { + "name": "transform", + "description": "Transformation matrix for layer, default is identity matrix", + "optional": true, + "type": "array", + "items": { + "type": "number" + } + }, + { + "name": "anchorX", + "description": "Transform anchor point X, absent if no transform specified", + "optional": true, + "type": "number" + }, + { + "name": "anchorY", + "description": "Transform anchor point Y, absent if no transform specified", + "optional": true, + "type": "number" + }, + { + "name": "anchorZ", + "description": "Transform anchor point Z, absent if no transform specified", + "optional": true, + "type": "number" + }, + { + "name": "paintCount", + "description": "Indicates how many time this layer has painted.", + "type": "integer" + }, + { + "name": "drawsContent", + "description": "Indicates whether this layer hosts any content, rather than being used for\ntransform/scrolling purposes only.", + "type": "boolean" + }, + { + "name": "invisible", + "description": "Set if layer is not visible.", + "optional": true, + "type": "boolean" + }, + { + "name": "scrollRects", + "description": "Rectangles scrolling on main thread only.", + "optional": true, + "type": "array", + "items": { + "$ref": "ScrollRect" + } + }, + { + "name": "stickyPositionConstraint", + "description": "Sticky position constraint information", + "optional": true, + "$ref": "StickyPositionConstraint" + } + ] + }, + { + "id": "PaintProfile", + "description": "Array of timings, one per paint step.", + "type": "array", + "items": { + "type": "number" + } + } + ], + "commands": [ + { + "name": "compositingReasons", + "description": "Provides the reasons why the given layer was composited.", + "parameters": [ + { + "name": "layerId", + "description": "The id of the layer for which we want to get the reasons it was composited.", + "$ref": "LayerId" + } + ], + "returns": [ + { + "name": "compositingReasons", + "description": "A list of strings specifying reasons for the given layer to become composited.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "disable", + "description": "Disables compositing tree inspection." + }, + { + "name": "enable", + "description": "Enables compositing tree inspection." + }, + { + "name": "loadSnapshot", + "description": "Returns the snapshot identifier.", + "parameters": [ + { + "name": "tiles", + "description": "An array of tiles composing the snapshot.", + "type": "array", + "items": { + "$ref": "PictureTile" + } + } + ], + "returns": [ + { + "name": "snapshotId", + "description": "The id of the snapshot.", + "$ref": "SnapshotId" + } + ] + }, + { + "name": "makeSnapshot", + "description": "Returns the layer snapshot identifier.", + "parameters": [ + { + "name": "layerId", + "description": "The id of the layer.", + "$ref": "LayerId" + } + ], + "returns": [ + { + "name": "snapshotId", + "description": "The id of the layer snapshot.", + "$ref": "SnapshotId" + } + ] + }, + { + "name": "profileSnapshot", + "parameters": [ + { + "name": "snapshotId", + "description": "The id of the layer snapshot.", + "$ref": "SnapshotId" + }, + { + "name": "minRepeatCount", + "description": "The maximum number of times to replay the snapshot (1, if not specified).", + "optional": true, + "type": "integer" + }, + { + "name": "minDuration", + "description": "The minimum duration (in seconds) to replay the snapshot.", + "optional": true, + "type": "number" + }, + { + "name": "clipRect", + "description": "The clip rectangle to apply when replaying the snapshot.", + "optional": true, + "$ref": "DOM.Rect" + } + ], + "returns": [ + { + "name": "timings", + "description": "The array of paint profiles, one per run.", + "type": "array", + "items": { + "$ref": "PaintProfile" + } + } + ] + }, + { + "name": "releaseSnapshot", + "description": "Releases layer snapshot captured by the back-end.", + "parameters": [ + { + "name": "snapshotId", + "description": "The id of the layer snapshot.", + "$ref": "SnapshotId" + } + ] + }, + { + "name": "replaySnapshot", + "description": "Replays the layer snapshot and returns the resulting bitmap.", + "parameters": [ + { + "name": "snapshotId", + "description": "The id of the layer snapshot.", + "$ref": "SnapshotId" + }, + { + "name": "fromStep", + "description": "The first step to replay from (replay from the very start if not specified).", + "optional": true, + "type": "integer" + }, + { + "name": "toStep", + "description": "The last step to replay to (replay till the end if not specified).", + "optional": true, + "type": "integer" + }, + { + "name": "scale", + "description": "The scale to apply while replaying (defaults to 1).", + "optional": true, + "type": "number" + } + ], + "returns": [ + { + "name": "dataURL", + "description": "A data: URL for resulting image.", + "type": "string" + } + ] + }, + { + "name": "snapshotCommandLog", + "description": "Replays the layer snapshot and returns canvas log.", + "parameters": [ + { + "name": "snapshotId", + "description": "The id of the layer snapshot.", + "$ref": "SnapshotId" + } + ], + "returns": [ + { + "name": "commandLog", + "description": "The array of canvas function calls.", + "type": "array", + "items": { + "type": "object" + } + } + ] + } + ], + "events": [ + { + "name": "layerPainted", + "parameters": [ + { + "name": "layerId", + "description": "The id of the painted layer.", + "$ref": "LayerId" + }, + { + "name": "clip", + "description": "Clip rectangle.", + "$ref": "DOM.Rect" + } + ] + }, + { + "name": "layerTreeDidChange", + "parameters": [ + { + "name": "layers", + "description": "Layer tree, absent if not in the comspositing mode.", + "optional": true, + "type": "array", + "items": { + "$ref": "Layer" + } + } + ] + } + ] + }, + { + "domain": "Log", + "description": "Provides access to log entries.", + "dependencies": [ + "Runtime", + "Network" + ], + "types": [ + { + "id": "LogEntry", + "description": "Log entry.", + "type": "object", + "properties": [ + { + "name": "source", + "description": "Log entry source.", + "type": "string", + "enum": [ + "xml", + "javascript", + "network", + "storage", + "appcache", + "rendering", + "security", + "deprecation", + "worker", + "violation", + "intervention", + "recommendation", + "other" + ] + }, + { + "name": "level", + "description": "Log entry severity.", + "type": "string", + "enum": [ + "verbose", + "info", + "warning", + "error" + ] + }, + { + "name": "text", + "description": "Logged text.", + "type": "string" + }, + { + "name": "timestamp", + "description": "Timestamp when this entry was added.", + "$ref": "Runtime.Timestamp" + }, + { + "name": "url", + "description": "URL of the resource if known.", + "optional": true, + "type": "string" + }, + { + "name": "lineNumber", + "description": "Line number in the resource.", + "optional": true, + "type": "integer" + }, + { + "name": "stackTrace", + "description": "JavaScript stack trace.", + "optional": true, + "$ref": "Runtime.StackTrace" + }, + { + "name": "networkRequestId", + "description": "Identifier of the network request associated with this entry.", + "optional": true, + "$ref": "Network.RequestId" + }, + { + "name": "workerId", + "description": "Identifier of the worker associated with this entry.", + "optional": true, + "type": "string" + }, + { + "name": "args", + "description": "Call arguments.", + "optional": true, + "type": "array", + "items": { + "$ref": "Runtime.RemoteObject" + } + } + ] + }, + { + "id": "ViolationSetting", + "description": "Violation configuration setting.", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Violation type.", + "type": "string", + "enum": [ + "longTask", + "longLayout", + "blockedEvent", + "blockedParser", + "discouragedAPIUse", + "handler", + "recurringHandler" + ] + }, + { + "name": "threshold", + "description": "Time threshold to trigger upon.", + "type": "number" + } + ] + } + ], + "commands": [ + { + "name": "clear", + "description": "Clears the log." + }, + { + "name": "disable", + "description": "Disables log domain, prevents further log entries from being reported to the client." + }, + { + "name": "enable", + "description": "Enables log domain, sends the entries collected so far to the client by means of the\n`entryAdded` notification." + }, + { + "name": "startViolationsReport", + "description": "start violation reporting.", + "parameters": [ + { + "name": "config", + "description": "Configuration for violations.", + "type": "array", + "items": { + "$ref": "ViolationSetting" + } + } + ] + }, + { + "name": "stopViolationsReport", + "description": "Stop violation reporting." + } + ], + "events": [ + { + "name": "entryAdded", + "description": "Issued when new message was logged.", + "parameters": [ + { + "name": "entry", + "description": "The entry.", + "$ref": "LogEntry" + } + ] + } + ] + }, + { + "domain": "Memory", + "experimental": true, + "types": [ + { + "id": "PressureLevel", + "description": "Memory pressure level.", + "type": "string", + "enum": [ + "moderate", + "critical" + ] + }, + { + "id": "SamplingProfileNode", + "description": "Heap profile sample.", + "type": "object", + "properties": [ + { + "name": "size", + "description": "Size of the sampled allocation.", + "type": "number" + }, + { + "name": "total", + "description": "Total bytes attributed to this sample.", + "type": "number" + }, + { + "name": "stack", + "description": "Execution stack at the point of allocation.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "id": "SamplingProfile", + "description": "Array of heap profile samples.", + "type": "object", + "properties": [ + { + "name": "samples", + "type": "array", + "items": { + "$ref": "SamplingProfileNode" + } + }, + { + "name": "modules", + "type": "array", + "items": { + "$ref": "Module" + } + } + ] + }, + { + "id": "Module", + "description": "Executable module information", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Name of the module.", + "type": "string" + }, + { + "name": "uuid", + "description": "UUID of the module.", + "type": "string" + }, + { + "name": "baseAddress", + "description": "Base address where the module is loaded into memory. Encoded as a decimal\nor hexadecimal (0x prefixed) string.", + "type": "string" + }, + { + "name": "size", + "description": "Size of the module in bytes.", + "type": "number" + } + ] + } + ], + "commands": [ + { + "name": "getDOMCounters", + "returns": [ + { + "name": "documents", + "type": "integer" + }, + { + "name": "nodes", + "type": "integer" + }, + { + "name": "jsEventListeners", + "type": "integer" + } + ] + }, + { + "name": "prepareForLeakDetection" + }, + { + "name": "setPressureNotificationsSuppressed", + "description": "Enable/disable suppressing memory pressure notifications in all processes.", + "parameters": [ + { + "name": "suppressed", + "description": "If true, memory pressure notifications will be suppressed.", + "type": "boolean" + } + ] + }, + { + "name": "simulatePressureNotification", + "description": "Simulate a memory pressure notification in all processes.", + "parameters": [ + { + "name": "level", + "description": "Memory pressure level of the notification.", + "$ref": "PressureLevel" + } + ] + }, + { + "name": "startSampling", + "description": "Start collecting native memory profile.", + "parameters": [ + { + "name": "samplingInterval", + "description": "Average number of bytes between samples.", + "optional": true, + "type": "integer" + }, + { + "name": "suppressRandomness", + "description": "Do not randomize intervals between samples.", + "optional": true, + "type": "boolean" + } + ] + }, + { + "name": "stopSampling", + "description": "Stop collecting native memory profile." + }, + { + "name": "getAllTimeSamplingProfile", + "description": "Retrieve native memory allocations profile\ncollected since renderer process startup.", + "returns": [ + { + "name": "profile", + "$ref": "SamplingProfile" + } + ] + }, + { + "name": "getBrowserSamplingProfile", + "description": "Retrieve native memory allocations profile\ncollected since browser process startup.", + "returns": [ + { + "name": "profile", + "$ref": "SamplingProfile" + } + ] + }, + { + "name": "getSamplingProfile", + "description": "Retrieve native memory allocations profile collected since last\n`startSampling` call.", + "returns": [ + { + "name": "profile", + "$ref": "SamplingProfile" + } + ] + } + ] + }, + { + "domain": "Network", + "description": "Network domain allows tracking network activities of the page. It exposes information about http,\nfile, data and other requests and responses, their headers, bodies, timing, etc.", + "dependencies": [ + "Debugger", + "Runtime", + "Security" + ], + "types": [ + { + "id": "ResourceType", + "description": "Resource type as it was perceived by the rendering engine.", + "type": "string", + "enum": [ + "Document", + "Stylesheet", + "Image", + "Media", + "Font", + "Script", + "TextTrack", + "XHR", + "Fetch", + "EventSource", + "WebSocket", + "Manifest", + "SignedExchange", + "Ping", + "CSPViolationReport", + "Other" + ] + }, + { + "id": "LoaderId", + "description": "Unique loader identifier.", + "type": "string" + }, + { + "id": "RequestId", + "description": "Unique request identifier.", + "type": "string" + }, + { + "id": "InterceptionId", + "description": "Unique intercepted request identifier.", + "type": "string" + }, + { + "id": "ErrorReason", + "description": "Network level fetch failure reason.", + "type": "string", + "enum": [ + "Failed", + "Aborted", + "TimedOut", + "AccessDenied", + "ConnectionClosed", + "ConnectionReset", + "ConnectionRefused", + "ConnectionAborted", + "ConnectionFailed", + "NameNotResolved", + "InternetDisconnected", + "AddressUnreachable", + "BlockedByClient", + "BlockedByResponse" + ] + }, + { + "id": "TimeSinceEpoch", + "description": "UTC time in seconds, counted from January 1, 1970.", + "type": "number" + }, + { + "id": "MonotonicTime", + "description": "Monotonically increasing time in seconds since an arbitrary point in the past.", + "type": "number" + }, + { + "id": "Headers", + "description": "Request / response headers as keys / values of JSON object.", + "type": "object" + }, + { + "id": "ConnectionType", + "description": "The underlying connection technology that the browser is supposedly using.", + "type": "string", + "enum": [ + "none", + "cellular2g", + "cellular3g", + "cellular4g", + "bluetooth", + "ethernet", + "wifi", + "wimax", + "other" + ] + }, + { + "id": "CookieSameSite", + "description": "Represents the cookie's 'SameSite' status:\nhttps://tools.ietf.org/html/draft-west-first-party-cookies", + "type": "string", + "enum": [ + "Strict", + "Lax" + ] + }, + { + "id": "ResourceTiming", + "description": "Timing information for the request.", + "type": "object", + "properties": [ + { + "name": "requestTime", + "description": "Timing's requestTime is a baseline in seconds, while the other numbers are ticks in\nmilliseconds relatively to this requestTime.", + "type": "number" + }, + { + "name": "proxyStart", + "description": "Started resolving proxy.", + "type": "number" + }, + { + "name": "proxyEnd", + "description": "Finished resolving proxy.", + "type": "number" + }, + { + "name": "dnsStart", + "description": "Started DNS address resolve.", + "type": "number" + }, + { + "name": "dnsEnd", + "description": "Finished DNS address resolve.", + "type": "number" + }, + { + "name": "connectStart", + "description": "Started connecting to the remote host.", + "type": "number" + }, + { + "name": "connectEnd", + "description": "Connected to the remote host.", + "type": "number" + }, + { + "name": "sslStart", + "description": "Started SSL handshake.", + "type": "number" + }, + { + "name": "sslEnd", + "description": "Finished SSL handshake.", + "type": "number" + }, + { + "name": "workerStart", + "description": "Started running ServiceWorker.", + "experimental": true, + "type": "number" + }, + { + "name": "workerReady", + "description": "Finished Starting ServiceWorker.", + "experimental": true, + "type": "number" + }, + { + "name": "sendStart", + "description": "Started sending request.", + "type": "number" + }, + { + "name": "sendEnd", + "description": "Finished sending request.", + "type": "number" + }, + { + "name": "pushStart", + "description": "Time the server started pushing request.", + "experimental": true, + "type": "number" + }, + { + "name": "pushEnd", + "description": "Time the server finished pushing request.", + "experimental": true, + "type": "number" + }, + { + "name": "receiveHeadersEnd", + "description": "Finished receiving response headers.", + "type": "number" + } + ] + }, + { + "id": "ResourcePriority", + "description": "Loading priority of a resource request.", + "type": "string", + "enum": [ + "VeryLow", + "Low", + "Medium", + "High", + "VeryHigh" + ] + }, + { + "id": "Request", + "description": "HTTP request data.", + "type": "object", + "properties": [ + { + "name": "url", + "description": "Request URL (without fragment).", + "type": "string" + }, + { + "name": "urlFragment", + "description": "Fragment of the requested URL starting with hash, if present.", + "optional": true, + "type": "string" + }, + { + "name": "method", + "description": "HTTP request method.", + "type": "string" + }, + { + "name": "headers", + "description": "HTTP request headers.", + "$ref": "Headers" + }, + { + "name": "postData", + "description": "HTTP POST request data.", + "optional": true, + "type": "string" + }, + { + "name": "hasPostData", + "description": "True when the request has POST data. Note that postData might still be omitted when this flag is true when the data is too long.", + "optional": true, + "type": "boolean" + }, + { + "name": "mixedContentType", + "description": "The mixed content type of the request.", + "optional": true, + "$ref": "Security.MixedContentType" + }, + { + "name": "initialPriority", + "description": "Priority of the resource request at the time request is sent.", + "$ref": "ResourcePriority" + }, + { + "name": "referrerPolicy", + "description": "The referrer policy of the request, as defined in https://www.w3.org/TR/referrer-policy/", + "type": "string", + "enum": [ + "unsafe-url", + "no-referrer-when-downgrade", + "no-referrer", + "origin", + "origin-when-cross-origin", + "same-origin", + "strict-origin", + "strict-origin-when-cross-origin" + ] + }, + { + "name": "isLinkPreload", + "description": "Whether is loaded via link preload.", + "optional": true, + "type": "boolean" + } + ] + }, + { + "id": "SignedCertificateTimestamp", + "description": "Details of a signed certificate timestamp (SCT).", + "type": "object", + "properties": [ + { + "name": "status", + "description": "Validation status.", + "type": "string" + }, + { + "name": "origin", + "description": "Origin.", + "type": "string" + }, + { + "name": "logDescription", + "description": "Log name / description.", + "type": "string" + }, + { + "name": "logId", + "description": "Log ID.", + "type": "string" + }, + { + "name": "timestamp", + "description": "Issuance date.", + "$ref": "TimeSinceEpoch" + }, + { + "name": "hashAlgorithm", + "description": "Hash algorithm.", + "type": "string" + }, + { + "name": "signatureAlgorithm", + "description": "Signature algorithm.", + "type": "string" + }, + { + "name": "signatureData", + "description": "Signature data.", + "type": "string" + } + ] + }, + { + "id": "SecurityDetails", + "description": "Security details about a request.", + "type": "object", + "properties": [ + { + "name": "protocol", + "description": "Protocol name (e.g. \"TLS 1.2\" or \"QUIC\").", + "type": "string" + }, + { + "name": "keyExchange", + "description": "Key Exchange used by the connection, or the empty string if not applicable.", + "type": "string" + }, + { + "name": "keyExchangeGroup", + "description": "(EC)DH group used by the connection, if applicable.", + "optional": true, + "type": "string" + }, + { + "name": "cipher", + "description": "Cipher name.", + "type": "string" + }, + { + "name": "mac", + "description": "TLS MAC. Note that AEAD ciphers do not have separate MACs.", + "optional": true, + "type": "string" + }, + { + "name": "certificateId", + "description": "Certificate ID value.", + "$ref": "Security.CertificateId" + }, + { + "name": "subjectName", + "description": "Certificate subject name.", + "type": "string" + }, + { + "name": "sanList", + "description": "Subject Alternative Name (SAN) DNS names and IP addresses.", + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "issuer", + "description": "Name of the issuing CA.", + "type": "string" + }, + { + "name": "validFrom", + "description": "Certificate valid from date.", + "$ref": "TimeSinceEpoch" + }, + { + "name": "validTo", + "description": "Certificate valid to (expiration) date", + "$ref": "TimeSinceEpoch" + }, + { + "name": "signedCertificateTimestampList", + "description": "List of signed certificate timestamps (SCTs).", + "type": "array", + "items": { + "$ref": "SignedCertificateTimestamp" + } + }, + { + "name": "certificateTransparencyCompliance", + "description": "Whether the request complied with Certificate Transparency policy", + "$ref": "CertificateTransparencyCompliance" + } + ] + }, + { + "id": "CertificateTransparencyCompliance", + "description": "Whether the request complied with Certificate Transparency policy.", + "type": "string", + "enum": [ + "unknown", + "not-compliant", + "compliant" + ] + }, + { + "id": "BlockedReason", + "description": "The reason why request was blocked.", + "type": "string", + "enum": [ + "other", + "csp", + "mixed-content", + "origin", + "inspector", + "subresource-filter", + "content-type", + "collapsed-by-client" + ] + }, + { + "id": "Response", + "description": "HTTP response data.", + "type": "object", + "properties": [ + { + "name": "url", + "description": "Response URL. This URL can be different from CachedResource.url in case of redirect.", + "type": "string" + }, + { + "name": "status", + "description": "HTTP response status code.", + "type": "integer" + }, + { + "name": "statusText", + "description": "HTTP response status text.", + "type": "string" + }, + { + "name": "headers", + "description": "HTTP response headers.", + "$ref": "Headers" + }, + { + "name": "headersText", + "description": "HTTP response headers text.", + "optional": true, + "type": "string" + }, + { + "name": "mimeType", + "description": "Resource mimeType as determined by the browser.", + "type": "string" + }, + { + "name": "requestHeaders", + "description": "Refined HTTP request headers that were actually transmitted over the network.", + "optional": true, + "$ref": "Headers" + }, + { + "name": "requestHeadersText", + "description": "HTTP request headers text.", + "optional": true, + "type": "string" + }, + { + "name": "connectionReused", + "description": "Specifies whether physical connection was actually reused for this request.", + "type": "boolean" + }, + { + "name": "connectionId", + "description": "Physical connection id that was actually used for this request.", + "type": "number" + }, + { + "name": "remoteIPAddress", + "description": "Remote IP address.", + "optional": true, + "type": "string" + }, + { + "name": "remotePort", + "description": "Remote port.", + "optional": true, + "type": "integer" + }, + { + "name": "fromDiskCache", + "description": "Specifies that the request was served from the disk cache.", + "optional": true, + "type": "boolean" + }, + { + "name": "fromServiceWorker", + "description": "Specifies that the request was served from the ServiceWorker.", + "optional": true, + "type": "boolean" + }, + { + "name": "encodedDataLength", + "description": "Total number of bytes received for this request so far.", + "type": "number" + }, + { + "name": "timing", + "description": "Timing information for the given request.", + "optional": true, + "$ref": "ResourceTiming" + }, + { + "name": "protocol", + "description": "Protocol used to fetch this request.", + "optional": true, + "type": "string" + }, + { + "name": "securityState", + "description": "Security state of the request resource.", + "$ref": "Security.SecurityState" + }, + { + "name": "securityDetails", + "description": "Security details for the request.", + "optional": true, + "$ref": "SecurityDetails" + } + ] + }, + { + "id": "WebSocketRequest", + "description": "WebSocket request data.", + "type": "object", + "properties": [ + { + "name": "headers", + "description": "HTTP request headers.", + "$ref": "Headers" + } + ] + }, + { + "id": "WebSocketResponse", + "description": "WebSocket response data.", + "type": "object", + "properties": [ + { + "name": "status", + "description": "HTTP response status code.", + "type": "integer" + }, + { + "name": "statusText", + "description": "HTTP response status text.", + "type": "string" + }, + { + "name": "headers", + "description": "HTTP response headers.", + "$ref": "Headers" + }, + { + "name": "headersText", + "description": "HTTP response headers text.", + "optional": true, + "type": "string" + }, + { + "name": "requestHeaders", + "description": "HTTP request headers.", + "optional": true, + "$ref": "Headers" + }, + { + "name": "requestHeadersText", + "description": "HTTP request headers text.", + "optional": true, + "type": "string" + } + ] + }, + { + "id": "WebSocketFrame", + "description": "WebSocket frame data.", + "type": "object", + "properties": [ + { + "name": "opcode", + "description": "WebSocket frame opcode.", + "type": "number" + }, + { + "name": "mask", + "description": "WebSocke frame mask.", + "type": "boolean" + }, + { + "name": "payloadData", + "description": "WebSocke frame payload data.", + "type": "string" + } + ] + }, + { + "id": "CachedResource", + "description": "Information about the cached resource.", + "type": "object", + "properties": [ + { + "name": "url", + "description": "Resource URL. This is the url of the original network request.", + "type": "string" + }, + { + "name": "type", + "description": "Type of this resource.", + "$ref": "ResourceType" + }, + { + "name": "response", + "description": "Cached response data.", + "optional": true, + "$ref": "Response" + }, + { + "name": "bodySize", + "description": "Cached response body size.", + "type": "number" + } + ] + }, + { + "id": "Initiator", + "description": "Information about the request initiator.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "Type of this initiator.", + "type": "string", + "enum": [ + "parser", + "script", + "preload", + "SignedExchange", + "other" + ] + }, + { + "name": "stack", + "description": "Initiator JavaScript stack trace, set for Script only.", + "optional": true, + "$ref": "Runtime.StackTrace" + }, + { + "name": "url", + "description": "Initiator URL, set for Parser type or for Script type (when script is importing module) or for SignedExchange type.", + "optional": true, + "type": "string" + }, + { + "name": "lineNumber", + "description": "Initiator line number, set for Parser type or for Script type (when script is importing\nmodule) (0-based).", + "optional": true, + "type": "number" + } + ] + }, + { + "id": "Cookie", + "description": "Cookie object", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Cookie name.", + "type": "string" + }, + { + "name": "value", + "description": "Cookie value.", + "type": "string" + }, + { + "name": "domain", + "description": "Cookie domain.", + "type": "string" + }, + { + "name": "path", + "description": "Cookie path.", + "type": "string" + }, + { + "name": "expires", + "description": "Cookie expiration date as the number of seconds since the UNIX epoch.", + "type": "number" + }, + { + "name": "size", + "description": "Cookie size.", + "type": "integer" + }, + { + "name": "httpOnly", + "description": "True if cookie is http-only.", + "type": "boolean" + }, + { + "name": "secure", + "description": "True if cookie is secure.", + "type": "boolean" + }, + { + "name": "session", + "description": "True in case of session cookie.", + "type": "boolean" + }, + { + "name": "sameSite", + "description": "Cookie SameSite type.", + "optional": true, + "$ref": "CookieSameSite" + } + ] + }, + { + "id": "CookieParam", + "description": "Cookie parameter object", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Cookie name.", + "type": "string" + }, + { + "name": "value", + "description": "Cookie value.", + "type": "string" + }, + { + "name": "url", + "description": "The request-URI to associate with the setting of the cookie. This value can affect the\ndefault domain and path values of the created cookie.", + "optional": true, + "type": "string" + }, + { + "name": "domain", + "description": "Cookie domain.", + "optional": true, + "type": "string" + }, + { + "name": "path", + "description": "Cookie path.", + "optional": true, + "type": "string" + }, + { + "name": "secure", + "description": "True if cookie is secure.", + "optional": true, + "type": "boolean" + }, + { + "name": "httpOnly", + "description": "True if cookie is http-only.", + "optional": true, + "type": "boolean" + }, + { + "name": "sameSite", + "description": "Cookie SameSite type.", + "optional": true, + "$ref": "CookieSameSite" + }, + { + "name": "expires", + "description": "Cookie expiration date, session cookie if not set", + "optional": true, + "$ref": "TimeSinceEpoch" + } + ] + }, + { + "id": "AuthChallenge", + "description": "Authorization challenge for HTTP status code 401 or 407.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "source", + "description": "Source of the authentication challenge.", + "optional": true, + "type": "string", + "enum": [ + "Server", + "Proxy" + ] + }, + { + "name": "origin", + "description": "Origin of the challenger.", + "type": "string" + }, + { + "name": "scheme", + "description": "The authentication scheme used, such as basic or digest", + "type": "string" + }, + { + "name": "realm", + "description": "The realm of the challenge. May be empty.", + "type": "string" + } + ] + }, + { + "id": "AuthChallengeResponse", + "description": "Response to an AuthChallenge.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "response", + "description": "The decision on what to do in response to the authorization challenge. Default means\ndeferring to the default behavior of the net stack, which will likely either the Cancel\nauthentication or display a popup dialog box.", + "type": "string", + "enum": [ + "Default", + "CancelAuth", + "ProvideCredentials" + ] + }, + { + "name": "username", + "description": "The username to provide, possibly empty. Should only be set if response is\nProvideCredentials.", + "optional": true, + "type": "string" + }, + { + "name": "password", + "description": "The password to provide, possibly empty. Should only be set if response is\nProvideCredentials.", + "optional": true, + "type": "string" + } + ] + }, + { + "id": "InterceptionStage", + "description": "Stages of the interception to begin intercepting. Request will intercept before the request is\nsent. Response will intercept after the response is received.", + "experimental": true, + "type": "string", + "enum": [ + "Request", + "HeadersReceived" + ] + }, + { + "id": "RequestPattern", + "description": "Request pattern for interception.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "urlPattern", + "description": "Wildcards ('*' -> zero or more, '?' -> exactly one) are allowed. Escape character is\nbackslash. Omitting is equivalent to \"*\".", + "optional": true, + "type": "string" + }, + { + "name": "resourceType", + "description": "If set, only requests for matching resource types will be intercepted.", + "optional": true, + "$ref": "ResourceType" + }, + { + "name": "interceptionStage", + "description": "Stage at wich to begin intercepting requests. Default is Request.", + "optional": true, + "$ref": "InterceptionStage" + } + ] + }, + { + "id": "SignedExchangeSignature", + "description": "Information about a signed exchange signature.\nhttps://wicg.github.io/webpackage/draft-yasskin-httpbis-origin-signed-exchanges-impl.html#rfc.section.3.1", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "label", + "description": "Signed exchange signature label.", + "type": "string" + }, + { + "name": "signature", + "description": "The hex string of signed exchange signature.", + "type": "string" + }, + { + "name": "integrity", + "description": "Signed exchange signature integrity.", + "type": "string" + }, + { + "name": "certUrl", + "description": "Signed exchange signature cert Url.", + "optional": true, + "type": "string" + }, + { + "name": "certSha256", + "description": "The hex string of signed exchange signature cert sha256.", + "optional": true, + "type": "string" + }, + { + "name": "validityUrl", + "description": "Signed exchange signature validity Url.", + "type": "string" + }, + { + "name": "date", + "description": "Signed exchange signature date.", + "type": "integer" + }, + { + "name": "expires", + "description": "Signed exchange signature expires.", + "type": "integer" + }, + { + "name": "certificates", + "description": "The encoded certificates.", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "id": "SignedExchangeHeader", + "description": "Information about a signed exchange header.\nhttps://wicg.github.io/webpackage/draft-yasskin-httpbis-origin-signed-exchanges-impl.html#cbor-representation", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "requestUrl", + "description": "Signed exchange request URL.", + "type": "string" + }, + { + "name": "requestMethod", + "description": "Signed exchange request method.", + "type": "string" + }, + { + "name": "responseCode", + "description": "Signed exchange response code.", + "type": "integer" + }, + { + "name": "responseHeaders", + "description": "Signed exchange response headers.", + "$ref": "Headers" + }, + { + "name": "signatures", + "description": "Signed exchange response signature.", + "type": "array", + "items": { + "$ref": "SignedExchangeSignature" + } + } + ] + }, + { + "id": "SignedExchangeErrorField", + "description": "Field type for a signed exchange related error.", + "experimental": true, + "type": "string", + "enum": [ + "signatureSig", + "signatureIntegrity", + "signatureCertUrl", + "signatureCertSha256", + "signatureValidityUrl", + "signatureTimestamps" + ] + }, + { + "id": "SignedExchangeError", + "description": "Information about a signed exchange response.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "message", + "description": "Error message.", + "type": "string" + }, + { + "name": "signatureIndex", + "description": "The index of the signature which caused the error.", + "optional": true, + "type": "integer" + }, + { + "name": "errorField", + "description": "The field which caused the error.", + "optional": true, + "$ref": "SignedExchangeErrorField" + } + ] + }, + { + "id": "SignedExchangeInfo", + "description": "Information about a signed exchange response.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "outerResponse", + "description": "The outer response of signed HTTP exchange which was received from network.", + "$ref": "Response" + }, + { + "name": "header", + "description": "Information about the signed exchange header.", + "optional": true, + "$ref": "SignedExchangeHeader" + }, + { + "name": "securityDetails", + "description": "Security details for the signed exchange header.", + "optional": true, + "$ref": "SecurityDetails" + }, + { + "name": "errors", + "description": "Errors occurred while handling the signed exchagne.", + "optional": true, + "type": "array", + "items": { + "$ref": "SignedExchangeError" + } + } + ] + } + ], + "commands": [ + { + "name": "canClearBrowserCache", + "description": "Tells whether clearing browser cache is supported.", + "deprecated": true, + "returns": [ + { + "name": "result", + "description": "True if browser cache can be cleared.", + "type": "boolean" + } + ] + }, + { + "name": "canClearBrowserCookies", + "description": "Tells whether clearing browser cookies is supported.", + "deprecated": true, + "returns": [ + { + "name": "result", + "description": "True if browser cookies can be cleared.", + "type": "boolean" + } + ] + }, + { + "name": "canEmulateNetworkConditions", + "description": "Tells whether emulation of network conditions is supported.", + "deprecated": true, + "returns": [ + { + "name": "result", + "description": "True if emulation of network conditions is supported.", + "type": "boolean" + } + ] + }, + { + "name": "clearBrowserCache", + "description": "Clears browser cache." + }, + { + "name": "clearBrowserCookies", + "description": "Clears browser cookies." + }, + { + "name": "continueInterceptedRequest", + "description": "Response to Network.requestIntercepted which either modifies the request to continue with any\nmodifications, or blocks it, or completes it with the provided response bytes. If a network\nfetch occurs as a result which encounters a redirect an additional Network.requestIntercepted\nevent will be sent with the same InterceptionId.", + "experimental": true, + "parameters": [ + { + "name": "interceptionId", + "$ref": "InterceptionId" + }, + { + "name": "errorReason", + "description": "If set this causes the request to fail with the given reason. Passing `Aborted` for requests\nmarked with `isNavigationRequest` also cancels the navigation. Must not be set in response\nto an authChallenge.", + "optional": true, + "$ref": "ErrorReason" + }, + { + "name": "rawResponse", + "description": "If set the requests completes using with the provided base64 encoded raw response, including\nHTTP status line and headers etc... Must not be set in response to an authChallenge.", + "optional": true, + "type": "binary" + }, + { + "name": "url", + "description": "If set the request url will be modified in a way that's not observable by page. Must not be\nset in response to an authChallenge.", + "optional": true, + "type": "string" + }, + { + "name": "method", + "description": "If set this allows the request method to be overridden. Must not be set in response to an\nauthChallenge.", + "optional": true, + "type": "string" + }, + { + "name": "postData", + "description": "If set this allows postData to be set. Must not be set in response to an authChallenge.", + "optional": true, + "type": "string" + }, + { + "name": "headers", + "description": "If set this allows the request headers to be changed. Must not be set in response to an\nauthChallenge.", + "optional": true, + "$ref": "Headers" + }, + { + "name": "authChallengeResponse", + "description": "Response to a requestIntercepted with an authChallenge. Must not be set otherwise.", + "optional": true, + "$ref": "AuthChallengeResponse" + } + ] + }, + { + "name": "deleteCookies", + "description": "Deletes browser cookies with matching name and url or domain/path pair.", + "parameters": [ + { + "name": "name", + "description": "Name of the cookies to remove.", + "type": "string" + }, + { + "name": "url", + "description": "If specified, deletes all the cookies with the given name where domain and path match\nprovided URL.", + "optional": true, + "type": "string" + }, + { + "name": "domain", + "description": "If specified, deletes only cookies with the exact domain.", + "optional": true, + "type": "string" + }, + { + "name": "path", + "description": "If specified, deletes only cookies with the exact path.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "disable", + "description": "Disables network tracking, prevents network events from being sent to the client." + }, + { + "name": "emulateNetworkConditions", + "description": "Activates emulation of network conditions.", + "parameters": [ + { + "name": "offline", + "description": "True to emulate internet disconnection.", + "type": "boolean" + }, + { + "name": "latency", + "description": "Minimum latency from request sent to response headers received (ms).", + "type": "number" + }, + { + "name": "downloadThroughput", + "description": "Maximal aggregated download throughput (bytes/sec). -1 disables download throttling.", + "type": "number" + }, + { + "name": "uploadThroughput", + "description": "Maximal aggregated upload throughput (bytes/sec). -1 disables upload throttling.", + "type": "number" + }, + { + "name": "connectionType", + "description": "Connection type if known.", + "optional": true, + "$ref": "ConnectionType" + } + ] + }, + { + "name": "enable", + "description": "Enables network tracking, network events will now be delivered to the client.", + "parameters": [ + { + "name": "maxTotalBufferSize", + "description": "Buffer size in bytes to use when preserving network payloads (XHRs, etc).", + "experimental": true, + "optional": true, + "type": "integer" + }, + { + "name": "maxResourceBufferSize", + "description": "Per-resource buffer size in bytes to use when preserving network payloads (XHRs, etc).", + "experimental": true, + "optional": true, + "type": "integer" + }, + { + "name": "maxPostDataSize", + "description": "Longest post body size (in bytes) that would be included in requestWillBeSent notification", + "optional": true, + "type": "integer" + } + ] + }, + { + "name": "getAllCookies", + "description": "Returns all browser cookies. Depending on the backend support, will return detailed cookie\ninformation in the `cookies` field.", + "returns": [ + { + "name": "cookies", + "description": "Array of cookie objects.", + "type": "array", + "items": { + "$ref": "Cookie" + } + } + ] + }, + { + "name": "getCertificate", + "description": "Returns the DER-encoded certificate.", + "experimental": true, + "parameters": [ + { + "name": "origin", + "description": "Origin to get certificate for.", + "type": "string" + } + ], + "returns": [ + { + "name": "tableNames", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "getCookies", + "description": "Returns all browser cookies for the current URL. Depending on the backend support, will return\ndetailed cookie information in the `cookies` field.", + "parameters": [ + { + "name": "urls", + "description": "The list of URLs for which applicable cookies will be fetched", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + } + ], + "returns": [ + { + "name": "cookies", + "description": "Array of cookie objects.", + "type": "array", + "items": { + "$ref": "Cookie" + } + } + ] + }, + { + "name": "getResponseBody", + "description": "Returns content served for the given request.", + "parameters": [ + { + "name": "requestId", + "description": "Identifier of the network request to get content for.", + "$ref": "RequestId" + } + ], + "returns": [ + { + "name": "body", + "description": "Response body.", + "type": "string" + }, + { + "name": "base64Encoded", + "description": "True, if content was sent as base64.", + "type": "boolean" + } + ] + }, + { + "name": "getRequestPostData", + "description": "Returns post data sent with the request. Returns an error when no data was sent with the request.", + "parameters": [ + { + "name": "requestId", + "description": "Identifier of the network request to get content for.", + "$ref": "RequestId" + } + ], + "returns": [ + { + "name": "postData", + "description": "Base64-encoded request body.", + "type": "string" + } + ] + }, + { + "name": "getResponseBodyForInterception", + "description": "Returns content served for the given currently intercepted request.", + "experimental": true, + "parameters": [ + { + "name": "interceptionId", + "description": "Identifier for the intercepted request to get body for.", + "$ref": "InterceptionId" + } + ], + "returns": [ + { + "name": "body", + "description": "Response body.", + "type": "string" + }, + { + "name": "base64Encoded", + "description": "True, if content was sent as base64.", + "type": "boolean" + } + ] + }, + { + "name": "takeResponseBodyForInterceptionAsStream", + "description": "Returns a handle to the stream representing the response body. Note that after this command,\nthe intercepted request can't be continued as is -- you either need to cancel it or to provide\nthe response body. The stream only supports sequential read, IO.read will fail if the position\nis specified.", + "experimental": true, + "parameters": [ + { + "name": "interceptionId", + "$ref": "InterceptionId" + } + ], + "returns": [ + { + "name": "stream", + "$ref": "IO.StreamHandle" + } + ] + }, + { + "name": "replayXHR", + "description": "This method sends a new XMLHttpRequest which is identical to the original one. The following\nparameters should be identical: method, url, async, request body, extra headers, withCredentials\nattribute, user, password.", + "experimental": true, + "parameters": [ + { + "name": "requestId", + "description": "Identifier of XHR to replay.", + "$ref": "RequestId" + } + ] + }, + { + "name": "searchInResponseBody", + "description": "Searches for given string in response content.", + "experimental": true, + "parameters": [ + { + "name": "requestId", + "description": "Identifier of the network response to search.", + "$ref": "RequestId" + }, + { + "name": "query", + "description": "String to search for.", + "type": "string" + }, + { + "name": "caseSensitive", + "description": "If true, search is case sensitive.", + "optional": true, + "type": "boolean" + }, + { + "name": "isRegex", + "description": "If true, treats string parameter as regex.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "result", + "description": "List of search matches.", + "type": "array", + "items": { + "$ref": "Debugger.SearchMatch" + } + } + ] + }, + { + "name": "setBlockedURLs", + "description": "Blocks URLs from loading.", + "experimental": true, + "parameters": [ + { + "name": "urls", + "description": "URL patterns to block. Wildcards ('*') are allowed.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "setBypassServiceWorker", + "description": "Toggles ignoring of service worker for each request.", + "experimental": true, + "parameters": [ + { + "name": "bypass", + "description": "Bypass service worker and load from network.", + "type": "boolean" + } + ] + }, + { + "name": "setCacheDisabled", + "description": "Toggles ignoring cache for each request. If `true`, cache will not be used.", + "parameters": [ + { + "name": "cacheDisabled", + "description": "Cache disabled state.", + "type": "boolean" + } + ] + }, + { + "name": "setCookie", + "description": "Sets a cookie with the given cookie data; may overwrite equivalent cookies if they exist.", + "parameters": [ + { + "name": "name", + "description": "Cookie name.", + "type": "string" + }, + { + "name": "value", + "description": "Cookie value.", + "type": "string" + }, + { + "name": "url", + "description": "The request-URI to associate with the setting of the cookie. This value can affect the\ndefault domain and path values of the created cookie.", + "optional": true, + "type": "string" + }, + { + "name": "domain", + "description": "Cookie domain.", + "optional": true, + "type": "string" + }, + { + "name": "path", + "description": "Cookie path.", + "optional": true, + "type": "string" + }, + { + "name": "secure", + "description": "True if cookie is secure.", + "optional": true, + "type": "boolean" + }, + { + "name": "httpOnly", + "description": "True if cookie is http-only.", + "optional": true, + "type": "boolean" + }, + { + "name": "sameSite", + "description": "Cookie SameSite type.", + "optional": true, + "$ref": "CookieSameSite" + }, + { + "name": "expires", + "description": "Cookie expiration date, session cookie if not set", + "optional": true, + "$ref": "TimeSinceEpoch" + } + ], + "returns": [ + { + "name": "success", + "description": "True if successfully set cookie.", + "type": "boolean" + } + ] + }, + { + "name": "setCookies", + "description": "Sets given cookies.", + "parameters": [ + { + "name": "cookies", + "description": "Cookies to be set.", + "type": "array", + "items": { + "$ref": "CookieParam" + } + } + ] + }, + { + "name": "setDataSizeLimitsForTest", + "description": "For testing.", + "experimental": true, + "parameters": [ + { + "name": "maxTotalSize", + "description": "Maximum total buffer size.", + "type": "integer" + }, + { + "name": "maxResourceSize", + "description": "Maximum per-resource size.", + "type": "integer" + } + ] + }, + { + "name": "setExtraHTTPHeaders", + "description": "Specifies whether to always send extra HTTP headers with the requests from this page.", + "parameters": [ + { + "name": "headers", + "description": "Map with extra HTTP headers.", + "$ref": "Headers" + } + ] + }, + { + "name": "setRequestInterception", + "description": "Sets the requests to intercept that match a the provided patterns and optionally resource types.", + "experimental": true, + "parameters": [ + { + "name": "patterns", + "description": "Requests matching any of these patterns will be forwarded and wait for the corresponding\ncontinueInterceptedRequest call.", + "type": "array", + "items": { + "$ref": "RequestPattern" + } + } + ] + }, + { + "name": "setUserAgentOverride", + "description": "Allows overriding user agent with the given string.", + "redirect": "Emulation", + "parameters": [ + { + "name": "userAgent", + "description": "User agent to use.", + "type": "string" + }, + { + "name": "acceptLanguage", + "description": "Browser langugage to emulate.", + "optional": true, + "type": "string" + }, + { + "name": "platform", + "description": "The platform navigator.platform should return.", + "optional": true, + "type": "string" + } + ] + } + ], + "events": [ + { + "name": "dataReceived", + "description": "Fired when data chunk was received over the network.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "dataLength", + "description": "Data chunk length.", + "type": "integer" + }, + { + "name": "encodedDataLength", + "description": "Actual bytes received (might be less than dataLength for compressed encodings).", + "type": "integer" + } + ] + }, + { + "name": "eventSourceMessageReceived", + "description": "Fired when EventSource message is received.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "eventName", + "description": "Message type.", + "type": "string" + }, + { + "name": "eventId", + "description": "Message identifier.", + "type": "string" + }, + { + "name": "data", + "description": "Message content.", + "type": "string" + } + ] + }, + { + "name": "loadingFailed", + "description": "Fired when HTTP request has failed to load.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "type", + "description": "Resource type.", + "$ref": "ResourceType" + }, + { + "name": "errorText", + "description": "User friendly error message.", + "type": "string" + }, + { + "name": "canceled", + "description": "True if loading was canceled.", + "optional": true, + "type": "boolean" + }, + { + "name": "blockedReason", + "description": "The reason why loading was blocked, if any.", + "optional": true, + "$ref": "BlockedReason" + } + ] + }, + { + "name": "loadingFinished", + "description": "Fired when HTTP request has finished loading.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "encodedDataLength", + "description": "Total number of bytes received for this request.", + "type": "number" + }, + { + "name": "shouldReportCorbBlocking", + "description": "Set when 1) response was blocked by Cross-Origin Read Blocking and also\n2) this needs to be reported to the DevTools console.", + "optional": true, + "type": "boolean" + } + ] + }, + { + "name": "requestIntercepted", + "description": "Details of an intercepted HTTP request, which must be either allowed, blocked, modified or\nmocked.", + "experimental": true, + "parameters": [ + { + "name": "interceptionId", + "description": "Each request the page makes will have a unique id, however if any redirects are encountered\nwhile processing that fetch, they will be reported with the same id as the original fetch.\nLikewise if HTTP authentication is needed then the same fetch id will be used.", + "$ref": "InterceptionId" + }, + { + "name": "request", + "$ref": "Request" + }, + { + "name": "frameId", + "description": "The id of the frame that initiated the request.", + "$ref": "Page.FrameId" + }, + { + "name": "resourceType", + "description": "How the requested resource will be used.", + "$ref": "ResourceType" + }, + { + "name": "isNavigationRequest", + "description": "Whether this is a navigation request, which can abort the navigation completely.", + "type": "boolean" + }, + { + "name": "isDownload", + "description": "Set if the request is a navigation that will result in a download.\nOnly present after response is received from the server (i.e. HeadersReceived stage).", + "optional": true, + "type": "boolean" + }, + { + "name": "redirectUrl", + "description": "Redirect location, only sent if a redirect was intercepted.", + "optional": true, + "type": "string" + }, + { + "name": "authChallenge", + "description": "Details of the Authorization Challenge encountered. If this is set then\ncontinueInterceptedRequest must contain an authChallengeResponse.", + "optional": true, + "$ref": "AuthChallenge" + }, + { + "name": "responseErrorReason", + "description": "Response error if intercepted at response stage or if redirect occurred while intercepting\nrequest.", + "optional": true, + "$ref": "ErrorReason" + }, + { + "name": "responseStatusCode", + "description": "Response code if intercepted at response stage or if redirect occurred while intercepting\nrequest or auth retry occurred.", + "optional": true, + "type": "integer" + }, + { + "name": "responseHeaders", + "description": "Response headers if intercepted at the response stage or if redirect occurred while\nintercepting request or auth retry occurred.", + "optional": true, + "$ref": "Headers" + } + ] + }, + { + "name": "requestServedFromCache", + "description": "Fired if request ended up loading from cache.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + } + ] + }, + { + "name": "requestWillBeSent", + "description": "Fired when page is about to send HTTP request.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "loaderId", + "description": "Loader identifier. Empty string if the request is fetched from worker.", + "$ref": "LoaderId" + }, + { + "name": "documentURL", + "description": "URL of the document this request is loaded for.", + "type": "string" + }, + { + "name": "request", + "description": "Request data.", + "$ref": "Request" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "wallTime", + "description": "Timestamp.", + "$ref": "TimeSinceEpoch" + }, + { + "name": "initiator", + "description": "Request initiator.", + "$ref": "Initiator" + }, + { + "name": "redirectResponse", + "description": "Redirect response data.", + "optional": true, + "$ref": "Response" + }, + { + "name": "type", + "description": "Type of this resource.", + "optional": true, + "$ref": "ResourceType" + }, + { + "name": "frameId", + "description": "Frame identifier.", + "optional": true, + "$ref": "Page.FrameId" + }, + { + "name": "hasUserGesture", + "description": "Whether the request is initiated by a user gesture. Defaults to false.", + "optional": true, + "type": "boolean" + } + ] + }, + { + "name": "resourceChangedPriority", + "description": "Fired when resource loading priority is changed", + "experimental": true, + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "newPriority", + "description": "New priority", + "$ref": "ResourcePriority" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + } + ] + }, + { + "name": "signedExchangeReceived", + "description": "Fired when a signed exchange was received over the network", + "experimental": true, + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "info", + "description": "Information about the signed exchange response.", + "$ref": "SignedExchangeInfo" + } + ] + }, + { + "name": "responseReceived", + "description": "Fired when HTTP response is available.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "loaderId", + "description": "Loader identifier. Empty string if the request is fetched from worker.", + "$ref": "LoaderId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "type", + "description": "Resource type.", + "$ref": "ResourceType" + }, + { + "name": "response", + "description": "Response data.", + "$ref": "Response" + }, + { + "name": "frameId", + "description": "Frame identifier.", + "optional": true, + "$ref": "Page.FrameId" + } + ] + }, + { + "name": "webSocketClosed", + "description": "Fired when WebSocket is closed.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + } + ] + }, + { + "name": "webSocketCreated", + "description": "Fired upon WebSocket creation.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "url", + "description": "WebSocket request URL.", + "type": "string" + }, + { + "name": "initiator", + "description": "Request initiator.", + "optional": true, + "$ref": "Initiator" + } + ] + }, + { + "name": "webSocketFrameError", + "description": "Fired when WebSocket frame error occurs.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "errorMessage", + "description": "WebSocket frame error message.", + "type": "string" + } + ] + }, + { + "name": "webSocketFrameReceived", + "description": "Fired when WebSocket frame is received.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "response", + "description": "WebSocket response data.", + "$ref": "WebSocketFrame" + } + ] + }, + { + "name": "webSocketFrameSent", + "description": "Fired when WebSocket frame is sent.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "response", + "description": "WebSocket response data.", + "$ref": "WebSocketFrame" + } + ] + }, + { + "name": "webSocketHandshakeResponseReceived", + "description": "Fired when WebSocket handshake response becomes available.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "response", + "description": "WebSocket response data.", + "$ref": "WebSocketResponse" + } + ] + }, + { + "name": "webSocketWillSendHandshakeRequest", + "description": "Fired when WebSocket is about to initiate handshake.", + "parameters": [ + { + "name": "requestId", + "description": "Request identifier.", + "$ref": "RequestId" + }, + { + "name": "timestamp", + "description": "Timestamp.", + "$ref": "MonotonicTime" + }, + { + "name": "wallTime", + "description": "UTC Timestamp.", + "$ref": "TimeSinceEpoch" + }, + { + "name": "request", + "description": "WebSocket request data.", + "$ref": "WebSocketRequest" + } + ] + } + ] + }, + { + "domain": "Overlay", + "description": "This domain provides various functionality related to drawing atop the inspected page.", + "experimental": true, + "dependencies": [ + "DOM", + "Page", + "Runtime" + ], + "types": [ + { + "id": "HighlightConfig", + "description": "Configuration data for the highlighting of page elements.", + "type": "object", + "properties": [ + { + "name": "showInfo", + "description": "Whether the node info tooltip should be shown (default: false).", + "optional": true, + "type": "boolean" + }, + { + "name": "showRulers", + "description": "Whether the rulers should be shown (default: false).", + "optional": true, + "type": "boolean" + }, + { + "name": "showExtensionLines", + "description": "Whether the extension lines from node to the rulers should be shown (default: false).", + "optional": true, + "type": "boolean" + }, + { + "name": "displayAsMaterial", + "optional": true, + "type": "boolean" + }, + { + "name": "contentColor", + "description": "The content box highlight fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "paddingColor", + "description": "The padding highlight fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "borderColor", + "description": "The border highlight fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "marginColor", + "description": "The margin highlight fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "eventTargetColor", + "description": "The event target element highlight fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "shapeColor", + "description": "The shape outside fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "shapeMarginColor", + "description": "The shape margin fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "selectorList", + "description": "Selectors to highlight relevant nodes.", + "optional": true, + "type": "string" + }, + { + "name": "cssGridColor", + "description": "The grid layout color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + } + ] + }, + { + "id": "InspectMode", + "type": "string", + "enum": [ + "searchForNode", + "searchForUAShadowDOM", + "none" + ] + } + ], + "commands": [ + { + "name": "disable", + "description": "Disables domain notifications." + }, + { + "name": "enable", + "description": "Enables domain notifications." + }, + { + "name": "getHighlightObjectForTest", + "description": "For testing.", + "parameters": [ + { + "name": "nodeId", + "description": "Id of the node to get highlight object for.", + "$ref": "DOM.NodeId" + } + ], + "returns": [ + { + "name": "highlight", + "description": "Highlight data for the node.", + "type": "object" + } + ] + }, + { + "name": "hideHighlight", + "description": "Hides any highlight." + }, + { + "name": "highlightFrame", + "description": "Highlights owner element of the frame with given id.", + "parameters": [ + { + "name": "frameId", + "description": "Identifier of the frame to highlight.", + "$ref": "Page.FrameId" + }, + { + "name": "contentColor", + "description": "The content box highlight fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "contentOutlineColor", + "description": "The content box highlight outline color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + } + ] + }, + { + "name": "highlightNode", + "description": "Highlights DOM node with given id or with the given JavaScript object wrapper. Either nodeId or\nobjectId must be specified.", + "parameters": [ + { + "name": "highlightConfig", + "description": "A descriptor for the highlight appearance.", + "$ref": "HighlightConfig" + }, + { + "name": "nodeId", + "description": "Identifier of the node to highlight.", + "optional": true, + "$ref": "DOM.NodeId" + }, + { + "name": "backendNodeId", + "description": "Identifier of the backend node to highlight.", + "optional": true, + "$ref": "DOM.BackendNodeId" + }, + { + "name": "objectId", + "description": "JavaScript object id of the node to be highlighted.", + "optional": true, + "$ref": "Runtime.RemoteObjectId" + } + ] + }, + { + "name": "highlightQuad", + "description": "Highlights given quad. Coordinates are absolute with respect to the main frame viewport.", + "parameters": [ + { + "name": "quad", + "description": "Quad to highlight", + "$ref": "DOM.Quad" + }, + { + "name": "color", + "description": "The highlight fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "outlineColor", + "description": "The highlight outline color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + } + ] + }, + { + "name": "highlightRect", + "description": "Highlights given rectangle. Coordinates are absolute with respect to the main frame viewport.", + "parameters": [ + { + "name": "x", + "description": "X coordinate", + "type": "integer" + }, + { + "name": "y", + "description": "Y coordinate", + "type": "integer" + }, + { + "name": "width", + "description": "Rectangle width", + "type": "integer" + }, + { + "name": "height", + "description": "Rectangle height", + "type": "integer" + }, + { + "name": "color", + "description": "The highlight fill color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + }, + { + "name": "outlineColor", + "description": "The highlight outline color (default: transparent).", + "optional": true, + "$ref": "DOM.RGBA" + } + ] + }, + { + "name": "setInspectMode", + "description": "Enters the 'inspect' mode. In this mode, elements that user is hovering over are highlighted.\nBackend then generates 'inspectNodeRequested' event upon element selection.", + "parameters": [ + { + "name": "mode", + "description": "Set an inspection mode.", + "$ref": "InspectMode" + }, + { + "name": "highlightConfig", + "description": "A descriptor for the highlight appearance of hovered-over nodes. May be omitted if `enabled\n== false`.", + "optional": true, + "$ref": "HighlightConfig" + } + ] + }, + { + "name": "setPausedInDebuggerMessage", + "parameters": [ + { + "name": "message", + "description": "The message to display, also triggers resume and step over controls.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "setShowDebugBorders", + "description": "Requests that backend shows debug borders on layers", + "parameters": [ + { + "name": "show", + "description": "True for showing debug borders", + "type": "boolean" + } + ] + }, + { + "name": "setShowFPSCounter", + "description": "Requests that backend shows the FPS counter", + "parameters": [ + { + "name": "show", + "description": "True for showing the FPS counter", + "type": "boolean" + } + ] + }, + { + "name": "setShowPaintRects", + "description": "Requests that backend shows paint rectangles", + "parameters": [ + { + "name": "result", + "description": "True for showing paint rectangles", + "type": "boolean" + } + ] + }, + { + "name": "setShowScrollBottleneckRects", + "description": "Requests that backend shows scroll bottleneck rects", + "parameters": [ + { + "name": "show", + "description": "True for showing scroll bottleneck rects", + "type": "boolean" + } + ] + }, + { + "name": "setShowHitTestBorders", + "description": "Requests that backend shows hit-test borders on layers", + "parameters": [ + { + "name": "show", + "description": "True for showing hit-test borders", + "type": "boolean" + } + ] + }, + { + "name": "setShowViewportSizeOnResize", + "description": "Paints viewport size upon main frame resize.", + "parameters": [ + { + "name": "show", + "description": "Whether to paint size or not.", + "type": "boolean" + } + ] + }, + { + "name": "setSuspended", + "parameters": [ + { + "name": "suspended", + "description": "Whether overlay should be suspended and not consume any resources until resumed.", + "type": "boolean" + } + ] + } + ], + "events": [ + { + "name": "inspectNodeRequested", + "description": "Fired when the node should be inspected. This happens after call to `setInspectMode` or when\nuser manually inspects an element.", + "parameters": [ + { + "name": "backendNodeId", + "description": "Id of the node to inspect.", + "$ref": "DOM.BackendNodeId" + } + ] + }, + { + "name": "nodeHighlightRequested", + "description": "Fired when the node should be highlighted. This happens after call to `setInspectMode`.", + "parameters": [ + { + "name": "nodeId", + "$ref": "DOM.NodeId" + } + ] + }, + { + "name": "screenshotRequested", + "description": "Fired when user asks to capture screenshot of some area on the page.", + "parameters": [ + { + "name": "viewport", + "description": "Viewport to capture, in CSS.", + "$ref": "Page.Viewport" + } + ] + } + ] + }, + { + "domain": "Page", + "description": "Actions and events related to the inspected page belong to the page domain.", + "dependencies": [ + "Debugger", + "DOM", + "Network", + "Runtime" + ], + "types": [ + { + "id": "FrameId", + "description": "Unique frame identifier.", + "type": "string" + }, + { + "id": "Frame", + "description": "Information about the Frame on the page.", + "type": "object", + "properties": [ + { + "name": "id", + "description": "Frame unique identifier.", + "type": "string" + }, + { + "name": "parentId", + "description": "Parent frame identifier.", + "optional": true, + "type": "string" + }, + { + "name": "loaderId", + "description": "Identifier of the loader associated with this frame.", + "$ref": "Network.LoaderId" + }, + { + "name": "name", + "description": "Frame's name as specified in the tag.", + "optional": true, + "type": "string" + }, + { + "name": "url", + "description": "Frame document's URL.", + "type": "string" + }, + { + "name": "securityOrigin", + "description": "Frame document's security origin.", + "type": "string" + }, + { + "name": "mimeType", + "description": "Frame document's mimeType as determined by the browser.", + "type": "string" + }, + { + "name": "unreachableUrl", + "description": "If the frame failed to load, this contains the URL that could not be loaded.", + "experimental": true, + "optional": true, + "type": "string" + } + ] + }, + { + "id": "FrameResource", + "description": "Information about the Resource on the page.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "url", + "description": "Resource URL.", + "type": "string" + }, + { + "name": "type", + "description": "Type of this resource.", + "$ref": "Network.ResourceType" + }, + { + "name": "mimeType", + "description": "Resource mimeType as determined by the browser.", + "type": "string" + }, + { + "name": "lastModified", + "description": "last-modified timestamp as reported by server.", + "optional": true, + "$ref": "Network.TimeSinceEpoch" + }, + { + "name": "contentSize", + "description": "Resource content size.", + "optional": true, + "type": "number" + }, + { + "name": "failed", + "description": "True if the resource failed to load.", + "optional": true, + "type": "boolean" + }, + { + "name": "canceled", + "description": "True if the resource was canceled during loading.", + "optional": true, + "type": "boolean" + } + ] + }, + { + "id": "FrameResourceTree", + "description": "Information about the Frame hierarchy along with their cached resources.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "frame", + "description": "Frame information for this tree item.", + "$ref": "Frame" + }, + { + "name": "childFrames", + "description": "Child frames.", + "optional": true, + "type": "array", + "items": { + "$ref": "FrameResourceTree" + } + }, + { + "name": "resources", + "description": "Information about frame resources.", + "type": "array", + "items": { + "$ref": "FrameResource" + } + } + ] + }, + { + "id": "FrameTree", + "description": "Information about the Frame hierarchy.", + "type": "object", + "properties": [ + { + "name": "frame", + "description": "Frame information for this tree item.", + "$ref": "Frame" + }, + { + "name": "childFrames", + "description": "Child frames.", + "optional": true, + "type": "array", + "items": { + "$ref": "FrameTree" + } + } + ] + }, + { + "id": "ScriptIdentifier", + "description": "Unique script identifier.", + "type": "string" + }, + { + "id": "TransitionType", + "description": "Transition type.", + "type": "string", + "enum": [ + "link", + "typed", + "address_bar", + "auto_bookmark", + "auto_subframe", + "manual_subframe", + "generated", + "auto_toplevel", + "form_submit", + "reload", + "keyword", + "keyword_generated", + "other" + ] + }, + { + "id": "NavigationEntry", + "description": "Navigation history entry.", + "type": "object", + "properties": [ + { + "name": "id", + "description": "Unique id of the navigation history entry.", + "type": "integer" + }, + { + "name": "url", + "description": "URL of the navigation history entry.", + "type": "string" + }, + { + "name": "userTypedURL", + "description": "URL that the user typed in the url bar.", + "type": "string" + }, + { + "name": "title", + "description": "Title of the navigation history entry.", + "type": "string" + }, + { + "name": "transitionType", + "description": "Transition type.", + "$ref": "TransitionType" + } + ] + }, + { + "id": "ScreencastFrameMetadata", + "description": "Screencast frame metadata.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "offsetTop", + "description": "Top offset in DIP.", + "type": "number" + }, + { + "name": "pageScaleFactor", + "description": "Page scale factor.", + "type": "number" + }, + { + "name": "deviceWidth", + "description": "Device screen width in DIP.", + "type": "number" + }, + { + "name": "deviceHeight", + "description": "Device screen height in DIP.", + "type": "number" + }, + { + "name": "scrollOffsetX", + "description": "Position of horizontal scroll in CSS pixels.", + "type": "number" + }, + { + "name": "scrollOffsetY", + "description": "Position of vertical scroll in CSS pixels.", + "type": "number" + }, + { + "name": "timestamp", + "description": "Frame swap timestamp.", + "optional": true, + "$ref": "Network.TimeSinceEpoch" + } + ] + }, + { + "id": "DialogType", + "description": "Javascript dialog type.", + "type": "string", + "enum": [ + "alert", + "confirm", + "prompt", + "beforeunload" + ] + }, + { + "id": "AppManifestError", + "description": "Error while paring app manifest.", + "type": "object", + "properties": [ + { + "name": "message", + "description": "Error message.", + "type": "string" + }, + { + "name": "critical", + "description": "If criticial, this is a non-recoverable parse error.", + "type": "integer" + }, + { + "name": "line", + "description": "Error line.", + "type": "integer" + }, + { + "name": "column", + "description": "Error column.", + "type": "integer" + } + ] + }, + { + "id": "LayoutViewport", + "description": "Layout viewport position and dimensions.", + "type": "object", + "properties": [ + { + "name": "pageX", + "description": "Horizontal offset relative to the document (CSS pixels).", + "type": "integer" + }, + { + "name": "pageY", + "description": "Vertical offset relative to the document (CSS pixels).", + "type": "integer" + }, + { + "name": "clientWidth", + "description": "Width (CSS pixels), excludes scrollbar if present.", + "type": "integer" + }, + { + "name": "clientHeight", + "description": "Height (CSS pixels), excludes scrollbar if present.", + "type": "integer" + } + ] + }, + { + "id": "VisualViewport", + "description": "Visual viewport position, dimensions, and scale.", + "type": "object", + "properties": [ + { + "name": "offsetX", + "description": "Horizontal offset relative to the layout viewport (CSS pixels).", + "type": "number" + }, + { + "name": "offsetY", + "description": "Vertical offset relative to the layout viewport (CSS pixels).", + "type": "number" + }, + { + "name": "pageX", + "description": "Horizontal offset relative to the document (CSS pixels).", + "type": "number" + }, + { + "name": "pageY", + "description": "Vertical offset relative to the document (CSS pixels).", + "type": "number" + }, + { + "name": "clientWidth", + "description": "Width (CSS pixels), excludes scrollbar if present.", + "type": "number" + }, + { + "name": "clientHeight", + "description": "Height (CSS pixels), excludes scrollbar if present.", + "type": "number" + }, + { + "name": "scale", + "description": "Scale relative to the ideal viewport (size at width=device-width).", + "type": "number" + } + ] + }, + { + "id": "Viewport", + "description": "Viewport for capturing screenshot.", + "type": "object", + "properties": [ + { + "name": "x", + "description": "X offset in CSS pixels.", + "type": "number" + }, + { + "name": "y", + "description": "Y offset in CSS pixels", + "type": "number" + }, + { + "name": "width", + "description": "Rectangle width in CSS pixels", + "type": "number" + }, + { + "name": "height", + "description": "Rectangle height in CSS pixels", + "type": "number" + }, + { + "name": "scale", + "description": "Page scale factor.", + "type": "number" + } + ] + }, + { + "id": "FontFamilies", + "description": "Generic font families collection.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "standard", + "description": "The standard font-family.", + "optional": true, + "type": "string" + }, + { + "name": "fixed", + "description": "The fixed font-family.", + "optional": true, + "type": "string" + }, + { + "name": "serif", + "description": "The serif font-family.", + "optional": true, + "type": "string" + }, + { + "name": "sansSerif", + "description": "The sansSerif font-family.", + "optional": true, + "type": "string" + }, + { + "name": "cursive", + "description": "The cursive font-family.", + "optional": true, + "type": "string" + }, + { + "name": "fantasy", + "description": "The fantasy font-family.", + "optional": true, + "type": "string" + }, + { + "name": "pictograph", + "description": "The pictograph font-family.", + "optional": true, + "type": "string" + } + ] + }, + { + "id": "FontSizes", + "description": "Default font sizes.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "standard", + "description": "Default standard font size.", + "optional": true, + "type": "integer" + }, + { + "name": "fixed", + "description": "Default fixed font size.", + "optional": true, + "type": "integer" + } + ] + } + ], + "commands": [ + { + "name": "addScriptToEvaluateOnLoad", + "description": "Deprecated, please use addScriptToEvaluateOnNewDocument instead.", + "experimental": true, + "deprecated": true, + "parameters": [ + { + "name": "scriptSource", + "type": "string" + } + ], + "returns": [ + { + "name": "identifier", + "description": "Identifier of the added script.", + "$ref": "ScriptIdentifier" + } + ] + }, + { + "name": "addScriptToEvaluateOnNewDocument", + "description": "Evaluates given script in every frame upon creation (before loading frame's scripts).", + "parameters": [ + { + "name": "source", + "type": "string" + }, + { + "name": "worldName", + "description": "If specified, creates an isolated world with the given name and evaluates given script in it.\nThis world name will be used as the ExecutionContextDescription::name when the corresponding\nevent is emitted.", + "experimental": true, + "optional": true, + "type": "string" + } + ], + "returns": [ + { + "name": "identifier", + "description": "Identifier of the added script.", + "$ref": "ScriptIdentifier" + } + ] + }, + { + "name": "bringToFront", + "description": "Brings page to front (activates tab)." + }, + { + "name": "captureScreenshot", + "description": "Capture page screenshot.", + "parameters": [ + { + "name": "format", + "description": "Image compression format (defaults to png).", + "optional": true, + "type": "string", + "enum": [ + "jpeg", + "png" + ] + }, + { + "name": "quality", + "description": "Compression quality from range [0..100] (jpeg only).", + "optional": true, + "type": "integer" + }, + { + "name": "clip", + "description": "Capture the screenshot of a given region only.", + "optional": true, + "$ref": "Viewport" + }, + { + "name": "fromSurface", + "description": "Capture the screenshot from the surface, rather than the view. Defaults to true.", + "experimental": true, + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "data", + "description": "Base64-encoded image data.", + "type": "binary" + } + ] + }, + { + "name": "captureSnapshot", + "description": "Returns a snapshot of the page as a string. For MHTML format, the serialization includes\niframes, shadow DOM, external resources, and element-inline styles.", + "experimental": true, + "parameters": [ + { + "name": "format", + "description": "Format (defaults to mhtml).", + "optional": true, + "type": "string", + "enum": [ + "mhtml" + ] + } + ], + "returns": [ + { + "name": "data", + "description": "Serialized page data.", + "type": "string" + } + ] + }, + { + "name": "clearDeviceMetricsOverride", + "description": "Clears the overriden device metrics.", + "experimental": true, + "deprecated": true, + "redirect": "Emulation" + }, + { + "name": "clearDeviceOrientationOverride", + "description": "Clears the overridden Device Orientation.", + "experimental": true, + "deprecated": true, + "redirect": "DeviceOrientation" + }, + { + "name": "clearGeolocationOverride", + "description": "Clears the overriden Geolocation Position and Error.", + "deprecated": true, + "redirect": "Emulation" + }, + { + "name": "createIsolatedWorld", + "description": "Creates an isolated world for the given frame.", + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame in which the isolated world should be created.", + "$ref": "FrameId" + }, + { + "name": "worldName", + "description": "An optional name which is reported in the Execution Context.", + "optional": true, + "type": "string" + }, + { + "name": "grantUniveralAccess", + "description": "Whether or not universal access should be granted to the isolated world. This is a powerful\noption, use with caution.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "executionContextId", + "description": "Execution context of the isolated world.", + "$ref": "Runtime.ExecutionContextId" + } + ] + }, + { + "name": "deleteCookie", + "description": "Deletes browser cookie with given name, domain and path.", + "experimental": true, + "deprecated": true, + "redirect": "Network", + "parameters": [ + { + "name": "cookieName", + "description": "Name of the cookie to remove.", + "type": "string" + }, + { + "name": "url", + "description": "URL to match cooke domain and path.", + "type": "string" + } + ] + }, + { + "name": "disable", + "description": "Disables page domain notifications." + }, + { + "name": "enable", + "description": "Enables page domain notifications." + }, + { + "name": "getAppManifest", + "returns": [ + { + "name": "url", + "description": "Manifest location.", + "type": "string" + }, + { + "name": "errors", + "type": "array", + "items": { + "$ref": "AppManifestError" + } + }, + { + "name": "data", + "description": "Manifest content.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "getCookies", + "description": "Returns all browser cookies. Depending on the backend support, will return detailed cookie\ninformation in the `cookies` field.", + "experimental": true, + "deprecated": true, + "redirect": "Network", + "returns": [ + { + "name": "cookies", + "description": "Array of cookie objects.", + "type": "array", + "items": { + "$ref": "Network.Cookie" + } + } + ] + }, + { + "name": "getFrameTree", + "description": "Returns present frame tree structure.", + "returns": [ + { + "name": "frameTree", + "description": "Present frame tree structure.", + "$ref": "FrameTree" + } + ] + }, + { + "name": "getLayoutMetrics", + "description": "Returns metrics relating to the layouting of the page, such as viewport bounds/scale.", + "returns": [ + { + "name": "layoutViewport", + "description": "Metrics relating to the layout viewport.", + "$ref": "LayoutViewport" + }, + { + "name": "visualViewport", + "description": "Metrics relating to the visual viewport.", + "$ref": "VisualViewport" + }, + { + "name": "contentSize", + "description": "Size of scrollable area.", + "$ref": "DOM.Rect" + } + ] + }, + { + "name": "getNavigationHistory", + "description": "Returns navigation history for the current page.", + "returns": [ + { + "name": "currentIndex", + "description": "Index of the current navigation history entry.", + "type": "integer" + }, + { + "name": "entries", + "description": "Array of navigation history entries.", + "type": "array", + "items": { + "$ref": "NavigationEntry" + } + } + ] + }, + { + "name": "getResourceContent", + "description": "Returns content of the given resource.", + "experimental": true, + "parameters": [ + { + "name": "frameId", + "description": "Frame id to get resource for.", + "$ref": "FrameId" + }, + { + "name": "url", + "description": "URL of the resource to get content for.", + "type": "string" + } + ], + "returns": [ + { + "name": "content", + "description": "Resource content.", + "type": "string" + }, + { + "name": "base64Encoded", + "description": "True, if content was served as base64.", + "type": "boolean" + } + ] + }, + { + "name": "getResourceTree", + "description": "Returns present frame / resource tree structure.", + "experimental": true, + "returns": [ + { + "name": "frameTree", + "description": "Present frame / resource tree structure.", + "$ref": "FrameResourceTree" + } + ] + }, + { + "name": "handleJavaScriptDialog", + "description": "Accepts or dismisses a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload).", + "parameters": [ + { + "name": "accept", + "description": "Whether to accept or dismiss the dialog.", + "type": "boolean" + }, + { + "name": "promptText", + "description": "The text to enter into the dialog prompt before accepting. Used only if this is a prompt\ndialog.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "navigate", + "description": "Navigates current page to the given URL.", + "parameters": [ + { + "name": "url", + "description": "URL to navigate the page to.", + "type": "string" + }, + { + "name": "referrer", + "description": "Referrer URL.", + "optional": true, + "type": "string" + }, + { + "name": "transitionType", + "description": "Intended transition type.", + "optional": true, + "$ref": "TransitionType" + }, + { + "name": "frameId", + "description": "Frame id to navigate, if not specified navigates the top frame.", + "optional": true, + "$ref": "FrameId" + } + ], + "returns": [ + { + "name": "frameId", + "description": "Frame id that has navigated (or failed to navigate)", + "$ref": "FrameId" + }, + { + "name": "loaderId", + "description": "Loader identifier.", + "optional": true, + "$ref": "Network.LoaderId" + }, + { + "name": "errorText", + "description": "User friendly error message, present if and only if navigation has failed.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "navigateToHistoryEntry", + "description": "Navigates current page to the given history entry.", + "parameters": [ + { + "name": "entryId", + "description": "Unique id of the entry to navigate to.", + "type": "integer" + } + ] + }, + { + "name": "printToPDF", + "description": "Print page as PDF.", + "parameters": [ + { + "name": "landscape", + "description": "Paper orientation. Defaults to false.", + "optional": true, + "type": "boolean" + }, + { + "name": "displayHeaderFooter", + "description": "Display header and footer. Defaults to false.", + "optional": true, + "type": "boolean" + }, + { + "name": "printBackground", + "description": "Print background graphics. Defaults to false.", + "optional": true, + "type": "boolean" + }, + { + "name": "scale", + "description": "Scale of the webpage rendering. Defaults to 1.", + "optional": true, + "type": "number" + }, + { + "name": "paperWidth", + "description": "Paper width in inches. Defaults to 8.5 inches.", + "optional": true, + "type": "number" + }, + { + "name": "paperHeight", + "description": "Paper height in inches. Defaults to 11 inches.", + "optional": true, + "type": "number" + }, + { + "name": "marginTop", + "description": "Top margin in inches. Defaults to 1cm (~0.4 inches).", + "optional": true, + "type": "number" + }, + { + "name": "marginBottom", + "description": "Bottom margin in inches. Defaults to 1cm (~0.4 inches).", + "optional": true, + "type": "number" + }, + { + "name": "marginLeft", + "description": "Left margin in inches. Defaults to 1cm (~0.4 inches).", + "optional": true, + "type": "number" + }, + { + "name": "marginRight", + "description": "Right margin in inches. Defaults to 1cm (~0.4 inches).", + "optional": true, + "type": "number" + }, + { + "name": "pageRanges", + "description": "Paper ranges to print, e.g., '1-5, 8, 11-13'. Defaults to the empty string, which means\nprint all pages.", + "optional": true, + "type": "string" + }, + { + "name": "ignoreInvalidPageRanges", + "description": "Whether to silently ignore invalid but successfully parsed page ranges, such as '3-2'.\nDefaults to false.", + "optional": true, + "type": "boolean" + }, + { + "name": "headerTemplate", + "description": "HTML template for the print header. Should be valid HTML markup with following\nclasses used to inject printing values into them:\n- `date`: formatted print date\n- `title`: document title\n- `url`: document location\n- `pageNumber`: current page number\n- `totalPages`: total pages in the document\n\nFor example, `<span class=title></span>` would generate span containing the title.", + "optional": true, + "type": "string" + }, + { + "name": "footerTemplate", + "description": "HTML template for the print footer. Should use the same format as the `headerTemplate`.", + "optional": true, + "type": "string" + }, + { + "name": "preferCSSPageSize", + "description": "Whether or not to prefer page size as defined by css. Defaults to false,\nin which case the content will be scaled to fit the paper size.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "data", + "description": "Base64-encoded pdf data.", + "type": "binary" + } + ] + }, + { + "name": "reload", + "description": "Reloads given page optionally ignoring the cache.", + "parameters": [ + { + "name": "ignoreCache", + "description": "If true, browser cache is ignored (as if the user pressed Shift+refresh).", + "optional": true, + "type": "boolean" + }, + { + "name": "scriptToEvaluateOnLoad", + "description": "If set, the script will be injected into all frames of the inspected page after reload.\nArgument will be ignored if reloading dataURL origin.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "removeScriptToEvaluateOnLoad", + "description": "Deprecated, please use removeScriptToEvaluateOnNewDocument instead.", + "experimental": true, + "deprecated": true, + "parameters": [ + { + "name": "identifier", + "$ref": "ScriptIdentifier" + } + ] + }, + { + "name": "removeScriptToEvaluateOnNewDocument", + "description": "Removes given script from the list.", + "parameters": [ + { + "name": "identifier", + "$ref": "ScriptIdentifier" + } + ] + }, + { + "name": "requestAppBanner", + "experimental": true + }, + { + "name": "screencastFrameAck", + "description": "Acknowledges that a screencast frame has been received by the frontend.", + "experimental": true, + "parameters": [ + { + "name": "sessionId", + "description": "Frame number.", + "type": "integer" + } + ] + }, + { + "name": "searchInResource", + "description": "Searches for given string in resource content.", + "experimental": true, + "parameters": [ + { + "name": "frameId", + "description": "Frame id for resource to search in.", + "$ref": "FrameId" + }, + { + "name": "url", + "description": "URL of the resource to search in.", + "type": "string" + }, + { + "name": "query", + "description": "String to search for.", + "type": "string" + }, + { + "name": "caseSensitive", + "description": "If true, search is case sensitive.", + "optional": true, + "type": "boolean" + }, + { + "name": "isRegex", + "description": "If true, treats string parameter as regex.", + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "result", + "description": "List of search matches.", + "type": "array", + "items": { + "$ref": "Debugger.SearchMatch" + } + } + ] + }, + { + "name": "setAdBlockingEnabled", + "description": "Enable Chrome's experimental ad filter on all sites.", + "experimental": true, + "parameters": [ + { + "name": "enabled", + "description": "Whether to block ads.", + "type": "boolean" + } + ] + }, + { + "name": "setBypassCSP", + "description": "Enable page Content Security Policy by-passing.", + "experimental": true, + "parameters": [ + { + "name": "enabled", + "description": "Whether to bypass page CSP.", + "type": "boolean" + } + ] + }, + { + "name": "setDeviceMetricsOverride", + "description": "Overrides the values of device screen dimensions (window.screen.width, window.screen.height,\nwindow.innerWidth, window.innerHeight, and \"device-width\"/\"device-height\"-related CSS media\nquery results).", + "experimental": true, + "deprecated": true, + "redirect": "Emulation", + "parameters": [ + { + "name": "width", + "description": "Overriding width value in pixels (minimum 0, maximum 10000000). 0 disables the override.", + "type": "integer" + }, + { + "name": "height", + "description": "Overriding height value in pixels (minimum 0, maximum 10000000). 0 disables the override.", + "type": "integer" + }, + { + "name": "deviceScaleFactor", + "description": "Overriding device scale factor value. 0 disables the override.", + "type": "number" + }, + { + "name": "mobile", + "description": "Whether to emulate mobile device. This includes viewport meta tag, overlay scrollbars, text\nautosizing and more.", + "type": "boolean" + }, + { + "name": "scale", + "description": "Scale to apply to resulting view image.", + "optional": true, + "type": "number" + }, + { + "name": "screenWidth", + "description": "Overriding screen width value in pixels (minimum 0, maximum 10000000).", + "optional": true, + "type": "integer" + }, + { + "name": "screenHeight", + "description": "Overriding screen height value in pixels (minimum 0, maximum 10000000).", + "optional": true, + "type": "integer" + }, + { + "name": "positionX", + "description": "Overriding view X position on screen in pixels (minimum 0, maximum 10000000).", + "optional": true, + "type": "integer" + }, + { + "name": "positionY", + "description": "Overriding view Y position on screen in pixels (minimum 0, maximum 10000000).", + "optional": true, + "type": "integer" + }, + { + "name": "dontSetVisibleSize", + "description": "Do not set visible view size, rely upon explicit setVisibleSize call.", + "optional": true, + "type": "boolean" + }, + { + "name": "screenOrientation", + "description": "Screen orientation override.", + "optional": true, + "$ref": "Emulation.ScreenOrientation" + }, + { + "name": "viewport", + "description": "The viewport dimensions and scale. If not set, the override is cleared.", + "optional": true, + "$ref": "Viewport" + } + ] + }, + { + "name": "setDeviceOrientationOverride", + "description": "Overrides the Device Orientation.", + "experimental": true, + "deprecated": true, + "redirect": "DeviceOrientation", + "parameters": [ + { + "name": "alpha", + "description": "Mock alpha", + "type": "number" + }, + { + "name": "beta", + "description": "Mock beta", + "type": "number" + }, + { + "name": "gamma", + "description": "Mock gamma", + "type": "number" + } + ] + }, + { + "name": "setFontFamilies", + "description": "Set generic font families.", + "experimental": true, + "parameters": [ + { + "name": "fontFamilies", + "description": "Specifies font families to set. If a font family is not specified, it won't be changed.", + "$ref": "FontFamilies" + } + ] + }, + { + "name": "setFontSizes", + "description": "Set default font sizes.", + "experimental": true, + "parameters": [ + { + "name": "fontSizes", + "description": "Specifies font sizes to set. If a font size is not specified, it won't be changed.", + "$ref": "FontSizes" + } + ] + }, + { + "name": "setDocumentContent", + "description": "Sets given markup as the document's HTML.", + "parameters": [ + { + "name": "frameId", + "description": "Frame id to set HTML for.", + "$ref": "FrameId" + }, + { + "name": "html", + "description": "HTML content to set.", + "type": "string" + } + ] + }, + { + "name": "setDownloadBehavior", + "description": "Set the behavior when downloading a file.", + "experimental": true, + "parameters": [ + { + "name": "behavior", + "description": "Whether to allow all or deny all download requests, or use default Chrome behavior if\navailable (otherwise deny).", + "type": "string", + "enum": [ + "deny", + "allow", + "default" + ] + }, + { + "name": "downloadPath", + "description": "The default path to save downloaded files to. This is requred if behavior is set to 'allow'", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "setGeolocationOverride", + "description": "Overrides the Geolocation Position or Error. Omitting any of the parameters emulates position\nunavailable.", + "deprecated": true, + "redirect": "Emulation", + "parameters": [ + { + "name": "latitude", + "description": "Mock latitude", + "optional": true, + "type": "number" + }, + { + "name": "longitude", + "description": "Mock longitude", + "optional": true, + "type": "number" + }, + { + "name": "accuracy", + "description": "Mock accuracy", + "optional": true, + "type": "number" + } + ] + }, + { + "name": "setLifecycleEventsEnabled", + "description": "Controls whether page will emit lifecycle events.", + "experimental": true, + "parameters": [ + { + "name": "enabled", + "description": "If true, starts emitting lifecycle events.", + "type": "boolean" + } + ] + }, + { + "name": "setTouchEmulationEnabled", + "description": "Toggles mouse event-based touch event emulation.", + "experimental": true, + "deprecated": true, + "redirect": "Emulation", + "parameters": [ + { + "name": "enabled", + "description": "Whether the touch event emulation should be enabled.", + "type": "boolean" + }, + { + "name": "configuration", + "description": "Touch/gesture events configuration. Default: current platform.", + "optional": true, + "type": "string", + "enum": [ + "mobile", + "desktop" + ] + } + ] + }, + { + "name": "startScreencast", + "description": "Starts sending each frame using the `screencastFrame` event.", + "experimental": true, + "parameters": [ + { + "name": "format", + "description": "Image compression format.", + "optional": true, + "type": "string", + "enum": [ + "jpeg", + "png" + ] + }, + { + "name": "quality", + "description": "Compression quality from range [0..100].", + "optional": true, + "type": "integer" + }, + { + "name": "maxWidth", + "description": "Maximum screenshot width.", + "optional": true, + "type": "integer" + }, + { + "name": "maxHeight", + "description": "Maximum screenshot height.", + "optional": true, + "type": "integer" + }, + { + "name": "everyNthFrame", + "description": "Send every n-th frame.", + "optional": true, + "type": "integer" + } + ] + }, + { + "name": "stopLoading", + "description": "Force the page stop all navigations and pending resource fetches." + }, + { + "name": "crash", + "description": "Crashes renderer on the IO thread, generates minidumps.", + "experimental": true + }, + { + "name": "close", + "description": "Tries to close page, running its beforeunload hooks, if any.", + "experimental": true + }, + { + "name": "setWebLifecycleState", + "description": "Tries to update the web lifecycle state of the page.\nIt will transition the page to the given state according to:\nhttps://github.com/WICG/web-lifecycle/", + "experimental": true, + "parameters": [ + { + "name": "state", + "description": "Target lifecycle state", + "type": "string", + "enum": [ + "frozen", + "active" + ] + } + ] + }, + { + "name": "stopScreencast", + "description": "Stops sending each frame in the `screencastFrame`.", + "experimental": true + }, + { + "name": "setProduceCompilationCache", + "description": "Forces compilation cache to be generated for every subresource script.", + "experimental": true, + "parameters": [ + { + "name": "enabled", + "type": "boolean" + } + ] + }, + { + "name": "addCompilationCache", + "description": "Seeds compilation cache for given url. Compilation cache does not survive\ncross-process navigation.", + "experimental": true, + "parameters": [ + { + "name": "url", + "type": "string" + }, + { + "name": "data", + "description": "Base64-encoded data", + "type": "binary" + } + ] + }, + { + "name": "clearCompilationCache", + "description": "Clears seeded compilation cache.", + "experimental": true + }, + { + "name": "generateTestReport", + "description": "Generates a report for testing.", + "experimental": true, + "parameters": [ + { + "name": "message", + "description": "Message to be displayed in the report.", + "type": "string" + }, + { + "name": "group", + "description": "Specifies the endpoint group to deliver the report to.", + "optional": true, + "type": "string" + } + ] + } + ], + "events": [ + { + "name": "domContentEventFired", + "parameters": [ + { + "name": "timestamp", + "$ref": "Network.MonotonicTime" + } + ] + }, + { + "name": "frameAttached", + "description": "Fired when frame has been attached to its parent.", + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame that has been attached.", + "$ref": "FrameId" + }, + { + "name": "parentFrameId", + "description": "Parent frame identifier.", + "$ref": "FrameId" + }, + { + "name": "stack", + "description": "JavaScript stack trace of when frame was attached, only set if frame initiated from script.", + "optional": true, + "$ref": "Runtime.StackTrace" + } + ] + }, + { + "name": "frameClearedScheduledNavigation", + "description": "Fired when frame no longer has a scheduled navigation.", + "experimental": true, + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame that has cleared its scheduled navigation.", + "$ref": "FrameId" + } + ] + }, + { + "name": "frameDetached", + "description": "Fired when frame has been detached from its parent.", + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame that has been detached.", + "$ref": "FrameId" + } + ] + }, + { + "name": "frameNavigated", + "description": "Fired once navigation of the frame has completed. Frame is now associated with the new loader.", + "parameters": [ + { + "name": "frame", + "description": "Frame object.", + "$ref": "Frame" + } + ] + }, + { + "name": "frameResized", + "experimental": true + }, + { + "name": "frameScheduledNavigation", + "description": "Fired when frame schedules a potential navigation.", + "experimental": true, + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame that has scheduled a navigation.", + "$ref": "FrameId" + }, + { + "name": "delay", + "description": "Delay (in seconds) until the navigation is scheduled to begin. The navigation is not\nguaranteed to start.", + "type": "number" + }, + { + "name": "reason", + "description": "The reason for the navigation.", + "type": "string", + "enum": [ + "formSubmissionGet", + "formSubmissionPost", + "httpHeaderRefresh", + "scriptInitiated", + "metaTagRefresh", + "pageBlockInterstitial", + "reload" + ] + }, + { + "name": "url", + "description": "The destination URL for the scheduled navigation.", + "type": "string" + } + ] + }, + { + "name": "frameStartedLoading", + "description": "Fired when frame has started loading.", + "experimental": true, + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame that has started loading.", + "$ref": "FrameId" + } + ] + }, + { + "name": "frameStoppedLoading", + "description": "Fired when frame has stopped loading.", + "experimental": true, + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame that has stopped loading.", + "$ref": "FrameId" + } + ] + }, + { + "name": "interstitialHidden", + "description": "Fired when interstitial page was hidden" + }, + { + "name": "interstitialShown", + "description": "Fired when interstitial page was shown" + }, + { + "name": "javascriptDialogClosed", + "description": "Fired when a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload) has been\nclosed.", + "parameters": [ + { + "name": "result", + "description": "Whether dialog was confirmed.", + "type": "boolean" + }, + { + "name": "userInput", + "description": "User input in case of prompt.", + "type": "string" + } + ] + }, + { + "name": "javascriptDialogOpening", + "description": "Fired when a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload) is about to\nopen.", + "parameters": [ + { + "name": "url", + "description": "Frame url.", + "type": "string" + }, + { + "name": "message", + "description": "Message that will be displayed by the dialog.", + "type": "string" + }, + { + "name": "type", + "description": "Dialog type.", + "$ref": "DialogType" + }, + { + "name": "hasBrowserHandler", + "description": "True iff browser is capable showing or acting on the given dialog. When browser has no\ndialog handler for given target, calling alert while Page domain is engaged will stall\nthe page execution. Execution can be resumed via calling Page.handleJavaScriptDialog.", + "type": "boolean" + }, + { + "name": "defaultPrompt", + "description": "Default dialog prompt.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "lifecycleEvent", + "description": "Fired for top level page lifecycle events such as navigation, load, paint, etc.", + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame.", + "$ref": "FrameId" + }, + { + "name": "loaderId", + "description": "Loader identifier. Empty string if the request is fetched from worker.", + "$ref": "Network.LoaderId" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "timestamp", + "$ref": "Network.MonotonicTime" + } + ] + }, + { + "name": "loadEventFired", + "parameters": [ + { + "name": "timestamp", + "$ref": "Network.MonotonicTime" + } + ] + }, + { + "name": "navigatedWithinDocument", + "description": "Fired when same-document navigation happens, e.g. due to history API usage or anchor navigation.", + "experimental": true, + "parameters": [ + { + "name": "frameId", + "description": "Id of the frame.", + "$ref": "FrameId" + }, + { + "name": "url", + "description": "Frame's new url.", + "type": "string" + } + ] + }, + { + "name": "screencastFrame", + "description": "Compressed image data requested by the `startScreencast`.", + "experimental": true, + "parameters": [ + { + "name": "data", + "description": "Base64-encoded compressed image.", + "type": "binary" + }, + { + "name": "metadata", + "description": "Screencast frame metadata.", + "$ref": "ScreencastFrameMetadata" + }, + { + "name": "sessionId", + "description": "Frame number.", + "type": "integer" + } + ] + }, + { + "name": "screencastVisibilityChanged", + "description": "Fired when the page with currently enabled screencast was shown or hidden `.", + "experimental": true, + "parameters": [ + { + "name": "visible", + "description": "True if the page is visible.", + "type": "boolean" + } + ] + }, + { + "name": "windowOpen", + "description": "Fired when a new window is going to be opened, via window.open(), link click, form submission,\netc.", + "parameters": [ + { + "name": "url", + "description": "The URL for the new window.", + "type": "string" + }, + { + "name": "windowName", + "description": "Window name.", + "type": "string" + }, + { + "name": "windowFeatures", + "description": "An array of enabled window features.", + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "userGesture", + "description": "Whether or not it was triggered by user gesture.", + "type": "boolean" + } + ] + }, + { + "name": "compilationCacheProduced", + "description": "Issued for every compilation cache generated. Is only available\nif Page.setGenerateCompilationCache is enabled.", + "experimental": true, + "parameters": [ + { + "name": "url", + "type": "string" + }, + { + "name": "data", + "description": "Base64-encoded data", + "type": "binary" + } + ] + } + ] + }, + { + "domain": "Performance", + "types": [ + { + "id": "Metric", + "description": "Run-time execution metric.", + "type": "object", + "properties": [ + { + "name": "name", + "description": "Metric name.", + "type": "string" + }, + { + "name": "value", + "description": "Metric value.", + "type": "number" + } + ] + } + ], + "commands": [ + { + "name": "disable", + "description": "Disable collecting and reporting metrics." + }, + { + "name": "enable", + "description": "Enable collecting and reporting metrics." + }, + { + "name": "setTimeDomain", + "description": "Sets time domain to use for collecting and reporting duration metrics.\nNote that this must be called before enabling metrics collection. Calling\nthis method while metrics collection is enabled returns an error.", + "experimental": true, + "parameters": [ + { + "name": "timeDomain", + "description": "Time domain", + "type": "string", + "enum": [ + "timeTicks", + "threadTicks" + ] + } + ] + }, + { + "name": "getMetrics", + "description": "Retrieve current values of run-time metrics.", + "returns": [ + { + "name": "metrics", + "description": "Current values for run-time metrics.", + "type": "array", + "items": { + "$ref": "Metric" + } + } + ] + } + ], + "events": [ + { + "name": "metrics", + "description": "Current values of the metrics.", + "parameters": [ + { + "name": "metrics", + "description": "Current values of the metrics.", + "type": "array", + "items": { + "$ref": "Metric" + } + }, + { + "name": "title", + "description": "Timestamp title.", + "type": "string" + } + ] + } + ] + }, + { + "domain": "Security", + "description": "Security", + "types": [ + { + "id": "CertificateId", + "description": "An internal certificate ID value.", + "type": "integer" + }, + { + "id": "MixedContentType", + "description": "A description of mixed content (HTTP resources on HTTPS pages), as defined by\nhttps://www.w3.org/TR/mixed-content/#categories", + "type": "string", + "enum": [ + "blockable", + "optionally-blockable", + "none" + ] + }, + { + "id": "SecurityState", + "description": "The security level of a page or resource.", + "type": "string", + "enum": [ + "unknown", + "neutral", + "insecure", + "secure", + "info" + ] + }, + { + "id": "SecurityStateExplanation", + "description": "An explanation of an factor contributing to the security state.", + "type": "object", + "properties": [ + { + "name": "securityState", + "description": "Security state representing the severity of the factor being explained.", + "$ref": "SecurityState" + }, + { + "name": "title", + "description": "Title describing the type of factor.", + "type": "string" + }, + { + "name": "summary", + "description": "Short phrase describing the type of factor.", + "type": "string" + }, + { + "name": "description", + "description": "Full text explanation of the factor.", + "type": "string" + }, + { + "name": "mixedContentType", + "description": "The type of mixed content described by the explanation.", + "$ref": "MixedContentType" + }, + { + "name": "certificate", + "description": "Page certificate.", + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "recommendations", + "description": "Recommendations to fix any issues.", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "id": "InsecureContentStatus", + "description": "Information about insecure content on the page.", + "type": "object", + "properties": [ + { + "name": "ranMixedContent", + "description": "True if the page was loaded over HTTPS and ran mixed (HTTP) content such as scripts.", + "type": "boolean" + }, + { + "name": "displayedMixedContent", + "description": "True if the page was loaded over HTTPS and displayed mixed (HTTP) content such as images.", + "type": "boolean" + }, + { + "name": "containedMixedForm", + "description": "True if the page was loaded over HTTPS and contained a form targeting an insecure url.", + "type": "boolean" + }, + { + "name": "ranContentWithCertErrors", + "description": "True if the page was loaded over HTTPS without certificate errors, and ran content such as\nscripts that were loaded with certificate errors.", + "type": "boolean" + }, + { + "name": "displayedContentWithCertErrors", + "description": "True if the page was loaded over HTTPS without certificate errors, and displayed content\nsuch as images that were loaded with certificate errors.", + "type": "boolean" + }, + { + "name": "ranInsecureContentStyle", + "description": "Security state representing a page that ran insecure content.", + "$ref": "SecurityState" + }, + { + "name": "displayedInsecureContentStyle", + "description": "Security state representing a page that displayed insecure content.", + "$ref": "SecurityState" + } + ] + }, + { + "id": "CertificateErrorAction", + "description": "The action to take when a certificate error occurs. continue will continue processing the\nrequest and cancel will cancel the request.", + "type": "string", + "enum": [ + "continue", + "cancel" + ] + } + ], + "commands": [ + { + "name": "disable", + "description": "Disables tracking security state changes." + }, + { + "name": "enable", + "description": "Enables tracking security state changes." + }, + { + "name": "setIgnoreCertificateErrors", + "description": "Enable/disable whether all certificate errors should be ignored.", + "experimental": true, + "parameters": [ + { + "name": "ignore", + "description": "If true, all certificate errors will be ignored.", + "type": "boolean" + } + ] + }, + { + "name": "handleCertificateError", + "description": "Handles a certificate error that fired a certificateError event.", + "deprecated": true, + "parameters": [ + { + "name": "eventId", + "description": "The ID of the event.", + "type": "integer" + }, + { + "name": "action", + "description": "The action to take on the certificate error.", + "$ref": "CertificateErrorAction" + } + ] + }, + { + "name": "setOverrideCertificateErrors", + "description": "Enable/disable overriding certificate errors. If enabled, all certificate error events need to\nbe handled by the DevTools client and should be answered with `handleCertificateError` commands.", + "deprecated": true, + "parameters": [ + { + "name": "override", + "description": "If true, certificate errors will be overridden.", + "type": "boolean" + } + ] + } + ], + "events": [ + { + "name": "certificateError", + "description": "There is a certificate error. If overriding certificate errors is enabled, then it should be\nhandled with the `handleCertificateError` command. Note: this event does not fire if the\ncertificate error has been allowed internally. Only one client per target should override\ncertificate errors at the same time.", + "deprecated": true, + "parameters": [ + { + "name": "eventId", + "description": "The ID of the event.", + "type": "integer" + }, + { + "name": "errorType", + "description": "The type of the error.", + "type": "string" + }, + { + "name": "requestURL", + "description": "The url that was requested.", + "type": "string" + } + ] + }, + { + "name": "securityStateChanged", + "description": "The security state of the page changed.", + "parameters": [ + { + "name": "securityState", + "description": "Security state.", + "$ref": "SecurityState" + }, + { + "name": "schemeIsCryptographic", + "description": "True if the page was loaded over cryptographic transport such as HTTPS.", + "type": "boolean" + }, + { + "name": "explanations", + "description": "List of explanations for the security state. If the overall security state is `insecure` or\n`warning`, at least one corresponding explanation should be included.", + "type": "array", + "items": { + "$ref": "SecurityStateExplanation" + } + }, + { + "name": "insecureContentStatus", + "description": "Information about insecure content on the page.", + "$ref": "InsecureContentStatus" + }, + { + "name": "summary", + "description": "Overrides user-visible description of the state.", + "optional": true, + "type": "string" + } + ] + } + ] + }, + { + "domain": "ServiceWorker", + "experimental": true, + "types": [ + { + "id": "ServiceWorkerRegistration", + "description": "ServiceWorker registration.", + "type": "object", + "properties": [ + { + "name": "registrationId", + "type": "string" + }, + { + "name": "scopeURL", + "type": "string" + }, + { + "name": "isDeleted", + "type": "boolean" + } + ] + }, + { + "id": "ServiceWorkerVersionRunningStatus", + "type": "string", + "enum": [ + "stopped", + "starting", + "running", + "stopping" + ] + }, + { + "id": "ServiceWorkerVersionStatus", + "type": "string", + "enum": [ + "new", + "installing", + "installed", + "activating", + "activated", + "redundant" + ] + }, + { + "id": "ServiceWorkerVersion", + "description": "ServiceWorker version.", + "type": "object", + "properties": [ + { + "name": "versionId", + "type": "string" + }, + { + "name": "registrationId", + "type": "string" + }, + { + "name": "scriptURL", + "type": "string" + }, + { + "name": "runningStatus", + "$ref": "ServiceWorkerVersionRunningStatus" + }, + { + "name": "status", + "$ref": "ServiceWorkerVersionStatus" + }, + { + "name": "scriptLastModified", + "description": "The Last-Modified header value of the main script.", + "optional": true, + "type": "number" + }, + { + "name": "scriptResponseTime", + "description": "The time at which the response headers of the main script were received from the server.\nFor cached script it is the last time the cache entry was validated.", + "optional": true, + "type": "number" + }, + { + "name": "controlledClients", + "optional": true, + "type": "array", + "items": { + "$ref": "Target.TargetID" + } + }, + { + "name": "targetId", + "optional": true, + "$ref": "Target.TargetID" + } + ] + }, + { + "id": "ServiceWorkerErrorMessage", + "description": "ServiceWorker error message.", + "type": "object", + "properties": [ + { + "name": "errorMessage", + "type": "string" + }, + { + "name": "registrationId", + "type": "string" + }, + { + "name": "versionId", + "type": "string" + }, + { + "name": "sourceURL", + "type": "string" + }, + { + "name": "lineNumber", + "type": "integer" + }, + { + "name": "columnNumber", + "type": "integer" + } + ] + } + ], + "commands": [ + { + "name": "deliverPushMessage", + "parameters": [ + { + "name": "origin", + "type": "string" + }, + { + "name": "registrationId", + "type": "string" + }, + { + "name": "data", + "type": "string" + } + ] + }, + { + "name": "disable" + }, + { + "name": "dispatchSyncEvent", + "parameters": [ + { + "name": "origin", + "type": "string" + }, + { + "name": "registrationId", + "type": "string" + }, + { + "name": "tag", + "type": "string" + }, + { + "name": "lastChance", + "type": "boolean" + } + ] + }, + { + "name": "enable" + }, + { + "name": "inspectWorker", + "parameters": [ + { + "name": "versionId", + "type": "string" + } + ] + }, + { + "name": "setForceUpdateOnPageLoad", + "parameters": [ + { + "name": "forceUpdateOnPageLoad", + "type": "boolean" + } + ] + }, + { + "name": "skipWaiting", + "parameters": [ + { + "name": "scopeURL", + "type": "string" + } + ] + }, + { + "name": "startWorker", + "parameters": [ + { + "name": "scopeURL", + "type": "string" + } + ] + }, + { + "name": "stopAllWorkers" + }, + { + "name": "stopWorker", + "parameters": [ + { + "name": "versionId", + "type": "string" + } + ] + }, + { + "name": "unregister", + "parameters": [ + { + "name": "scopeURL", + "type": "string" + } + ] + }, + { + "name": "updateRegistration", + "parameters": [ + { + "name": "scopeURL", + "type": "string" + } + ] + } + ], + "events": [ + { + "name": "workerErrorReported", + "parameters": [ + { + "name": "errorMessage", + "$ref": "ServiceWorkerErrorMessage" + } + ] + }, + { + "name": "workerRegistrationUpdated", + "parameters": [ + { + "name": "registrations", + "type": "array", + "items": { + "$ref": "ServiceWorkerRegistration" + } + } + ] + }, + { + "name": "workerVersionUpdated", + "parameters": [ + { + "name": "versions", + "type": "array", + "items": { + "$ref": "ServiceWorkerVersion" + } + } + ] + } + ] + }, + { + "domain": "Storage", + "experimental": true, + "types": [ + { + "id": "StorageType", + "description": "Enum of possible storage types.", + "type": "string", + "enum": [ + "appcache", + "cookies", + "file_systems", + "indexeddb", + "local_storage", + "shader_cache", + "websql", + "service_workers", + "cache_storage", + "all", + "other" + ] + }, + { + "id": "UsageForType", + "description": "Usage for a storage type.", + "type": "object", + "properties": [ + { + "name": "storageType", + "description": "Name of storage type.", + "$ref": "StorageType" + }, + { + "name": "usage", + "description": "Storage usage (bytes).", + "type": "number" + } + ] + } + ], + "commands": [ + { + "name": "clearDataForOrigin", + "description": "Clears storage for origin.", + "parameters": [ + { + "name": "origin", + "description": "Security origin.", + "type": "string" + }, + { + "name": "storageTypes", + "description": "Comma separated origin names.", + "type": "string" + } + ] + }, + { + "name": "getUsageAndQuota", + "description": "Returns usage and quota in bytes.", + "parameters": [ + { + "name": "origin", + "description": "Security origin.", + "type": "string" + } + ], + "returns": [ + { + "name": "usage", + "description": "Storage usage (bytes).", + "type": "number" + }, + { + "name": "quota", + "description": "Storage quota (bytes).", + "type": "number" + }, + { + "name": "usageBreakdown", + "description": "Storage usage per type (bytes).", + "type": "array", + "items": { + "$ref": "UsageForType" + } + } + ] + }, + { + "name": "trackCacheStorageForOrigin", + "description": "Registers origin to be notified when an update occurs to its cache storage list.", + "parameters": [ + { + "name": "origin", + "description": "Security origin.", + "type": "string" + } + ] + }, + { + "name": "trackIndexedDBForOrigin", + "description": "Registers origin to be notified when an update occurs to its IndexedDB.", + "parameters": [ + { + "name": "origin", + "description": "Security origin.", + "type": "string" + } + ] + }, + { + "name": "untrackCacheStorageForOrigin", + "description": "Unregisters origin from receiving notifications for cache storage.", + "parameters": [ + { + "name": "origin", + "description": "Security origin.", + "type": "string" + } + ] + }, + { + "name": "untrackIndexedDBForOrigin", + "description": "Unregisters origin from receiving notifications for IndexedDB.", + "parameters": [ + { + "name": "origin", + "description": "Security origin.", + "type": "string" + } + ] + } + ], + "events": [ + { + "name": "cacheStorageContentUpdated", + "description": "A cache's contents have been modified.", + "parameters": [ + { + "name": "origin", + "description": "Origin to update.", + "type": "string" + }, + { + "name": "cacheName", + "description": "Name of cache in origin.", + "type": "string" + } + ] + }, + { + "name": "cacheStorageListUpdated", + "description": "A cache has been added/deleted.", + "parameters": [ + { + "name": "origin", + "description": "Origin to update.", + "type": "string" + } + ] + }, + { + "name": "indexedDBContentUpdated", + "description": "The origin's IndexedDB object store has been modified.", + "parameters": [ + { + "name": "origin", + "description": "Origin to update.", + "type": "string" + }, + { + "name": "databaseName", + "description": "Database to update.", + "type": "string" + }, + { + "name": "objectStoreName", + "description": "ObjectStore to update.", + "type": "string" + } + ] + }, + { + "name": "indexedDBListUpdated", + "description": "The origin's IndexedDB database list has been modified.", + "parameters": [ + { + "name": "origin", + "description": "Origin to update.", + "type": "string" + } + ] + } + ] + }, + { + "domain": "SystemInfo", + "description": "The SystemInfo domain defines methods and events for querying low-level system information.", + "experimental": true, + "types": [ + { + "id": "GPUDevice", + "description": "Describes a single graphics processor (GPU).", + "type": "object", + "properties": [ + { + "name": "vendorId", + "description": "PCI ID of the GPU vendor, if available; 0 otherwise.", + "type": "number" + }, + { + "name": "deviceId", + "description": "PCI ID of the GPU device, if available; 0 otherwise.", + "type": "number" + }, + { + "name": "vendorString", + "description": "String description of the GPU vendor, if the PCI ID is not available.", + "type": "string" + }, + { + "name": "deviceString", + "description": "String description of the GPU device, if the PCI ID is not available.", + "type": "string" + } + ] + }, + { + "id": "GPUInfo", + "description": "Provides information about the GPU(s) on the system.", + "type": "object", + "properties": [ + { + "name": "devices", + "description": "The graphics devices on the system. Element 0 is the primary GPU.", + "type": "array", + "items": { + "$ref": "GPUDevice" + } + }, + { + "name": "auxAttributes", + "description": "An optional dictionary of additional GPU related attributes.", + "optional": true, + "type": "object" + }, + { + "name": "featureStatus", + "description": "An optional dictionary of graphics features and their status.", + "optional": true, + "type": "object" + }, + { + "name": "driverBugWorkarounds", + "description": "An optional array of GPU driver bug workarounds.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "id": "ProcessInfo", + "description": "Represents process info.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "Specifies process type.", + "type": "string" + }, + { + "name": "id", + "description": "Specifies process id.", + "type": "integer" + }, + { + "name": "cpuTime", + "description": "Specifies cumulative CPU usage in seconds across all threads of the\nprocess since the process start.", + "type": "number" + } + ] + } + ], + "commands": [ + { + "name": "getInfo", + "description": "Returns information about the system.", + "returns": [ + { + "name": "gpu", + "description": "Information about the GPUs on the system.", + "$ref": "GPUInfo" + }, + { + "name": "modelName", + "description": "A platform-dependent description of the model of the machine. On Mac OS, this is, for\nexample, 'MacBookPro'. Will be the empty string if not supported.", + "type": "string" + }, + { + "name": "modelVersion", + "description": "A platform-dependent description of the version of the machine. On Mac OS, this is, for\nexample, '10.1'. Will be the empty string if not supported.", + "type": "string" + }, + { + "name": "commandLine", + "description": "The command line string used to launch the browser. Will be the empty string if not\nsupported.", + "type": "string" + } + ] + }, + { + "name": "getProcessInfo", + "description": "Returns information about all running processes.", + "returns": [ + { + "name": "processInfo", + "description": "An array of process info blocks.", + "type": "array", + "items": { + "$ref": "ProcessInfo" + } + } + ] + } + ] + }, + { + "domain": "Target", + "description": "Supports additional targets discovery and allows to attach to them.", + "types": [ + { + "id": "TargetID", + "type": "string" + }, + { + "id": "SessionID", + "description": "Unique identifier of attached debugging session.", + "type": "string" + }, + { + "id": "BrowserContextID", + "experimental": true, + "type": "string" + }, + { + "id": "TargetInfo", + "type": "object", + "properties": [ + { + "name": "targetId", + "$ref": "TargetID" + }, + { + "name": "type", + "type": "string" + }, + { + "name": "title", + "type": "string" + }, + { + "name": "url", + "type": "string" + }, + { + "name": "attached", + "description": "Whether the target has an attached client.", + "type": "boolean" + }, + { + "name": "openerId", + "description": "Opener target Id", + "optional": true, + "$ref": "TargetID" + }, + { + "name": "browserContextId", + "experimental": true, + "optional": true, + "$ref": "BrowserContextID" + } + ] + }, + { + "id": "RemoteLocation", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "host", + "type": "string" + }, + { + "name": "port", + "type": "integer" + } + ] + } + ], + "commands": [ + { + "name": "activateTarget", + "description": "Activates (focuses) the target.", + "parameters": [ + { + "name": "targetId", + "$ref": "TargetID" + } + ] + }, + { + "name": "attachToTarget", + "description": "Attaches to the target with given id.", + "parameters": [ + { + "name": "targetId", + "$ref": "TargetID" + }, + { + "name": "flatten", + "description": "Enables \"flat\" access to the session via specifying sessionId attribute in the commands.", + "experimental": true, + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "sessionId", + "description": "Id assigned to the session.", + "$ref": "SessionID" + } + ] + }, + { + "name": "attachToBrowserTarget", + "description": "Attaches to the browser target, only uses flat sessionId mode.", + "experimental": true, + "returns": [ + { + "name": "sessionId", + "description": "Id assigned to the session.", + "$ref": "SessionID" + } + ] + }, + { + "name": "closeTarget", + "description": "Closes the target. If the target is a page that gets closed too.", + "parameters": [ + { + "name": "targetId", + "$ref": "TargetID" + } + ], + "returns": [ + { + "name": "success", + "type": "boolean" + } + ] + }, + { + "name": "exposeDevToolsProtocol", + "description": "Inject object to the target's main frame that provides a communication\nchannel with browser target.\n\nInjected object will be available as `window[bindingName]`.\n\nThe object has the follwing API:\n- `binding.send(json)` - a method to send messages over the remote debugging protocol\n- `binding.onmessage = json => handleMessage(json)` - a callback that will be called for the protocol notifications and command responses.", + "experimental": true, + "parameters": [ + { + "name": "targetId", + "$ref": "TargetID" + }, + { + "name": "bindingName", + "description": "Binding name, 'cdp' if not specified.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "createBrowserContext", + "description": "Creates a new empty BrowserContext. Similar to an incognito profile but you can have more than\none.", + "experimental": true, + "returns": [ + { + "name": "browserContextId", + "description": "The id of the context created.", + "$ref": "BrowserContextID" + } + ] + }, + { + "name": "getBrowserContexts", + "description": "Returns all browser contexts created with `Target.createBrowserContext` method.", + "experimental": true, + "returns": [ + { + "name": "browserContextIds", + "description": "An array of browser context ids.", + "type": "array", + "items": { + "$ref": "BrowserContextID" + } + } + ] + }, + { + "name": "createTarget", + "description": "Creates a new page.", + "parameters": [ + { + "name": "url", + "description": "The initial URL the page will be navigated to.", + "type": "string" + }, + { + "name": "width", + "description": "Frame width in DIP (headless chrome only).", + "optional": true, + "type": "integer" + }, + { + "name": "height", + "description": "Frame height in DIP (headless chrome only).", + "optional": true, + "type": "integer" + }, + { + "name": "browserContextId", + "description": "The browser context to create the page in.", + "optional": true, + "$ref": "BrowserContextID" + }, + { + "name": "enableBeginFrameControl", + "description": "Whether BeginFrames for this target will be controlled via DevTools (headless chrome only,\nnot supported on MacOS yet, false by default).", + "experimental": true, + "optional": true, + "type": "boolean" + } + ], + "returns": [ + { + "name": "targetId", + "description": "The id of the page opened.", + "$ref": "TargetID" + } + ] + }, + { + "name": "detachFromTarget", + "description": "Detaches session with given id.", + "parameters": [ + { + "name": "sessionId", + "description": "Session to detach.", + "optional": true, + "$ref": "SessionID" + }, + { + "name": "targetId", + "description": "Deprecated.", + "deprecated": true, + "optional": true, + "$ref": "TargetID" + } + ] + }, + { + "name": "disposeBrowserContext", + "description": "Deletes a BrowserContext. All the belonging pages will be closed without calling their\nbeforeunload hooks.", + "experimental": true, + "parameters": [ + { + "name": "browserContextId", + "$ref": "BrowserContextID" + } + ] + }, + { + "name": "getTargetInfo", + "description": "Returns information about a target.", + "experimental": true, + "parameters": [ + { + "name": "targetId", + "optional": true, + "$ref": "TargetID" + } + ], + "returns": [ + { + "name": "targetInfo", + "$ref": "TargetInfo" + } + ] + }, + { + "name": "getTargets", + "description": "Retrieves a list of available targets.", + "returns": [ + { + "name": "targetInfos", + "description": "The list of targets.", + "type": "array", + "items": { + "$ref": "TargetInfo" + } + } + ] + }, + { + "name": "sendMessageToTarget", + "description": "Sends protocol message over session with given id.", + "parameters": [ + { + "name": "message", + "type": "string" + }, + { + "name": "sessionId", + "description": "Identifier of the session.", + "optional": true, + "$ref": "SessionID" + }, + { + "name": "targetId", + "description": "Deprecated.", + "deprecated": true, + "optional": true, + "$ref": "TargetID" + } + ] + }, + { + "name": "setAutoAttach", + "description": "Controls whether to automatically attach to new targets which are considered to be related to\nthis one. When turned on, attaches to all existing related targets as well. When turned off,\nautomatically detaches from all currently attached targets.", + "experimental": true, + "parameters": [ + { + "name": "autoAttach", + "description": "Whether to auto-attach to related targets.", + "type": "boolean" + }, + { + "name": "waitForDebuggerOnStart", + "description": "Whether to pause new targets when attaching to them. Use `Runtime.runIfWaitingForDebugger`\nto run paused targets.", + "type": "boolean" + }, + { + "name": "flatten", + "description": "Enables \"flat\" access to the session via specifying sessionId attribute in the commands.", + "experimental": true, + "optional": true, + "type": "boolean" + } + ] + }, + { + "name": "setDiscoverTargets", + "description": "Controls whether to discover available targets and notify via\n`targetCreated/targetInfoChanged/targetDestroyed` events.", + "parameters": [ + { + "name": "discover", + "description": "Whether to discover available targets.", + "type": "boolean" + } + ] + }, + { + "name": "setRemoteLocations", + "description": "Enables target discovery for the specified locations, when `setDiscoverTargets` was set to\n`true`.", + "experimental": true, + "parameters": [ + { + "name": "locations", + "description": "List of remote locations.", + "type": "array", + "items": { + "$ref": "RemoteLocation" + } + } + ] + } + ], + "events": [ + { + "name": "attachedToTarget", + "description": "Issued when attached to target because of auto-attach or `attachToTarget` command.", + "experimental": true, + "parameters": [ + { + "name": "sessionId", + "description": "Identifier assigned to the session used to send/receive messages.", + "$ref": "SessionID" + }, + { + "name": "targetInfo", + "$ref": "TargetInfo" + }, + { + "name": "waitingForDebugger", + "type": "boolean" + } + ] + }, + { + "name": "detachedFromTarget", + "description": "Issued when detached from target for any reason (including `detachFromTarget` command). Can be\nissued multiple times per target if multiple sessions have been attached to it.", + "experimental": true, + "parameters": [ + { + "name": "sessionId", + "description": "Detached session identifier.", + "$ref": "SessionID" + }, + { + "name": "targetId", + "description": "Deprecated.", + "deprecated": true, + "optional": true, + "$ref": "TargetID" + } + ] + }, + { + "name": "receivedMessageFromTarget", + "description": "Notifies about a new protocol message received from the session (as reported in\n`attachedToTarget` event).", + "parameters": [ + { + "name": "sessionId", + "description": "Identifier of a session which sends a message.", + "$ref": "SessionID" + }, + { + "name": "message", + "type": "string" + }, + { + "name": "targetId", + "description": "Deprecated.", + "deprecated": true, + "optional": true, + "$ref": "TargetID" + } + ] + }, + { + "name": "targetCreated", + "description": "Issued when a possible inspection target is created.", + "parameters": [ + { + "name": "targetInfo", + "$ref": "TargetInfo" + } + ] + }, + { + "name": "targetDestroyed", + "description": "Issued when a target is destroyed.", + "parameters": [ + { + "name": "targetId", + "$ref": "TargetID" + } + ] + }, + { + "name": "targetCrashed", + "description": "Issued when a target has crashed.", + "parameters": [ + { + "name": "targetId", + "$ref": "TargetID" + }, + { + "name": "status", + "description": "Termination status type.", + "type": "string" + }, + { + "name": "errorCode", + "description": "Termination error code.", + "type": "integer" + } + ] + }, + { + "name": "targetInfoChanged", + "description": "Issued when some information about a target has changed. This only happens between\n`targetCreated` and `targetDestroyed`.", + "parameters": [ + { + "name": "targetInfo", + "$ref": "TargetInfo" + } + ] + } + ] + }, + { + "domain": "Tethering", + "description": "The Tethering domain defines methods and events for browser port binding.", + "experimental": true, + "commands": [ + { + "name": "bind", + "description": "Request browser port binding.", + "parameters": [ + { + "name": "port", + "description": "Port number to bind.", + "type": "integer" + } + ] + }, + { + "name": "unbind", + "description": "Request browser port unbinding.", + "parameters": [ + { + "name": "port", + "description": "Port number to unbind.", + "type": "integer" + } + ] + } + ], + "events": [ + { + "name": "accepted", + "description": "Informs that port was successfully bound and got a specified connection id.", + "parameters": [ + { + "name": "port", + "description": "Port number that was successfully bound.", + "type": "integer" + }, + { + "name": "connectionId", + "description": "Connection id to be used.", + "type": "string" + } + ] + } + ] + }, + { + "domain": "Tracing", + "experimental": true, + "dependencies": [ + "IO" + ], + "types": [ + { + "id": "MemoryDumpConfig", + "description": "Configuration for memory dump. Used only when \"memory-infra\" category is enabled.", + "type": "object" + }, + { + "id": "TraceConfig", + "type": "object", + "properties": [ + { + "name": "recordMode", + "description": "Controls how the trace buffer stores data.", + "optional": true, + "type": "string", + "enum": [ + "recordUntilFull", + "recordContinuously", + "recordAsMuchAsPossible", + "echoToConsole" + ] + }, + { + "name": "enableSampling", + "description": "Turns on JavaScript stack sampling.", + "optional": true, + "type": "boolean" + }, + { + "name": "enableSystrace", + "description": "Turns on system tracing.", + "optional": true, + "type": "boolean" + }, + { + "name": "enableArgumentFilter", + "description": "Turns on argument filter.", + "optional": true, + "type": "boolean" + }, + { + "name": "includedCategories", + "description": "Included category filters.", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "excludedCategories", + "description": "Excluded category filters.", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "syntheticDelays", + "description": "Configuration to synthesize the delays in tracing.", + "optional": true, + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "memoryDumpConfig", + "description": "Configuration for memory dump triggers. Used only when \"memory-infra\" category is enabled.", + "optional": true, + "$ref": "MemoryDumpConfig" + } + ] + }, + { + "id": "StreamCompression", + "description": "Compression type to use for traces returned via streams.", + "type": "string", + "enum": [ + "none", + "gzip" + ] + } + ], + "commands": [ + { + "name": "end", + "description": "Stop trace events collection." + }, + { + "name": "getCategories", + "description": "Gets supported tracing categories.", + "returns": [ + { + "name": "categories", + "description": "A list of supported tracing categories.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "recordClockSyncMarker", + "description": "Record a clock sync marker in the trace.", + "parameters": [ + { + "name": "syncId", + "description": "The ID of this clock sync marker", + "type": "string" + } + ] + }, + { + "name": "requestMemoryDump", + "description": "Request a global memory dump.", + "returns": [ + { + "name": "dumpGuid", + "description": "GUID of the resulting global memory dump.", + "type": "string" + }, + { + "name": "success", + "description": "True iff the global memory dump succeeded.", + "type": "boolean" + } + ] + }, + { + "name": "start", + "description": "Start trace events collection.", + "parameters": [ + { + "name": "categories", + "description": "Category/tag filter", + "deprecated": true, + "optional": true, + "type": "string" + }, + { + "name": "options", + "description": "Tracing options", + "deprecated": true, + "optional": true, + "type": "string" + }, + { + "name": "bufferUsageReportingInterval", + "description": "If set, the agent will issue bufferUsage events at this interval, specified in milliseconds", + "optional": true, + "type": "number" + }, + { + "name": "transferMode", + "description": "Whether to report trace events as series of dataCollected events or to save trace to a\nstream (defaults to `ReportEvents`).", + "optional": true, + "type": "string", + "enum": [ + "ReportEvents", + "ReturnAsStream" + ] + }, + { + "name": "streamCompression", + "description": "Compression format to use. This only applies when using `ReturnAsStream`\ntransfer mode (defaults to `none`)", + "optional": true, + "$ref": "StreamCompression" + }, + { + "name": "traceConfig", + "optional": true, + "$ref": "TraceConfig" + } + ] + } + ], + "events": [ + { + "name": "bufferUsage", + "parameters": [ + { + "name": "percentFull", + "description": "A number in range [0..1] that indicates the used size of event buffer as a fraction of its\ntotal size.", + "optional": true, + "type": "number" + }, + { + "name": "eventCount", + "description": "An approximate number of events in the trace log.", + "optional": true, + "type": "number" + }, + { + "name": "value", + "description": "A number in range [0..1] that indicates the used size of event buffer as a fraction of its\ntotal size.", + "optional": true, + "type": "number" + } + ] + }, + { + "name": "dataCollected", + "description": "Contains an bucket of collected trace events. When tracing is stopped collected events will be\nsend as a sequence of dataCollected events followed by tracingComplete event.", + "parameters": [ + { + "name": "value", + "type": "array", + "items": { + "type": "object" + } + } + ] + }, + { + "name": "tracingComplete", + "description": "Signals that tracing is stopped and there is no trace buffers pending flush, all data were\ndelivered via dataCollected events.", + "parameters": [ + { + "name": "stream", + "description": "A handle of the stream that holds resulting trace data.", + "optional": true, + "$ref": "IO.StreamHandle" + }, + { + "name": "streamCompression", + "description": "Compression format of returned stream.", + "optional": true, + "$ref": "StreamCompression" + } + ] + } + ] + }, + { + "domain": "Testing", + "description": "Testing domain is a dumping ground for the capabilities requires for browser or app testing that do not fit other\ndomains.", + "experimental": true, + "dependencies": [ + "Page" + ], + "commands": [ + { + "name": "generateTestReport", + "description": "Generates a report for testing.", + "parameters": [ + { + "name": "message", + "description": "Message to be displayed in the report.", + "type": "string" + }, + { + "name": "group", + "description": "Specifies the endpoint group to deliver the report to.", + "optional": true, + "type": "string" + } + ] + } + ] + }, + { + "domain": "Fetch", + "description": "A domain for letting clients substitute browser's network layer with client code.", + "experimental": true, + "dependencies": [ + "Network", + "IO", + "Page" + ], + "types": [ + { + "id": "RequestId", + "description": "Unique request identifier.", + "type": "string" + }, + { + "id": "RequestStage", + "description": "Stages of the request to handle. Request will intercept before the request is\nsent. Response will intercept after the response is received (but before response\nbody is received.", + "experimental": true, + "type": "string", + "enum": [ + "Request", + "Response" + ] + }, + { + "id": "RequestPattern", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "urlPattern", + "description": "Wildcards ('*' -> zero or more, '?' -> exactly one) are allowed. Escape character is\nbackslash. Omitting is equivalent to \"*\".", + "optional": true, + "type": "string" + }, + { + "name": "resourceType", + "description": "If set, only requests for matching resource types will be intercepted.", + "optional": true, + "$ref": "Network.ResourceType" + }, + { + "name": "requestStage", + "description": "Stage at wich to begin intercepting requests. Default is Request.", + "optional": true, + "$ref": "RequestStage" + } + ] + }, + { + "id": "HeaderEntry", + "description": "Response HTTP header entry", + "type": "object", + "properties": [ + { + "name": "name", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + }, + { + "id": "AuthChallenge", + "description": "Authorization challenge for HTTP status code 401 or 407.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "source", + "description": "Source of the authentication challenge.", + "optional": true, + "type": "string", + "enum": [ + "Server", + "Proxy" + ] + }, + { + "name": "origin", + "description": "Origin of the challenger.", + "type": "string" + }, + { + "name": "scheme", + "description": "The authentication scheme used, such as basic or digest", + "type": "string" + }, + { + "name": "realm", + "description": "The realm of the challenge. May be empty.", + "type": "string" + } + ] + }, + { + "id": "AuthChallengeResponse", + "description": "Response to an AuthChallenge.", + "experimental": true, + "type": "object", + "properties": [ + { + "name": "response", + "description": "The decision on what to do in response to the authorization challenge. Default means\ndeferring to the default behavior of the net stack, which will likely either the Cancel\nauthentication or display a popup dialog box.", + "type": "string", + "enum": [ + "Default", + "CancelAuth", + "ProvideCredentials" + ] + }, + { + "name": "username", + "description": "The username to provide, possibly empty. Should only be set if response is\nProvideCredentials.", + "optional": true, + "type": "string" + }, + { + "name": "password", + "description": "The password to provide, possibly empty. Should only be set if response is\nProvideCredentials.", + "optional": true, + "type": "string" + } + ] + } + ], + "commands": [ + { + "name": "disable", + "description": "Disables the fetch domain." + }, + { + "name": "enable", + "description": "Enables issuing of requestPaused events. A request will be paused until client\ncalls one of failRequest, fulfillRequest or continueRequest/continueWithAuth.", + "parameters": [ + { + "name": "patterns", + "description": "If specified, only requests matching any of these patterns will produce\nfetchRequested event and will be paused until clients response. If not set,\nall requests will be affected.", + "optional": true, + "type": "array", + "items": { + "$ref": "RequestPattern" + } + }, + { + "name": "handleAuthRequests", + "description": "If true, authRequired events will be issued and requests will be paused\nexpecting a call to continueWithAuth.", + "optional": true, + "type": "boolean" + } + ] + }, + { + "name": "failRequest", + "description": "Causes the request to fail with specified reason.", + "parameters": [ + { + "name": "requestId", + "description": "An id the client received in requestPaused event.", + "$ref": "RequestId" + }, + { + "name": "errorReason", + "description": "Causes the request to fail with the given reason.", + "$ref": "Network.ErrorReason" + } + ] + }, + { + "name": "fulfillRequest", + "description": "Provides response to the request.", + "parameters": [ + { + "name": "requestId", + "description": "An id the client received in requestPaused event.", + "$ref": "RequestId" + }, + { + "name": "responseCode", + "description": "An HTTP response code.", + "type": "integer" + }, + { + "name": "responseHeaders", + "description": "Response headers.", + "type": "array", + "items": { + "$ref": "HeaderEntry" + } + }, + { + "name": "body", + "description": "A response body.", + "optional": true, + "type": "binary" + }, + { + "name": "responsePhrase", + "description": "A textual representation of responseCode.\nIf absent, a standard phrase mathcing responseCode is used.", + "optional": true, + "type": "string" + } + ] + }, + { + "name": "continueRequest", + "description": "Continues the request, optionally modifying some of its parameters.", + "parameters": [ + { + "name": "requestId", + "description": "An id the client received in requestPaused event.", + "$ref": "RequestId" + }, + { + "name": "url", + "description": "If set, the request url will be modified in a way that's not observable by page.", + "optional": true, + "type": "string" + }, + { + "name": "method", + "description": "If set, the request method is overridden.", + "optional": true, + "type": "string" + }, + { + "name": "postData", + "description": "If set, overrides the post data in the request.", + "optional": true, + "type": "string" + }, + { + "name": "headers", + "description": "If set, overrides the request headrts.", + "optional": true, + "type": "array", + "items": { + "$ref": "HeaderEntry" + } + } + ] + }, + { + "name": "continueWithAuth", + "description": "Continues a request supplying authChallengeResponse following authRequired event.", + "parameters": [ + { + "name": "requestId", + "description": "An id the client received in authRequired event.", + "$ref": "RequestId" + }, + { + "name": "authChallengeResponse", + "description": "Response to with an authChallenge.", + "$ref": "AuthChallengeResponse" + } + ] + }, + { + "name": "getResponseBody", + "description": "Causes the body of the response to be received from the server and\nreturned as a single string. May only be issued for a request that\nis paused in the Response stage and is mutually exclusive with\ntakeResponseBodyForInterceptionAsStream. Calling other methods that\naffect the request or disabling fetch domain before body is received\nresults in an undefined behavior.", + "parameters": [ + { + "name": "requestId", + "description": "Identifier for the intercepted request to get body for.", + "$ref": "RequestId" + } + ], + "returns": [ + { + "name": "body", + "description": "Response body.", + "type": "string" + }, + { + "name": "base64Encoded", + "description": "True, if content was sent as base64.", + "type": "boolean" + } + ] + }, + { + "name": "takeResponseBodyAsStream", + "description": "Returns a handle to the stream representing the response body.\nThe request must be paused in the HeadersReceived stage.\nNote that after this command the request can't be continued\nas is -- client either needs to cancel it or to provide the\nresponse body.\nThe stream only supports sequential read, IO.read will fail if the position\nis specified.\nThis method is mutually exclusive with getResponseBody.\nCalling other methods that affect the request or disabling fetch\ndomain before body is received results in an undefined behavior.", + "parameters": [ + { + "name": "requestId", + "$ref": "RequestId" + } + ], + "returns": [ + { + "name": "stream", + "$ref": "IO.StreamHandle" + } + ] + } + ], + "events": [ + { + "name": "requestPaused", + "description": "Issued when the domain is enabled and the request URL matches the\nspecified filter. The request is paused until the client responds\nwith one of continueRequest, failRequest or fulfillRequest.\nThe stage of the request can be determined by presence of responseErrorReason\nand responseStatusCode -- the request is at the response stage if either\nof these fields is present and in the request stage otherwise.", + "parameters": [ + { + "name": "requestId", + "description": "Each request the page makes will have a unique id.", + "$ref": "RequestId" + }, + { + "name": "request", + "description": "The details of the request.", + "$ref": "Network.Request" + }, + { + "name": "frameId", + "description": "The id of the frame that initiated the request.", + "$ref": "Page.FrameId" + }, + { + "name": "resourceType", + "description": "How the requested resource will be used.", + "$ref": "Network.ResourceType" + }, + { + "name": "responseErrorReason", + "description": "Response error if intercepted at response stage.", + "optional": true, + "$ref": "Network.ErrorReason" + }, + { + "name": "responseStatusCode", + "description": "Response code if intercepted at response stage.", + "optional": true, + "type": "integer" + }, + { + "name": "responseHeaders", + "description": "Response headers if intercepted at the response stage.", + "optional": true, + "type": "array", + "items": { + "$ref": "HeaderEntry" + } + } + ] + }, + { + "name": "authRequired", + "description": "Issued when the domain is enabled with handleAuthRequests set to true.\nThe request is paused until client responds with continueWithAuth.", + "parameters": [ + { + "name": "requestId", + "description": "Each request the page makes will have a unique id.", + "$ref": "RequestId" + }, + { + "name": "request", + "description": "The details of the request.", + "$ref": "Network.Request" + }, + { + "name": "frameId", + "description": "The id of the frame that initiated the request.", + "$ref": "Page.FrameId" + }, + { + "name": "resourceType", + "description": "How the requested resource will be used.", + "$ref": "Network.ResourceType" + }, + { + "name": "authChallenge", + "description": "Details of the Authorization Challenge encountered.\nIf this is set, client should respond with continueRequest that\ncontains AuthChallengeResponse.", + "$ref": "AuthChallenge" + } + ] + } + ] + }, + { + "commands": [ + { + "name": "clearMessages", + "description": "Does nothing." + }, + { + "name": "disable", + "description": "Disables console domain, prevents further console messages from being reported to the client." + }, + { + "name": "enable", + "description": "Enables console domain, sends the messages collected so far to the client by means of the\n`messageAdded` notification." + } + ], + "description": "This domain is deprecated - use Runtime or Log instead.", + "deprecated": true, + "domain": "Console", + "dependencies": [ + "Runtime" + ], + "events": [ + { + "name": "messageAdded", + "parameters": [ + { + "$ref": "ConsoleMessage", + "name": "message", + "description": "Console message that has been added." + } + ], + "description": "Issued when new console message is added." + } + ], + "types": [ + { + "properties": [ + { + "enum": [ + "xml", + "javascript", + "network", + "console-api", + "storage", + "appcache", + "rendering", + "security", + "other", + "deprecation", + "worker" + ], + "type": "string", + "name": "source", + "description": "Message source." + }, + { + "enum": [ + "log", + "warning", + "error", + "debug", + "info" + ], + "type": "string", + "name": "level", + "description": "Message severity." + }, + { + "type": "string", + "name": "text", + "description": "Message text." + }, + { + "type": "string", + "optional": true, + "name": "url", + "description": "URL of the message origin." + }, + { + "type": "integer", + "optional": true, + "name": "line", + "description": "Line number in the resource that generated this message (1-based)." + }, + { + "type": "integer", + "optional": true, + "name": "column", + "description": "Column number in the resource that generated this message (1-based)." + } + ], + "type": "object", + "id": "ConsoleMessage", + "description": "Console message." + } + ] + }, + { + "commands": [ + { + "name": "continueToLocation", + "parameters": [ + { + "$ref": "Location", + "name": "location", + "description": "Location to continue to." + }, + { + "type": "string", + "enum": [ + "any", + "current" + ], + "optional": true, + "name": "targetCallFrames" + } + ], + "description": "Continues execution until specific location is reached." + }, + { + "name": "disable", + "description": "Disables debugger for given page." + }, + { + "returns": [ + { + "$ref": "Runtime.UniqueDebuggerId", + "name": "debuggerId", + "experimental": true, + "description": "Unique identifier of the debugger." + } + ], + "name": "enable", + "description": "Enables debugger for the given page. Clients should not assume that the debugging has been\nenabled until the result for this command is received." + }, + { + "returns": [ + { + "$ref": "Runtime.RemoteObject", + "name": "result", + "description": "Object wrapper for the evaluation result." + }, + { + "$ref": "Runtime.ExceptionDetails", + "optional": true, + "name": "exceptionDetails", + "description": "Exception details." + } + ], + "name": "evaluateOnCallFrame", + "parameters": [ + { + "$ref": "CallFrameId", + "name": "callFrameId", + "description": "Call frame identifier to evaluate on." + }, + { + "type": "string", + "name": "expression", + "description": "Expression to evaluate." + }, + { + "type": "string", + "optional": true, + "name": "objectGroup", + "description": "String object group name to put result into (allows rapid releasing resulting object handles\nusing `releaseObjectGroup`)." + }, + { + "type": "boolean", + "optional": true, + "name": "includeCommandLineAPI", + "description": "Specifies whether command line API should be available to the evaluated expression, defaults\nto false." + }, + { + "type": "boolean", + "optional": true, + "name": "silent", + "description": "In silent mode exceptions thrown during evaluation are not reported and do not pause\nexecution. Overrides `setPauseOnException` state." + }, + { + "type": "boolean", + "optional": true, + "name": "returnByValue", + "description": "Whether the result is expected to be a JSON object that should be sent by value." + }, + { + "type": "boolean", + "optional": true, + "name": "generatePreview", + "experimental": true, + "description": "Whether preview should be generated for the result." + }, + { + "type": "boolean", + "optional": true, + "name": "throwOnSideEffect", + "description": "Whether to throw an exception if side effect cannot be ruled out during evaluation." + }, + { + "$ref": "Runtime.TimeDelta", + "optional": true, + "name": "timeout", + "experimental": true, + "description": "Terminate execution after timing out (number of milliseconds)." + } + ], + "description": "Evaluates expression on a given call frame." + }, + { + "returns": [ + { + "items": { + "$ref": "BreakLocation" + }, + "type": "array", + "name": "locations", + "description": "List of the possible breakpoint locations." + } + ], + "name": "getPossibleBreakpoints", + "parameters": [ + { + "$ref": "Location", + "name": "start", + "description": "Start of range to search possible breakpoint locations in." + }, + { + "$ref": "Location", + "optional": true, + "name": "end", + "description": "End of range to search possible breakpoint locations in (excluding). When not specified, end\nof scripts is used as end of range." + }, + { + "type": "boolean", + "optional": true, + "name": "restrictToFunction", + "description": "Only consider locations which are in the same (non-nested) function as start." + } + ], + "description": "Returns possible locations for breakpoint. scriptId in start and end range locations should be\nthe same." + }, + { + "returns": [ + { + "type": "string", + "name": "scriptSource", + "description": "Script source." + } + ], + "name": "getScriptSource", + "parameters": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "Id of the script to get source for." + } + ], + "description": "Returns source for the script with given id." + }, + { + "returns": [ + { + "name": "stackTrace", + "$ref": "Runtime.StackTrace" + } + ], + "parameters": [ + { + "name": "stackTraceId", + "$ref": "Runtime.StackTraceId" + } + ], + "name": "getStackTrace", + "experimental": true, + "description": "Returns stack trace with given `stackTraceId`." + }, + { + "name": "pause", + "description": "Stops on the next JavaScript statement." + }, + { + "parameters": [ + { + "$ref": "Runtime.StackTraceId", + "name": "parentStackTraceId", + "description": "Debugger will pause when async call with given stack trace is started." + } + ], + "name": "pauseOnAsyncCall", + "experimental": true + }, + { + "name": "removeBreakpoint", + "parameters": [ + { + "name": "breakpointId", + "$ref": "BreakpointId" + } + ], + "description": "Removes JavaScript breakpoint." + }, + { + "returns": [ + { + "items": { + "$ref": "CallFrame" + }, + "type": "array", + "name": "callFrames", + "description": "New stack trace." + }, + { + "$ref": "Runtime.StackTrace", + "optional": true, + "name": "asyncStackTrace", + "description": "Async stack trace, if any." + }, + { + "$ref": "Runtime.StackTraceId", + "optional": true, + "name": "asyncStackTraceId", + "experimental": true, + "description": "Async stack trace, if any." + } + ], + "name": "restartFrame", + "parameters": [ + { + "$ref": "CallFrameId", + "name": "callFrameId", + "description": "Call frame identifier to evaluate on." + } + ], + "description": "Restarts particular call frame from the beginning." + }, + { + "name": "resume", + "description": "Resumes JavaScript execution." + }, + { + "name": "scheduleStepIntoAsync", + "experimental": true, + "description": "This method is deprecated - use Debugger.stepInto with breakOnAsyncCall and\nDebugger.pauseOnAsyncTask instead. Steps into next scheduled async task if any is scheduled\nbefore next pause. Returns success when async task is actually scheduled, returns error if no\ntask were scheduled or another scheduleStepIntoAsync was called." + }, + { + "returns": [ + { + "items": { + "$ref": "SearchMatch" + }, + "type": "array", + "name": "result", + "description": "List of search matches." + } + ], + "name": "searchInContent", + "parameters": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "Id of the script to search in." + }, + { + "type": "string", + "name": "query", + "description": "String to search for." + }, + { + "type": "boolean", + "optional": true, + "name": "caseSensitive", + "description": "If true, search is case sensitive." + }, + { + "type": "boolean", + "optional": true, + "name": "isRegex", + "description": "If true, treats string parameter as regex." + } + ], + "description": "Searches for given string in script content." + }, + { + "name": "setAsyncCallStackDepth", + "parameters": [ + { + "type": "integer", + "name": "maxDepth", + "description": "Maximum depth of async call stacks. Setting to `0` will effectively disable collecting async\ncall stacks (default)." + } + ], + "description": "Enables or disables async call stacks tracking." + }, + { + "parameters": [ + { + "items": { + "type": "string" + }, + "type": "array", + "name": "patterns", + "description": "Array of regexps that will be used to check script url for blackbox state." + } + ], + "name": "setBlackboxPatterns", + "experimental": true, + "description": "Replace previous blackbox patterns with passed ones. Forces backend to skip stepping/pausing in\nscripts with url matching one of the patterns. VM will try to leave blackboxed script by\nperforming 'step in' several times, finally resorting to 'step out' if unsuccessful." + }, + { + "parameters": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "Id of the script." + }, + { + "items": { + "$ref": "ScriptPosition" + }, + "type": "array", + "name": "positions" + } + ], + "name": "setBlackboxedRanges", + "experimental": true, + "description": "Makes backend skip steps in the script in blackboxed ranges. VM will try leave blacklisted\nscripts by performing 'step in' several times, finally resorting to 'step out' if unsuccessful.\nPositions array contains positions where blackbox state is changed. First interval isn't\nblackboxed. Array should be sorted." + }, + { + "returns": [ + { + "$ref": "BreakpointId", + "name": "breakpointId", + "description": "Id of the created breakpoint for further reference." + }, + { + "$ref": "Location", + "name": "actualLocation", + "description": "Location this breakpoint resolved into." + } + ], + "name": "setBreakpoint", + "parameters": [ + { + "$ref": "Location", + "name": "location", + "description": "Location to set breakpoint in." + }, + { + "type": "string", + "optional": true, + "name": "condition", + "description": "Expression to use as a breakpoint condition. When specified, debugger will only stop on the\nbreakpoint if this expression evaluates to true." + } + ], + "description": "Sets JavaScript breakpoint at a given location." + }, + { + "returns": [ + { + "$ref": "BreakpointId", + "name": "breakpointId", + "description": "Id of the created breakpoint for further reference." + }, + { + "items": { + "$ref": "Location" + }, + "type": "array", + "name": "locations", + "description": "List of the locations this breakpoint resolved into upon addition." + } + ], + "name": "setBreakpointByUrl", + "parameters": [ + { + "type": "integer", + "name": "lineNumber", + "description": "Line number to set breakpoint at." + }, + { + "type": "string", + "optional": true, + "name": "url", + "description": "URL of the resources to set breakpoint on." + }, + { + "type": "string", + "optional": true, + "name": "urlRegex", + "description": "Regex pattern for the URLs of the resources to set breakpoints on. Either `url` or\n`urlRegex` must be specified." + }, + { + "type": "string", + "optional": true, + "name": "scriptHash", + "description": "Script hash of the resources to set breakpoint on." + }, + { + "type": "integer", + "optional": true, + "name": "columnNumber", + "description": "Offset in the line to set breakpoint at." + }, + { + "type": "string", + "optional": true, + "name": "condition", + "description": "Expression to use as a breakpoint condition. When specified, debugger will only stop on the\nbreakpoint if this expression evaluates to true." + } + ], + "description": "Sets JavaScript breakpoint at given location specified either by URL or URL regex. Once this\ncommand is issued, all existing parsed scripts will have breakpoints resolved and returned in\n`locations` property. Further matching script parsing will result in subsequent\n`breakpointResolved` events issued. This logical breakpoint will survive page reloads." + }, + { + "returns": [ + { + "$ref": "BreakpointId", + "name": "breakpointId", + "description": "Id of the created breakpoint for further reference." + } + ], + "parameters": [ + { + "$ref": "Runtime.RemoteObjectId", + "name": "objectId", + "description": "Function object id." + }, + { + "type": "string", + "optional": true, + "name": "condition", + "description": "Expression to use as a breakpoint condition. When specified, debugger will\nstop on the breakpoint if this expression evaluates to true." + } + ], + "name": "setBreakpointOnFunctionCall", + "experimental": true, + "description": "Sets JavaScript breakpoint before each call to the given function.\nIf another function was created from the same source as a given one,\ncalling it will also trigger the breakpoint." + }, + { + "name": "setBreakpointsActive", + "parameters": [ + { + "type": "boolean", + "name": "active", + "description": "New value for breakpoints active state." + } + ], + "description": "Activates / deactivates all breakpoints on the page." + }, + { + "name": "setPauseOnExceptions", + "parameters": [ + { + "enum": [ + "none", + "uncaught", + "all" + ], + "type": "string", + "name": "state", + "description": "Pause on exceptions mode." + } + ], + "description": "Defines pause on exceptions state. Can be set to stop on all exceptions, uncaught exceptions or\nno exceptions. Initial pause on exceptions state is `none`." + }, + { + "parameters": [ + { + "$ref": "Runtime.CallArgument", + "name": "newValue", + "description": "New return value." + } + ], + "name": "setReturnValue", + "experimental": true, + "description": "Changes return value in top frame. Available only at return break position." + }, + { + "returns": [ + { + "type": "array", + "items": { + "$ref": "CallFrame" + }, + "optional": true, + "name": "callFrames", + "description": "New stack trace in case editing has happened while VM was stopped." + }, + { + "type": "boolean", + "optional": true, + "name": "stackChanged", + "description": "Whether current call stack was modified after applying the changes." + }, + { + "$ref": "Runtime.StackTrace", + "optional": true, + "name": "asyncStackTrace", + "description": "Async stack trace, if any." + }, + { + "$ref": "Runtime.StackTraceId", + "optional": true, + "name": "asyncStackTraceId", + "experimental": true, + "description": "Async stack trace, if any." + }, + { + "$ref": "Runtime.ExceptionDetails", + "optional": true, + "name": "exceptionDetails", + "description": "Exception details if any." + } + ], + "name": "setScriptSource", + "parameters": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "Id of the script to edit." + }, + { + "type": "string", + "name": "scriptSource", + "description": "New content of the script." + }, + { + "type": "boolean", + "optional": true, + "name": "dryRun", + "description": "If true the change will not actually be applied. Dry run may be used to get result\ndescription without actually modifying the code." + } + ], + "description": "Edits JavaScript source live." + }, + { + "name": "setSkipAllPauses", + "parameters": [ + { + "type": "boolean", + "name": "skip", + "description": "New value for skip pauses state." + } + ], + "description": "Makes page not interrupt on any pauses (breakpoint, exception, dom exception etc)." + }, + { + "name": "setVariableValue", + "parameters": [ + { + "type": "integer", + "name": "scopeNumber", + "description": "0-based number of scope as was listed in scope chain. Only 'local', 'closure' and 'catch'\nscope types are allowed. Other scopes could be manipulated manually." + }, + { + "type": "string", + "name": "variableName", + "description": "Variable name." + }, + { + "$ref": "Runtime.CallArgument", + "name": "newValue", + "description": "New variable value." + }, + { + "$ref": "CallFrameId", + "name": "callFrameId", + "description": "Id of callframe that holds variable." + } + ], + "description": "Changes value of variable in a callframe. Object-based scopes are not supported and must be\nmutated manually." + }, + { + "name": "stepInto", + "parameters": [ + { + "type": "boolean", + "optional": true, + "name": "breakOnAsyncCall", + "experimental": true, + "description": "Debugger will issue additional Debugger.paused notification if any async task is scheduled\nbefore next pause." + } + ], + "description": "Steps into the function call." + }, + { + "name": "stepOut", + "description": "Steps out of the function call." + }, + { + "name": "stepOver", + "description": "Steps over the statement." + } + ], + "description": "Debugger domain exposes JavaScript debugging capabilities. It allows setting and removing\nbreakpoints, stepping through execution, exploring stack traces, etc.", + "domain": "Debugger", + "dependencies": [ + "Runtime" + ], + "events": [ + { + "name": "breakpointResolved", + "parameters": [ + { + "$ref": "BreakpointId", + "name": "breakpointId", + "description": "Breakpoint unique identifier." + }, + { + "$ref": "Location", + "name": "location", + "description": "Actual breakpoint location." + } + ], + "description": "Fired when breakpoint is resolved to an actual script and location." + }, + { + "name": "paused", + "parameters": [ + { + "items": { + "$ref": "CallFrame" + }, + "type": "array", + "name": "callFrames", + "description": "Call stack the virtual machine stopped on." + }, + { + "enum": [ + "XHR", + "DOM", + "EventListener", + "exception", + "assert", + "debugCommand", + "promiseRejection", + "OOM", + "other", + "ambiguous" + ], + "type": "string", + "name": "reason", + "description": "Pause reason." + }, + { + "type": "object", + "optional": true, + "name": "data", + "description": "Object containing break-specific auxiliary properties." + }, + { + "type": "array", + "items": { + "type": "string" + }, + "optional": true, + "name": "hitBreakpoints", + "description": "Hit breakpoints IDs" + }, + { + "$ref": "Runtime.StackTrace", + "optional": true, + "name": "asyncStackTrace", + "description": "Async stack trace, if any." + }, + { + "$ref": "Runtime.StackTraceId", + "optional": true, + "name": "asyncStackTraceId", + "experimental": true, + "description": "Async stack trace, if any." + }, + { + "$ref": "Runtime.StackTraceId", + "optional": true, + "name": "asyncCallStackTraceId", + "experimental": true, + "description": "Just scheduled async call will have this stack trace as parent stack during async execution.\nThis field is available only after `Debugger.stepInto` call with `breakOnAsynCall` flag." + } + ], + "description": "Fired when the virtual machine stopped on breakpoint or exception or any other stop criteria." + }, + { + "name": "resumed", + "description": "Fired when the virtual machine resumed execution." + }, + { + "name": "scriptFailedToParse", + "parameters": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "Identifier of the script parsed." + }, + { + "type": "string", + "name": "url", + "description": "URL or name of the script parsed (if any)." + }, + { + "type": "integer", + "name": "startLine", + "description": "Line offset of the script within the resource with given URL (for script tags)." + }, + { + "type": "integer", + "name": "startColumn", + "description": "Column offset of the script within the resource with given URL." + }, + { + "type": "integer", + "name": "endLine", + "description": "Last line of the script." + }, + { + "type": "integer", + "name": "endColumn", + "description": "Length of the last line of the script." + }, + { + "$ref": "Runtime.ExecutionContextId", + "name": "executionContextId", + "description": "Specifies script creation context." + }, + { + "type": "string", + "name": "hash", + "description": "Content hash of the script." + }, + { + "type": "object", + "optional": true, + "name": "executionContextAuxData", + "description": "Embedder-specific auxiliary data." + }, + { + "type": "string", + "optional": true, + "name": "sourceMapURL", + "description": "URL of source map associated with script (if any)." + }, + { + "type": "boolean", + "optional": true, + "name": "hasSourceURL", + "description": "True, if this script has sourceURL." + }, + { + "type": "boolean", + "optional": true, + "name": "isModule", + "description": "True, if this script is ES6 module." + }, + { + "type": "integer", + "optional": true, + "name": "length", + "description": "This script length." + }, + { + "$ref": "Runtime.StackTrace", + "optional": true, + "name": "stackTrace", + "experimental": true, + "description": "JavaScript top stack frame of where the script parsed event was triggered if available." + } + ], + "description": "Fired when virtual machine fails to parse the script." + }, + { + "name": "scriptParsed", + "parameters": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "Identifier of the script parsed." + }, + { + "type": "string", + "name": "url", + "description": "URL or name of the script parsed (if any)." + }, + { + "type": "integer", + "name": "startLine", + "description": "Line offset of the script within the resource with given URL (for script tags)." + }, + { + "type": "integer", + "name": "startColumn", + "description": "Column offset of the script within the resource with given URL." + }, + { + "type": "integer", + "name": "endLine", + "description": "Last line of the script." + }, + { + "type": "integer", + "name": "endColumn", + "description": "Length of the last line of the script." + }, + { + "$ref": "Runtime.ExecutionContextId", + "name": "executionContextId", + "description": "Specifies script creation context." + }, + { + "type": "string", + "name": "hash", + "description": "Content hash of the script." + }, + { + "type": "object", + "optional": true, + "name": "executionContextAuxData", + "description": "Embedder-specific auxiliary data." + }, + { + "type": "boolean", + "optional": true, + "name": "isLiveEdit", + "experimental": true, + "description": "True, if this script is generated as a result of the live edit operation." + }, + { + "type": "string", + "optional": true, + "name": "sourceMapURL", + "description": "URL of source map associated with script (if any)." + }, + { + "type": "boolean", + "optional": true, + "name": "hasSourceURL", + "description": "True, if this script has sourceURL." + }, + { + "type": "boolean", + "optional": true, + "name": "isModule", + "description": "True, if this script is ES6 module." + }, + { + "type": "integer", + "optional": true, + "name": "length", + "description": "This script length." + }, + { + "$ref": "Runtime.StackTrace", + "optional": true, + "name": "stackTrace", + "experimental": true, + "description": "JavaScript top stack frame of where the script parsed event was triggered if available." + } + ], + "description": "Fired when virtual machine parses script. This event is also fired for all known and uncollected\nscripts upon enabling debugger." + } + ], + "types": [ + { + "type": "string", + "id": "BreakpointId", + "description": "Breakpoint identifier." + }, + { + "type": "string", + "id": "CallFrameId", + "description": "Call frame identifier." + }, + { + "properties": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "Script identifier as reported in the `Debugger.scriptParsed`." + }, + { + "type": "integer", + "name": "lineNumber", + "description": "Line number in the script (0-based)." + }, + { + "type": "integer", + "optional": true, + "name": "columnNumber", + "description": "Column number in the script (0-based)." + } + ], + "type": "object", + "id": "Location", + "description": "Location in the source code." + }, + { + "properties": [ + { + "type": "integer", + "name": "lineNumber" + }, + { + "type": "integer", + "name": "columnNumber" + } + ], + "type": "object", + "id": "ScriptPosition", + "experimental": true, + "description": "Location in the source code." + }, + { + "properties": [ + { + "$ref": "CallFrameId", + "name": "callFrameId", + "description": "Call frame identifier. This identifier is only valid while the virtual machine is paused." + }, + { + "type": "string", + "name": "functionName", + "description": "Name of the JavaScript function called on this call frame." + }, + { + "$ref": "Location", + "optional": true, + "name": "functionLocation", + "description": "Location in the source code." + }, + { + "$ref": "Location", + "name": "location", + "description": "Location in the source code." + }, + { + "type": "string", + "name": "url", + "description": "JavaScript script name or url." + }, + { + "items": { + "$ref": "Scope" + }, + "type": "array", + "name": "scopeChain", + "description": "Scope chain for this call frame." + }, + { + "$ref": "Runtime.RemoteObject", + "name": "this", + "description": "`this` object for this call frame." + }, + { + "$ref": "Runtime.RemoteObject", + "optional": true, + "name": "returnValue", + "description": "The value being returned, if the function is at return point." + } + ], + "type": "object", + "id": "CallFrame", + "description": "JavaScript call frame. Array of call frames form the call stack." + }, + { + "properties": [ + { + "enum": [ + "global", + "local", + "with", + "closure", + "catch", + "block", + "script", + "eval", + "module" + ], + "type": "string", + "name": "type", + "description": "Scope type." + }, + { + "$ref": "Runtime.RemoteObject", + "name": "object", + "description": "Object representing the scope. For `global` and `with` scopes it represents the actual\nobject; for the rest of the scopes, it is artificial transient object enumerating scope\nvariables as its properties." + }, + { + "type": "string", + "optional": true, + "name": "name" + }, + { + "$ref": "Location", + "optional": true, + "name": "startLocation", + "description": "Location in the source code where scope starts" + }, + { + "$ref": "Location", + "optional": true, + "name": "endLocation", + "description": "Location in the source code where scope ends" + } + ], + "type": "object", + "id": "Scope", + "description": "Scope description." + }, + { + "properties": [ + { + "type": "number", + "name": "lineNumber", + "description": "Line number in resource content." + }, + { + "type": "string", + "name": "lineContent", + "description": "Line with match content." + } + ], + "type": "object", + "id": "SearchMatch", + "description": "Search match for resource." + }, + { + "type": "object", + "id": "BreakLocation", + "properties": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "Script identifier as reported in the `Debugger.scriptParsed`." + }, + { + "type": "integer", + "name": "lineNumber", + "description": "Line number in the script (0-based)." + }, + { + "type": "integer", + "optional": true, + "name": "columnNumber", + "description": "Column number in the script (0-based)." + }, + { + "type": "string", + "enum": [ + "debuggerStatement", + "call", + "return" + ], + "optional": true, + "name": "type" + } + ] + } + ] + }, + { + "commands": [ + { + "name": "addInspectedHeapObject", + "parameters": [ + { + "$ref": "HeapSnapshotObjectId", + "name": "heapObjectId", + "description": "Heap snapshot object id to be accessible by means of $x command line API." + } + ], + "description": "Enables console to refer to the node with given id via $x (see Command Line API for more details\n$x functions)." + }, + { + "name": "collectGarbage" + }, + { + "name": "disable" + }, + { + "name": "enable" + }, + { + "returns": [ + { + "$ref": "HeapSnapshotObjectId", + "name": "heapSnapshotObjectId", + "description": "Id of the heap snapshot object corresponding to the passed remote object id." + } + ], + "name": "getHeapObjectId", + "parameters": [ + { + "$ref": "Runtime.RemoteObjectId", + "name": "objectId", + "description": "Identifier of the object to get heap object id for." + } + ] + }, + { + "returns": [ + { + "$ref": "Runtime.RemoteObject", + "name": "result", + "description": "Evaluation result." + } + ], + "name": "getObjectByHeapObjectId", + "parameters": [ + { + "name": "objectId", + "$ref": "HeapSnapshotObjectId" + }, + { + "type": "string", + "optional": true, + "name": "objectGroup", + "description": "Symbolic group name that can be used to release multiple objects." + } + ] + }, + { + "returns": [ + { + "$ref": "SamplingHeapProfile", + "name": "profile", + "description": "Return the sampling profile being collected." + } + ], + "name": "getSamplingProfile" + }, + { + "name": "startSampling", + "parameters": [ + { + "type": "number", + "optional": true, + "name": "samplingInterval", + "description": "Average sample interval in bytes. Poisson distribution is used for the intervals. The\ndefault value is 32768 bytes." + } + ] + }, + { + "name": "startTrackingHeapObjects", + "parameters": [ + { + "type": "boolean", + "optional": true, + "name": "trackAllocations" + } + ] + }, + { + "returns": [ + { + "$ref": "SamplingHeapProfile", + "name": "profile", + "description": "Recorded sampling heap profile." + } + ], + "name": "stopSampling" + }, + { + "name": "stopTrackingHeapObjects", + "parameters": [ + { + "type": "boolean", + "optional": true, + "name": "reportProgress", + "description": "If true 'reportHeapSnapshotProgress' events will be generated while snapshot is being taken\nwhen the tracking is stopped." + } + ] + }, + { + "name": "takeHeapSnapshot", + "parameters": [ + { + "type": "boolean", + "optional": true, + "name": "reportProgress", + "description": "If true 'reportHeapSnapshotProgress' events will be generated while snapshot is being taken." + } + ] + } + ], + "domain": "HeapProfiler", + "dependencies": [ + "Runtime" + ], + "experimental": true, + "events": [ + { + "name": "addHeapSnapshotChunk", + "parameters": [ + { + "type": "string", + "name": "chunk" + } + ] + }, + { + "name": "heapStatsUpdate", + "parameters": [ + { + "items": { + "type": "integer" + }, + "type": "array", + "name": "statsUpdate", + "description": "An array of triplets. Each triplet describes a fragment. The first integer is the fragment\nindex, the second integer is a total count of objects for the fragment, the third integer is\na total size of the objects for the fragment." + } + ], + "description": "If heap objects tracking has been started then backend may send update for one or more fragments" + }, + { + "name": "lastSeenObjectId", + "parameters": [ + { + "type": "integer", + "name": "lastSeenObjectId" + }, + { + "type": "number", + "name": "timestamp" + } + ], + "description": "If heap objects tracking has been started then backend regularly sends a current value for last\nseen object id and corresponding timestamp. If the were changes in the heap since last event\nthen one or more heapStatsUpdate events will be sent before a new lastSeenObjectId event." + }, + { + "name": "reportHeapSnapshotProgress", + "parameters": [ + { + "type": "integer", + "name": "done" + }, + { + "type": "integer", + "name": "total" + }, + { + "type": "boolean", + "optional": true, + "name": "finished" + } + ] + }, + { + "name": "resetProfiles" + } + ], + "types": [ + { + "type": "string", + "id": "HeapSnapshotObjectId", + "description": "Heap snapshot object id." + }, + { + "properties": [ + { + "$ref": "Runtime.CallFrame", + "name": "callFrame", + "description": "Function location." + }, + { + "type": "number", + "name": "selfSize", + "description": "Allocations size in bytes for the node excluding children." + }, + { + "type": "integer", + "name": "id", + "description": "Node id. Ids are unique across all profiles collected between startSampling and stopSampling." + }, + { + "items": { + "$ref": "SamplingHeapProfileNode" + }, + "type": "array", + "name": "children", + "description": "Child nodes." + } + ], + "type": "object", + "id": "SamplingHeapProfileNode", + "description": "Sampling Heap Profile node. Holds callsite information, allocation statistics and child nodes." + }, + { + "properties": [ + { + "type": "number", + "name": "size", + "description": "Allocation size in bytes attributed to the sample." + }, + { + "type": "integer", + "name": "nodeId", + "description": "Id of the corresponding profile tree node." + }, + { + "type": "number", + "name": "ordinal", + "description": "Time-ordered sample ordinal number. It is unique across all profiles retrieved\nbetween startSampling and stopSampling." + } + ], + "type": "object", + "id": "SamplingHeapProfileSample", + "description": "A single sample from a sampling profile." + }, + { + "properties": [ + { + "name": "head", + "$ref": "SamplingHeapProfileNode" + }, + { + "items": { + "$ref": "SamplingHeapProfileSample" + }, + "type": "array", + "name": "samples" + } + ], + "type": "object", + "id": "SamplingHeapProfile", + "description": "Sampling profile." + } + ] + }, + { + "domain": "Profiler", + "dependencies": [ + "Runtime", + "Debugger" + ], + "commands": [ + { + "name": "disable" + }, + { + "name": "enable" + }, + { + "returns": [ + { + "items": { + "$ref": "ScriptCoverage" + }, + "type": "array", + "name": "result", + "description": "Coverage data for the current isolate." + } + ], + "name": "getBestEffortCoverage", + "description": "Collect coverage data for the current isolate. The coverage data may be incomplete due to\ngarbage collection." + }, + { + "name": "setSamplingInterval", + "parameters": [ + { + "type": "integer", + "name": "interval", + "description": "New sampling interval in microseconds." + } + ], + "description": "Changes CPU profiler sampling interval. Must be called before CPU profiles recording started." + }, + { + "name": "start" + }, + { + "name": "startPreciseCoverage", + "parameters": [ + { + "type": "boolean", + "optional": true, + "name": "callCount", + "description": "Collect accurate call counts beyond simple 'covered' or 'not covered'." + }, + { + "type": "boolean", + "optional": true, + "name": "detailed", + "description": "Collect block-based coverage." + } + ], + "description": "Enable precise code coverage. Coverage data for JavaScript executed before enabling precise code\ncoverage may be incomplete. Enabling prevents running optimized code and resets execution\ncounters." + }, + { + "name": "startTypeProfile", + "experimental": true, + "description": "Enable type profile." + }, + { + "returns": [ + { + "$ref": "Profile", + "name": "profile", + "description": "Recorded profile." + } + ], + "name": "stop" + }, + { + "name": "stopPreciseCoverage", + "description": "Disable precise code coverage. Disabling releases unnecessary execution count records and allows\nexecuting optimized code." + }, + { + "name": "stopTypeProfile", + "experimental": true, + "description": "Disable type profile. Disabling releases type profile data collected so far." + }, + { + "returns": [ + { + "items": { + "$ref": "ScriptCoverage" + }, + "type": "array", + "name": "result", + "description": "Coverage data for the current isolate." + } + ], + "name": "takePreciseCoverage", + "description": "Collect coverage data for the current isolate, and resets execution counters. Precise code\ncoverage needs to have started." + }, + { + "returns": [ + { + "items": { + "$ref": "ScriptTypeProfile" + }, + "type": "array", + "name": "result", + "description": "Type profile for all scripts since startTypeProfile() was turned on." + } + ], + "name": "takeTypeProfile", + "experimental": true, + "description": "Collect type profile." + } + ], + "types": [ + { + "properties": [ + { + "type": "integer", + "name": "id", + "description": "Unique id of the node." + }, + { + "$ref": "Runtime.CallFrame", + "name": "callFrame", + "description": "Function location." + }, + { + "type": "integer", + "optional": true, + "name": "hitCount", + "description": "Number of samples where this node was on top of the call stack." + }, + { + "type": "array", + "items": { + "type": "integer" + }, + "optional": true, + "name": "children", + "description": "Child node ids." + }, + { + "type": "string", + "optional": true, + "name": "deoptReason", + "description": "The reason of being not optimized. The function may be deoptimized or marked as don't\noptimize." + }, + { + "type": "array", + "items": { + "$ref": "PositionTickInfo" + }, + "optional": true, + "name": "positionTicks", + "description": "An array of source position ticks." + } + ], + "type": "object", + "id": "ProfileNode", + "description": "Profile node. Holds callsite information, execution statistics and child nodes." + }, + { + "properties": [ + { + "items": { + "$ref": "ProfileNode" + }, + "type": "array", + "name": "nodes", + "description": "The list of profile nodes. First item is the root node." + }, + { + "type": "number", + "name": "startTime", + "description": "Profiling start timestamp in microseconds." + }, + { + "type": "number", + "name": "endTime", + "description": "Profiling end timestamp in microseconds." + }, + { + "type": "array", + "items": { + "type": "integer" + }, + "optional": true, + "name": "samples", + "description": "Ids of samples top nodes." + }, + { + "type": "array", + "items": { + "type": "integer" + }, + "optional": true, + "name": "timeDeltas", + "description": "Time intervals between adjacent samples in microseconds. The first delta is relative to the\nprofile startTime." + } + ], + "type": "object", + "id": "Profile", + "description": "Profile." + }, + { + "properties": [ + { + "type": "integer", + "name": "line", + "description": "Source line number (1-based)." + }, + { + "type": "integer", + "name": "ticks", + "description": "Number of samples attributed to the source line." + } + ], + "type": "object", + "id": "PositionTickInfo", + "description": "Specifies a number of samples attributed to a certain source position." + }, + { + "properties": [ + { + "type": "integer", + "name": "startOffset", + "description": "JavaScript script source offset for the range start." + }, + { + "type": "integer", + "name": "endOffset", + "description": "JavaScript script source offset for the range end." + }, + { + "type": "integer", + "name": "count", + "description": "Collected execution count of the source range." + } + ], + "type": "object", + "id": "CoverageRange", + "description": "Coverage data for a source range." + }, + { + "properties": [ + { + "type": "string", + "name": "functionName", + "description": "JavaScript function name." + }, + { + "items": { + "$ref": "CoverageRange" + }, + "type": "array", + "name": "ranges", + "description": "Source ranges inside the function with coverage data." + }, + { + "type": "boolean", + "name": "isBlockCoverage", + "description": "Whether coverage data for this function has block granularity." + } + ], + "type": "object", + "id": "FunctionCoverage", + "description": "Coverage data for a JavaScript function." + }, + { + "properties": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "JavaScript script id." + }, + { + "type": "string", + "name": "url", + "description": "JavaScript script name or url." + }, + { + "items": { + "$ref": "FunctionCoverage" + }, + "type": "array", + "name": "functions", + "description": "Functions contained in the script that has coverage data." + } + ], + "type": "object", + "id": "ScriptCoverage", + "description": "Coverage data for a JavaScript script." + }, + { + "properties": [ + { + "type": "string", + "name": "name", + "description": "Name of a type collected with type profiling." + } + ], + "type": "object", + "id": "TypeObject", + "experimental": true, + "description": "Describes a type collected during runtime." + }, + { + "properties": [ + { + "type": "integer", + "name": "offset", + "description": "Source offset of the parameter or end of function for return values." + }, + { + "items": { + "$ref": "TypeObject" + }, + "type": "array", + "name": "types", + "description": "The types for this parameter or return value." + } + ], + "type": "object", + "id": "TypeProfileEntry", + "experimental": true, + "description": "Source offset and types for a parameter or return value." + }, + { + "properties": [ + { + "$ref": "Runtime.ScriptId", + "name": "scriptId", + "description": "JavaScript script id." + }, + { + "type": "string", + "name": "url", + "description": "JavaScript script name or url." + }, + { + "items": { + "$ref": "TypeProfileEntry" + }, + "type": "array", + "name": "entries", + "description": "Type profile entries for parameters and return values of the functions in the script." + } + ], + "type": "object", + "id": "ScriptTypeProfile", + "experimental": true, + "description": "Type profile data collected during runtime for a JavaScript script." + } + ], + "events": [ + { + "name": "consoleProfileFinished", + "parameters": [ + { + "type": "string", + "name": "id" + }, + { + "$ref": "Debugger.Location", + "name": "location", + "description": "Location of console.profileEnd()." + }, + { + "name": "profile", + "$ref": "Profile" + }, + { + "type": "string", + "optional": true, + "name": "title", + "description": "Profile title passed as an argument to console.profile()." + } + ] + }, + { + "name": "consoleProfileStarted", + "parameters": [ + { + "type": "string", + "name": "id" + }, + { + "$ref": "Debugger.Location", + "name": "location", + "description": "Location of console.profile()." + }, + { + "type": "string", + "optional": true, + "name": "title", + "description": "Profile title passed as an argument to console.profile()." + } + ], + "description": "Sent when new profile recording is started using console.profile() call." + } + ] + }, + { + "commands": [ + { + "returns": [ + { + "$ref": "RemoteObject", + "name": "result", + "description": "Promise result. Will contain rejected value if promise was rejected." + }, + { + "$ref": "ExceptionDetails", + "optional": true, + "name": "exceptionDetails", + "description": "Exception details if stack strace is available." + } + ], + "name": "awaitPromise", + "parameters": [ + { + "$ref": "RemoteObjectId", + "name": "promiseObjectId", + "description": "Identifier of the promise." + }, + { + "type": "boolean", + "optional": true, + "name": "returnByValue", + "description": "Whether the result is expected to be a JSON object that should be sent by value." + }, + { + "type": "boolean", + "optional": true, + "name": "generatePreview", + "description": "Whether preview should be generated for the result." + } + ], + "description": "Add handler to promise with given promise object id." + }, + { + "returns": [ + { + "$ref": "RemoteObject", + "name": "result", + "description": "Call result." + }, + { + "$ref": "ExceptionDetails", + "optional": true, + "name": "exceptionDetails", + "description": "Exception details." + } + ], + "name": "callFunctionOn", + "parameters": [ + { + "type": "string", + "name": "functionDeclaration", + "description": "Declaration of the function to call." + }, + { + "$ref": "RemoteObjectId", + "optional": true, + "name": "objectId", + "description": "Identifier of the object to call function on. Either objectId or executionContextId should\nbe specified." + }, + { + "type": "array", + "items": { + "$ref": "CallArgument" + }, + "optional": true, + "name": "arguments", + "description": "Call arguments. All call arguments must belong to the same JavaScript world as the target\nobject." + }, + { + "type": "boolean", + "optional": true, + "name": "silent", + "description": "In silent mode exceptions thrown during evaluation are not reported and do not pause\nexecution. Overrides `setPauseOnException` state." + }, + { + "type": "boolean", + "optional": true, + "name": "returnByValue", + "description": "Whether the result is expected to be a JSON object which should be sent by value." + }, + { + "type": "boolean", + "optional": true, + "name": "generatePreview", + "experimental": true, + "description": "Whether preview should be generated for the result." + }, + { + "type": "boolean", + "optional": true, + "name": "userGesture", + "description": "Whether execution should be treated as initiated by user in the UI." + }, + { + "type": "boolean", + "optional": true, + "name": "awaitPromise", + "description": "Whether execution should `await` for resulting value and return once awaited promise is\nresolved." + }, + { + "$ref": "ExecutionContextId", + "optional": true, + "name": "executionContextId", + "description": "Specifies execution context which global object will be used to call function on. Either\nexecutionContextId or objectId should be specified." + }, + { + "type": "string", + "optional": true, + "name": "objectGroup", + "description": "Symbolic group name that can be used to release multiple objects. If objectGroup is not\nspecified and objectId is, objectGroup will be inherited from object." + } + ], + "description": "Calls function with given declaration on the given object. Object group of the result is\ninherited from the target object." + }, + { + "returns": [ + { + "$ref": "ScriptId", + "optional": true, + "name": "scriptId", + "description": "Id of the script." + }, + { + "$ref": "ExceptionDetails", + "optional": true, + "name": "exceptionDetails", + "description": "Exception details." + } + ], + "name": "compileScript", + "parameters": [ + { + "type": "string", + "name": "expression", + "description": "Expression to compile." + }, + { + "type": "string", + "name": "sourceURL", + "description": "Source url to be set for the script." + }, + { + "type": "boolean", + "name": "persistScript", + "description": "Specifies whether the compiled script should be persisted." + }, + { + "$ref": "ExecutionContextId", + "optional": true, + "name": "executionContextId", + "description": "Specifies in which execution context to perform script run. If the parameter is omitted the\nevaluation will be performed in the context of the inspected page." + } + ], + "description": "Compiles expression." + }, + { + "name": "disable", + "description": "Disables reporting of execution contexts creation." + }, + { + "name": "discardConsoleEntries", + "description": "Discards collected exceptions and console API calls." + }, + { + "name": "enable", + "description": "Enables reporting of execution contexts creation by means of `executionContextCreated` event.\nWhen the reporting gets enabled the event will be sent immediately for each existing execution\ncontext." + }, + { + "returns": [ + { + "$ref": "RemoteObject", + "name": "result", + "description": "Evaluation result." + }, + { + "$ref": "ExceptionDetails", + "optional": true, + "name": "exceptionDetails", + "description": "Exception details." + } + ], + "name": "evaluate", + "parameters": [ + { + "type": "string", + "name": "expression", + "description": "Expression to evaluate." + }, + { + "type": "string", + "optional": true, + "name": "objectGroup", + "description": "Symbolic group name that can be used to release multiple objects." + }, + { + "type": "boolean", + "optional": true, + "name": "includeCommandLineAPI", + "description": "Determines whether Command Line API should be available during the evaluation." + }, + { + "type": "boolean", + "optional": true, + "name": "silent", + "description": "In silent mode exceptions thrown during evaluation are not reported and do not pause\nexecution. Overrides `setPauseOnException` state." + }, + { + "$ref": "ExecutionContextId", + "optional": true, + "name": "contextId", + "description": "Specifies in which execution context to perform evaluation. If the parameter is omitted the\nevaluation will be performed in the context of the inspected page." + }, + { + "type": "boolean", + "optional": true, + "name": "returnByValue", + "description": "Whether the result is expected to be a JSON object that should be sent by value." + }, + { + "type": "boolean", + "optional": true, + "name": "generatePreview", + "experimental": true, + "description": "Whether preview should be generated for the result." + }, + { + "type": "boolean", + "optional": true, + "name": "userGesture", + "description": "Whether execution should be treated as initiated by user in the UI." + }, + { + "type": "boolean", + "optional": true, + "name": "awaitPromise", + "description": "Whether execution should `await` for resulting value and return once awaited promise is\nresolved." + }, + { + "type": "boolean", + "optional": true, + "name": "throwOnSideEffect", + "experimental": true, + "description": "Whether to throw an exception if side effect cannot be ruled out during evaluation." + }, + { + "$ref": "TimeDelta", + "optional": true, + "name": "timeout", + "experimental": true, + "description": "Terminate execution after timing out (number of milliseconds)." + } + ], + "description": "Evaluates expression on global object." + }, + { + "returns": [ + { + "type": "string", + "name": "id", + "description": "The isolate id." + } + ], + "name": "getIsolateId", + "experimental": true, + "description": "Returns the isolate id." + }, + { + "returns": [ + { + "type": "number", + "name": "usedSize", + "description": "Used heap size in bytes." + }, + { + "type": "number", + "name": "totalSize", + "description": "Allocated heap size in bytes." + } + ], + "name": "getHeapUsage", + "experimental": true, + "description": "Returns the JavaScript heap usage.\nIt is the total usage of the corresponding isolate not scoped to a particular Runtime." + }, + { + "returns": [ + { + "items": { + "$ref": "PropertyDescriptor" + }, + "type": "array", + "name": "result", + "description": "Object properties." + }, + { + "type": "array", + "items": { + "$ref": "InternalPropertyDescriptor" + }, + "optional": true, + "name": "internalProperties", + "description": "Internal object properties (only of the element itself)." + }, + { + "$ref": "ExceptionDetails", + "optional": true, + "name": "exceptionDetails", + "description": "Exception details." + } + ], + "name": "getProperties", + "parameters": [ + { + "$ref": "RemoteObjectId", + "name": "objectId", + "description": "Identifier of the object to return properties for." + }, + { + "type": "boolean", + "optional": true, + "name": "ownProperties", + "description": "If true, returns properties belonging only to the element itself, not to its prototype\nchain." + }, + { + "type": "boolean", + "optional": true, + "name": "accessorPropertiesOnly", + "experimental": true, + "description": "If true, returns accessor properties (with getter/setter) only; internal properties are not\nreturned either." + }, + { + "type": "boolean", + "optional": true, + "name": "generatePreview", + "experimental": true, + "description": "Whether preview should be generated for the results." + } + ], + "description": "Returns properties of a given object. Object group of the result is inherited from the target\nobject." + }, + { + "returns": [ + { + "items": { + "type": "string" + }, + "type": "array", + "name": "names" + } + ], + "name": "globalLexicalScopeNames", + "parameters": [ + { + "$ref": "ExecutionContextId", + "optional": true, + "name": "executionContextId", + "description": "Specifies in which execution context to lookup global scope variables." + } + ], + "description": "Returns all let, const and class variables from global scope." + }, + { + "returns": [ + { + "$ref": "RemoteObject", + "name": "objects", + "description": "Array with objects." + } + ], + "name": "queryObjects", + "parameters": [ + { + "$ref": "RemoteObjectId", + "name": "prototypeObjectId", + "description": "Identifier of the prototype to return objects for." + }, + { + "type": "string", + "optional": true, + "name": "objectGroup", + "description": "Symbolic group name that can be used to release the results." + } + ] + }, + { + "name": "releaseObject", + "parameters": [ + { + "$ref": "RemoteObjectId", + "name": "objectId", + "description": "Identifier of the object to release." + } + ], + "description": "Releases remote object with given id." + }, + { + "name": "releaseObjectGroup", + "parameters": [ + { + "type": "string", + "name": "objectGroup", + "description": "Symbolic object group name." + } + ], + "description": "Releases all remote objects that belong to a given group." + }, + { + "name": "runIfWaitingForDebugger", + "description": "Tells inspected instance to run if it was waiting for debugger to attach." + }, + { + "returns": [ + { + "$ref": "RemoteObject", + "name": "result", + "description": "Run result." + }, + { + "$ref": "ExceptionDetails", + "optional": true, + "name": "exceptionDetails", + "description": "Exception details." + } + ], + "name": "runScript", + "parameters": [ + { + "$ref": "ScriptId", + "name": "scriptId", + "description": "Id of the script to run." + }, + { + "$ref": "ExecutionContextId", + "optional": true, + "name": "executionContextId", + "description": "Specifies in which execution context to perform script run. If the parameter is omitted the\nevaluation will be performed in the context of the inspected page." + }, + { + "type": "string", + "optional": true, + "name": "objectGroup", + "description": "Symbolic group name that can be used to release multiple objects." + }, + { + "type": "boolean", + "optional": true, + "name": "silent", + "description": "In silent mode exceptions thrown during evaluation are not reported and do not pause\nexecution. Overrides `setPauseOnException` state." + }, + { + "type": "boolean", + "optional": true, + "name": "includeCommandLineAPI", + "description": "Determines whether Command Line API should be available during the evaluation." + }, + { + "type": "boolean", + "optional": true, + "name": "returnByValue", + "description": "Whether the result is expected to be a JSON object which should be sent by value." + }, + { + "type": "boolean", + "optional": true, + "name": "generatePreview", + "description": "Whether preview should be generated for the result." + }, + { + "type": "boolean", + "optional": true, + "name": "awaitPromise", + "description": "Whether execution should `await` for resulting value and return once awaited promise is\nresolved." + } + ], + "description": "Runs script with given id in a given context." + }, + { + "redirect": "Debugger", + "name": "setAsyncCallStackDepth", + "parameters": [ + { + "type": "integer", + "name": "maxDepth", + "description": "Maximum depth of async call stacks. Setting to `0` will effectively disable collecting async\ncall stacks (default)." + } + ], + "description": "Enables or disables async call stacks tracking." + }, + { + "parameters": [ + { + "type": "boolean", + "name": "enabled" + } + ], + "name": "setCustomObjectFormatterEnabled", + "experimental": true + }, + { + "parameters": [ + { + "type": "integer", + "name": "size" + } + ], + "name": "setMaxCallStackSizeToCapture", + "experimental": true + }, + { + "name": "terminateExecution", + "experimental": true, + "description": "Terminate current or next JavaScript execution.\nWill cancel the termination when the outer-most script execution ends." + }, + { + "parameters": [ + { + "type": "string", + "name": "name" + }, + { + "optional": true, + "name": "executionContextId", + "$ref": "ExecutionContextId" + } + ], + "name": "addBinding", + "experimental": true, + "description": "If executionContextId is empty, adds binding with the given name on the\nglobal objects of all inspected contexts, including those created later,\nbindings survive reloads.\nIf executionContextId is specified, adds binding only on global object of\ngiven execution context.\nBinding function takes exactly one argument, this argument should be string,\nin case of any other input, function throws an exception.\nEach binding function call produces Runtime.bindingCalled notification." + }, + { + "parameters": [ + { + "type": "string", + "name": "name" + } + ], + "name": "removeBinding", + "experimental": true, + "description": "This method does not remove binding function from global object but\nunsubscribes current runtime agent from Runtime.bindingCalled notifications." + } + ], + "domain": "Runtime", + "description": "Runtime domain exposes JavaScript runtime by means of remote evaluation and mirror objects.\nEvaluation results are returned as mirror object that expose object type, string representation\nand unique identifier that can be used for further object reference. Original objects are\nmaintained in memory unless they are either explicitly released or are released along with the\nother objects in their object group.", + "types": [ + { + "type": "string", + "id": "ScriptId", + "description": "Unique script identifier." + }, + { + "type": "string", + "id": "RemoteObjectId", + "description": "Unique object identifier." + }, + { + "type": "string", + "id": "UnserializableValue", + "description": "Primitive value which cannot be JSON-stringified. Includes values `-0`, `NaN`, `Infinity`,\n`-Infinity`, and bigint literals." + }, + { + "properties": [ + { + "enum": [ + "object", + "function", + "undefined", + "string", + "number", + "boolean", + "symbol", + "bigint" + ], + "type": "string", + "name": "type", + "description": "Object type." + }, + { + "type": "string", + "enum": [ + "array", + "null", + "node", + "regexp", + "date", + "map", + "set", + "weakmap", + "weakset", + "iterator", + "generator", + "error", + "proxy", + "promise", + "typedarray", + "arraybuffer", + "dataview" + ], + "optional": true, + "name": "subtype", + "description": "Object subtype hint. Specified for `object` type values only." + }, + { + "type": "string", + "optional": true, + "name": "className", + "description": "Object class (constructor) name. Specified for `object` type values only." + }, + { + "type": "any", + "optional": true, + "name": "value", + "description": "Remote object value in case of primitive values or JSON values (if it was requested)." + }, + { + "$ref": "UnserializableValue", + "optional": true, + "name": "unserializableValue", + "description": "Primitive value which can not be JSON-stringified does not have `value`, but gets this\nproperty." + }, + { + "type": "string", + "optional": true, + "name": "description", + "description": "String representation of the object." + }, + { + "$ref": "RemoteObjectId", + "optional": true, + "name": "objectId", + "description": "Unique object identifier (for non-primitive values)." + }, + { + "$ref": "ObjectPreview", + "optional": true, + "name": "preview", + "experimental": true, + "description": "Preview containing abbreviated property values. Specified for `object` type values only." + }, + { + "optional": true, + "name": "customPreview", + "experimental": true, + "$ref": "CustomPreview" + } + ], + "type": "object", + "id": "RemoteObject", + "description": "Mirror object referencing original JavaScript object." + }, + { + "type": "object", + "id": "CustomPreview", + "experimental": true, + "properties": [ + { + "type": "string", + "name": "header", + "description": "The JSON-stringified result of formatter.header(object, config) call.\nIt contains json ML array that represents RemoteObject." + }, + { + "$ref": "RemoteObjectId", + "optional": true, + "name": "bodyGetterId", + "description": "If formatter returns true as a result of formatter.hasBody call then bodyGetterId will\ncontain RemoteObjectId for the function that returns result of formatter.body(object, config) call.\nThe result value is json ML array." + } + ] + }, + { + "properties": [ + { + "enum": [ + "object", + "function", + "undefined", + "string", + "number", + "boolean", + "symbol", + "bigint" + ], + "type": "string", + "name": "type", + "description": "Object type." + }, + { + "type": "string", + "enum": [ + "array", + "null", + "node", + "regexp", + "date", + "map", + "set", + "weakmap", + "weakset", + "iterator", + "generator", + "error" + ], + "optional": true, + "name": "subtype", + "description": "Object subtype hint. Specified for `object` type values only." + }, + { + "type": "string", + "optional": true, + "name": "description", + "description": "String representation of the object." + }, + { + "type": "boolean", + "name": "overflow", + "description": "True iff some of the properties or entries of the original object did not fit." + }, + { + "items": { + "$ref": "PropertyPreview" + }, + "type": "array", + "name": "properties", + "description": "List of the properties." + }, + { + "type": "array", + "items": { + "$ref": "EntryPreview" + }, + "optional": true, + "name": "entries", + "description": "List of the entries. Specified for `map` and `set` subtype values only." + } + ], + "type": "object", + "id": "ObjectPreview", + "experimental": true, + "description": "Object containing abbreviated remote object value." + }, + { + "type": "object", + "id": "PropertyPreview", + "experimental": true, + "properties": [ + { + "type": "string", + "name": "name", + "description": "Property name." + }, + { + "enum": [ + "object", + "function", + "undefined", + "string", + "number", + "boolean", + "symbol", + "accessor", + "bigint" + ], + "type": "string", + "name": "type", + "description": "Object type. Accessor means that the property itself is an accessor property." + }, + { + "type": "string", + "optional": true, + "name": "value", + "description": "User-friendly property value string." + }, + { + "$ref": "ObjectPreview", + "optional": true, + "name": "valuePreview", + "description": "Nested value preview." + }, + { + "type": "string", + "enum": [ + "array", + "null", + "node", + "regexp", + "date", + "map", + "set", + "weakmap", + "weakset", + "iterator", + "generator", + "error" + ], + "optional": true, + "name": "subtype", + "description": "Object subtype hint. Specified for `object` type values only." + } + ] + }, + { + "type": "object", + "id": "EntryPreview", + "experimental": true, + "properties": [ + { + "$ref": "ObjectPreview", + "optional": true, + "name": "key", + "description": "Preview of the key. Specified for map-like collection entries." + }, + { + "$ref": "ObjectPreview", + "name": "value", + "description": "Preview of the value." + } + ] + }, + { + "properties": [ + { + "type": "string", + "name": "name", + "description": "Property name or symbol description." + }, + { + "$ref": "RemoteObject", + "optional": true, + "name": "value", + "description": "The value associated with the property." + }, + { + "type": "boolean", + "optional": true, + "name": "writable", + "description": "True if the value associated with the property may be changed (data descriptors only)." + }, + { + "$ref": "RemoteObject", + "optional": true, + "name": "get", + "description": "A function which serves as a getter for the property, or `undefined` if there is no getter\n(accessor descriptors only)." + }, + { + "$ref": "RemoteObject", + "optional": true, + "name": "set", + "description": "A function which serves as a setter for the property, or `undefined` if there is no setter\n(accessor descriptors only)." + }, + { + "type": "boolean", + "name": "configurable", + "description": "True if the type of this property descriptor may be changed and if the property may be\ndeleted from the corresponding object." + }, + { + "type": "boolean", + "name": "enumerable", + "description": "True if this property shows up during enumeration of the properties on the corresponding\nobject." + }, + { + "type": "boolean", + "optional": true, + "name": "wasThrown", + "description": "True if the result was thrown during the evaluation." + }, + { + "type": "boolean", + "optional": true, + "name": "isOwn", + "description": "True if the property is owned for the object." + }, + { + "$ref": "RemoteObject", + "optional": true, + "name": "symbol", + "description": "Property symbol object, if the property is of the `symbol` type." + } + ], + "type": "object", + "id": "PropertyDescriptor", + "description": "Object property descriptor." + }, + { + "properties": [ + { + "type": "string", + "name": "name", + "description": "Conventional property name." + }, + { + "$ref": "RemoteObject", + "optional": true, + "name": "value", + "description": "The value associated with the property." + } + ], + "type": "object", + "id": "InternalPropertyDescriptor", + "description": "Object internal property descriptor. This property isn't normally visible in JavaScript code." + }, + { + "properties": [ + { + "type": "any", + "optional": true, + "name": "value", + "description": "Primitive value or serializable javascript object." + }, + { + "$ref": "UnserializableValue", + "optional": true, + "name": "unserializableValue", + "description": "Primitive value which can not be JSON-stringified." + }, + { + "$ref": "RemoteObjectId", + "optional": true, + "name": "objectId", + "description": "Remote object handle." + } + ], + "type": "object", + "id": "CallArgument", + "description": "Represents function call argument. Either remote object id `objectId`, primitive `value`,\nunserializable primitive value or neither of (for undefined) them should be specified." + }, + { + "type": "integer", + "id": "ExecutionContextId", + "description": "Id of an execution context." + }, + { + "properties": [ + { + "$ref": "ExecutionContextId", + "name": "id", + "description": "Unique id of the execution context. It can be used to specify in which execution context\nscript evaluation should be performed." + }, + { + "type": "string", + "name": "origin", + "description": "Execution context origin." + }, + { + "type": "string", + "name": "name", + "description": "Human readable name describing given context." + }, + { + "type": "object", + "optional": true, + "name": "auxData", + "description": "Embedder-specific auxiliary data." + } + ], + "type": "object", + "id": "ExecutionContextDescription", + "description": "Description of an isolated world." + }, + { + "properties": [ + { + "type": "integer", + "name": "exceptionId", + "description": "Exception id." + }, + { + "type": "string", + "name": "text", + "description": "Exception text, which should be used together with exception object when available." + }, + { + "type": "integer", + "name": "lineNumber", + "description": "Line number of the exception location (0-based)." + }, + { + "type": "integer", + "name": "columnNumber", + "description": "Column number of the exception location (0-based)." + }, + { + "$ref": "ScriptId", + "optional": true, + "name": "scriptId", + "description": "Script ID of the exception location." + }, + { + "type": "string", + "optional": true, + "name": "url", + "description": "URL of the exception location, to be used when the script was not reported." + }, + { + "$ref": "StackTrace", + "optional": true, + "name": "stackTrace", + "description": "JavaScript stack trace if available." + }, + { + "$ref": "RemoteObject", + "optional": true, + "name": "exception", + "description": "Exception object if available." + }, + { + "$ref": "ExecutionContextId", + "optional": true, + "name": "executionContextId", + "description": "Identifier of the context where exception happened." + } + ], + "type": "object", + "id": "ExceptionDetails", + "description": "Detailed information about exception (or error) that was thrown during script compilation or\nexecution." + }, + { + "type": "number", + "id": "Timestamp", + "description": "Number of milliseconds since epoch." + }, + { + "type": "number", + "id": "TimeDelta", + "description": "Number of milliseconds." + }, + { + "properties": [ + { + "type": "string", + "name": "functionName", + "description": "JavaScript function name." + }, + { + "$ref": "ScriptId", + "name": "scriptId", + "description": "JavaScript script id." + }, + { + "type": "string", + "name": "url", + "description": "JavaScript script name or url." + }, + { + "type": "integer", + "name": "lineNumber", + "description": "JavaScript script line number (0-based)." + }, + { + "type": "integer", + "name": "columnNumber", + "description": "JavaScript script column number (0-based)." + } + ], + "type": "object", + "id": "CallFrame", + "description": "Stack entry for runtime errors and assertions." + }, + { + "properties": [ + { + "type": "string", + "optional": true, + "name": "description", + "description": "String label of this stack trace. For async traces this may be a name of the function that\ninitiated the async call." + }, + { + "items": { + "$ref": "CallFrame" + }, + "type": "array", + "name": "callFrames", + "description": "JavaScript function name." + }, + { + "$ref": "StackTrace", + "optional": true, + "name": "parent", + "description": "Asynchronous JavaScript stack trace that preceded this stack, if available." + }, + { + "$ref": "StackTraceId", + "optional": true, + "name": "parentId", + "experimental": true, + "description": "Asynchronous JavaScript stack trace that preceded this stack, if available." + } + ], + "type": "object", + "id": "StackTrace", + "description": "Call frames for assertions or error messages." + }, + { + "type": "string", + "id": "UniqueDebuggerId", + "experimental": true, + "description": "Unique identifier of current debugger." + }, + { + "properties": [ + { + "type": "string", + "name": "id" + }, + { + "optional": true, + "name": "debuggerId", + "$ref": "UniqueDebuggerId" + } + ], + "type": "object", + "id": "StackTraceId", + "experimental": true, + "description": "If `debuggerId` is set stack trace comes from another debugger and can be resolved there. This\nallows to track cross-debugger calls. See `Runtime.StackTrace` and `Debugger.paused` for usages." + } + ], + "events": [ + { + "parameters": [ + { + "type": "string", + "name": "name" + }, + { + "type": "string", + "name": "payload" + }, + { + "$ref": "ExecutionContextId", + "name": "executionContextId", + "description": "Identifier of the context where the call was made." + } + ], + "name": "bindingCalled", + "experimental": true, + "description": "Notification is issued every time when binding is called." + }, + { + "name": "consoleAPICalled", + "parameters": [ + { + "enum": [ + "log", + "debug", + "info", + "error", + "warning", + "dir", + "dirxml", + "table", + "trace", + "clear", + "startGroup", + "startGroupCollapsed", + "endGroup", + "assert", + "profile", + "profileEnd", + "count", + "timeEnd" + ], + "type": "string", + "name": "type", + "description": "Type of the call." + }, + { + "items": { + "$ref": "RemoteObject" + }, + "type": "array", + "name": "args", + "description": "Call arguments." + }, + { + "$ref": "ExecutionContextId", + "name": "executionContextId", + "description": "Identifier of the context where the call was made." + }, + { + "$ref": "Timestamp", + "name": "timestamp", + "description": "Call timestamp." + }, + { + "$ref": "StackTrace", + "optional": true, + "name": "stackTrace", + "description": "Stack trace captured when the call was made." + }, + { + "type": "string", + "optional": true, + "name": "context", + "experimental": true, + "description": "Console context descriptor for calls on non-default console context (not console.*):\n'anonymous#unique-logger-id' for call on unnamed context, 'name#unique-logger-id' for call\non named context." + } + ], + "description": "Issued when console API was called." + }, + { + "name": "exceptionRevoked", + "parameters": [ + { + "type": "string", + "name": "reason", + "description": "Reason describing why exception was revoked." + }, + { + "type": "integer", + "name": "exceptionId", + "description": "The id of revoked exception, as reported in `exceptionThrown`." + } + ], + "description": "Issued when unhandled exception was revoked." + }, + { + "name": "exceptionThrown", + "parameters": [ + { + "$ref": "Timestamp", + "name": "timestamp", + "description": "Timestamp of the exception." + }, + { + "name": "exceptionDetails", + "$ref": "ExceptionDetails" + } + ], + "description": "Issued when exception was thrown and unhandled." + }, + { + "name": "executionContextCreated", + "parameters": [ + { + "$ref": "ExecutionContextDescription", + "name": "context", + "description": "A newly created execution context." + } + ], + "description": "Issued when new execution context is created." + }, + { + "name": "executionContextDestroyed", + "parameters": [ + { + "$ref": "ExecutionContextId", + "name": "executionContextId", + "description": "Id of the destroyed context" + } + ], + "description": "Issued when execution context is destroyed." + }, + { + "name": "executionContextsCleared", + "description": "Issued when all executionContexts were cleared in browser" + }, + { + "name": "inspectRequested", + "parameters": [ + { + "name": "object", + "$ref": "RemoteObject" + }, + { + "type": "object", + "name": "hints" + } + ], + "description": "Issued when object should be inspected (for example, as a result of inspect() command line API\ncall)." + } + ] + }, + { + "deprecated": true, + "domain": "Schema", + "commands": [ + { + "returns": [ + { + "items": { + "$ref": "Domain" + }, + "type": "array", + "name": "domains", + "description": "List of supported domains." + } + ], + "name": "getDomains", + "description": "Returns supported domains." + } + ], + "description": "This domain is deprecated.", + "types": [ + { + "properties": [ + { + "type": "string", + "name": "name", + "description": "Domain name." + }, + { + "type": "string", + "name": "version", + "description": "Domain version." + } + ], + "type": "object", + "id": "Domain", + "description": "Description of the protocol domain." + } + ] + } + ], + "version": { + "major": "1", + "minor": "3" + } +}; + +export const Protocol = { Description }; diff --git a/remote/cdp/StreamRegistry.sys.mjs b/remote/cdp/StreamRegistry.sys.mjs new file mode 100644 index 0000000000..9474f16a57 --- /dev/null +++ b/remote/cdp/StreamRegistry.sys.mjs @@ -0,0 +1,139 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + UnsupportedError: "chrome://remote/content/cdp/Error.sys.mjs", +}); + +export class Stream { + #path; + #offset; + #length; + + constructor(path) { + this.#path = path; + this.#offset = 0; + this.#length = null; + } + + async destroy() { + await IOUtils.remove(this.#path); + } + + async seek(seekTo) { + // To keep compatibility with Chrome clip invalid offsets + this.#offset = Math.max(0, Math.min(seekTo, await this.length())); + } + + async readBytes(count) { + const bytes = await IOUtils.read(this.#path, { + offset: this.#offset, + maxBytes: count, + }); + this.#offset += bytes.length; + return bytes; + } + + async available() { + const length = await this.length(); + return length - this.#offset; + } + + async length() { + if (this.#length === null) { + const info = await IOUtils.stat(this.#path); + this.#length = info.size; + } + + return this.#length; + } + + get path() { + return this.#path; + } +} + +export class StreamRegistry { + constructor() { + // handle => stream + this.streams = new Map(); + + // Register an async shutdown blocker to ensure all open IO streams are + // closed, and remaining temporary files removed. Needs to happen before + // IOUtils has been shutdown. + IOUtils.profileBeforeChange.addBlocker( + "Remote Agent: Clean-up of open streams", + async () => { + await this.destructor(); + } + ); + } + + async destructor() { + for (const stream of this.streams.values()) { + await stream.destroy(); + } + + this.streams.clear(); + } + + /** + * Add a new stream to the registry. + * + * @param {Stream} stream + * The stream to use. + * + * @returns {string} + * Stream handle (uuid) + */ + add(stream) { + if (!(stream instanceof Stream)) { + // Bug 1602731 - Implement support for blob + throw new lazy.UnsupportedError(`Unknown stream type for ${stream}`); + } + + const handle = lazy.generateUUID(); + + this.streams.set(handle, stream); + return handle; + } + + /** + * Get a stream from the registry. + * + * @param {string} handle + * Handle of the stream to retrieve. + * + * @returns {Stream} + * The requested stream. + */ + get(handle) { + const stream = this.streams.get(handle); + + if (!stream) { + throw new TypeError(`Invalid stream handle`); + } + + return stream; + } + + /** + * Remove a stream from the registry. + * + * @param {string} handle + * Handle of the stream to remove. + * + * @returns {boolean} + * true if successfully removed + */ + async remove(handle) { + const stream = this.get(handle); + await stream.destroy(); + + return this.streams.delete(handle); + } +} diff --git a/remote/cdp/domains/ContentProcessDomain.sys.mjs b/remote/cdp/domains/ContentProcessDomain.sys.mjs new file mode 100644 index 0000000000..fefe6aece5 --- /dev/null +++ b/remote/cdp/domains/ContentProcessDomain.sys.mjs @@ -0,0 +1,25 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +export class ContentProcessDomain extends Domain { + destructor() { + super.destructor(); + } + + // helpers + + get content() { + return this.session.content; + } + + get docShell() { + return this.session.docShell; + } + + get chromeEventHandler() { + return this.docShell.chromeEventHandler; + } +} diff --git a/remote/cdp/domains/ContentProcessDomains.sys.mjs b/remote/cdp/domains/ContentProcessDomains.sys.mjs new file mode 100644 index 0000000000..a434dc0067 --- /dev/null +++ b/remote/cdp/domains/ContentProcessDomains.sys.mjs @@ -0,0 +1,19 @@ +/* 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/. */ + +export const ContentProcessDomains = {}; + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(ContentProcessDomains, { + DOM: "chrome://remote/content/cdp/domains/content/DOM.sys.mjs", + Emulation: "chrome://remote/content/cdp/domains/content/Emulation.sys.mjs", + Input: "chrome://remote/content/cdp/domains/content/Input.sys.mjs", + Log: "chrome://remote/content/cdp/domains/content/Log.sys.mjs", + Network: "chrome://remote/content/cdp/domains/content/Network.sys.mjs", + Page: "chrome://remote/content/cdp/domains/content/Page.sys.mjs", + Performance: + "chrome://remote/content/cdp/domains/content/Performance.sys.mjs", + Runtime: "chrome://remote/content/cdp/domains/content/Runtime.sys.mjs", + Security: "chrome://remote/content/cdp/domains/content/Security.sys.mjs", +}); diff --git a/remote/cdp/domains/Domain.sys.mjs b/remote/cdp/domains/Domain.sys.mjs new file mode 100644 index 0000000000..d9b36c62b8 --- /dev/null +++ b/remote/cdp/domains/Domain.sys.mjs @@ -0,0 +1,71 @@ +/* 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/. */ + +export class Domain { + constructor(session) { + this.session = session; + this.name = this.constructor.name; + + this.eventListeners_ = new Set(); + this._requestCounter = 0; + } + + destructor() {} + + emit(eventName, params = {}) { + for (const listener of this.eventListeners_) { + try { + if (isEventHandler(listener)) { + listener.onEvent(eventName, params); + } else { + listener.call(this, eventName, params); + } + } catch (e) { + console.error(e); + } + } + } + + /** + * Execute the provided method in the child domain that has the same domain + * name. eg. calling this.executeInChild from domains/parent/Input.jsm will + * attempt to execute the method in domains/content/Input.jsm. + * + * This can only be called from parent domains managed by a TabSession. + * + * @param {string} method + * Name of the method to call on the child domain. + * @param {object} params + * Optional parameters. Must be serializable. + */ + executeInChild(method, params) { + if (!this.session.executeInChild) { + throw new Error( + "executeInChild can only be used in Domains managed by a TabSession" + ); + } + this._requestCounter++; + const id = this.name + "-" + this._requestCounter; + return this.session.executeInChild(id, this.name, method, params); + } + + addEventListener(listener) { + if (typeof listener != "function" && !isEventHandler(listener)) { + throw new TypeError(); + } + this.eventListeners_.add(listener); + } + + // static + + static implements(command) { + return command && typeof this.prototype[command] == "function"; + } +} + +function isEventHandler(listener) { + return ( + listener && "onEvent" in listener && typeof listener.onEvent == "function" + ); +} diff --git a/remote/cdp/domains/DomainCache.sys.mjs b/remote/cdp/domains/DomainCache.sys.mjs new file mode 100644 index 0000000000..b4651cbe68 --- /dev/null +++ b/remote/cdp/domains/DomainCache.sys.mjs @@ -0,0 +1,113 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Domain: "chrome://remote/content/cdp/domains/Domain.sys.mjs", + UnknownMethodError: "chrome://remote/content/cdp/Error.sys.mjs", +}); + +/** + * Lazy domain instance cache. + * + * Domains are loaded into each target's realm, and consequently + * there exists one domain cache per realm. Domains are preregistered + * with this cache and then constructed lazily upon request. + * + * @param {Session} session + * Session that domains should be associated with as they + * are constructed. + * @param {Map.<string, string>} modules + * Table defining JS modules available to this domain cache. + * This should be a mapping between domain name + * and JS module path passed to ChromeUtils.import. + */ +export class DomainCache { + constructor(session, modules) { + this.session = session; + this.modules = modules; + this.instances = new Map(); + } + + /** Test if domain supports method. */ + domainSupportsMethod(name, method) { + const domain = this.modules[name]; + if (domain) { + return domain.implements(method); + } + return false; + } + + /** + * Gets the current instance of the domain, or creates a new one, + * and associates it with the predefined session. + * + * @throws {UnknownMethodError} + * If domain is not preregistered with this domain cache. + */ + get(name) { + let inst = this.instances.get(name); + if (!inst) { + const Cls = this.modules[name]; + if (!Cls) { + throw new lazy.UnknownMethodError(name); + } + if (!isConstructor(Cls)) { + throw new TypeError("Domain cannot be constructed"); + } + + inst = new Cls(this.session); + if (!(inst instanceof lazy.Domain)) { + throw new TypeError("Instance not a domain"); + } + + inst.addEventListener(this.session); + + this.instances.set(name, inst); + } + + return inst; + } + + /** + * Tells if a Domain of the given name is available + */ + has(name) { + return name in this.modules; + } + + get size() { + return this.instances.size; + } + + /** + * Execute the given command (function) of a given domain with the given parameters. + * If the command doesn't exists, it will throw. + * It returns the returned value of the command, which is most likely a promise. + */ + execute(domain, command, params) { + if (!this.domainSupportsMethod(domain, command)) { + throw new lazy.UnknownMethodError(domain, command); + } + const inst = this.get(domain); + return inst[command](params); + } + + /** Calls destructor on each domain and clears the cache. */ + clear() { + for (const inst of this.instances.values()) { + inst.destructor(); + } + this.instances.clear(); + } + + toString() { + return `[object DomainCache ${this.size}]`; + } +} + +function isConstructor(obj) { + return !!obj.prototype && !!obj.prototype.constructor.name; +} diff --git a/remote/cdp/domains/ParentProcessDomains.sys.mjs b/remote/cdp/domains/ParentProcessDomains.sys.mjs new file mode 100644 index 0000000000..fcf806b884 --- /dev/null +++ b/remote/cdp/domains/ParentProcessDomains.sys.mjs @@ -0,0 +1,19 @@ +/* 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/. */ + +export const ParentProcessDomains = {}; + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(ParentProcessDomains, { + Browser: "chrome://remote/content/cdp/domains/parent/Browser.sys.mjs", + Emulation: "chrome://remote/content/cdp/domains/parent/Emulation.sys.mjs", + Fetch: "chrome://remote/content/cdp/domains/parent/Fetch.sys.mjs", + Input: "chrome://remote/content/cdp/domains/parent/Input.sys.mjs", + IO: "chrome://remote/content/cdp/domains/parent/IO.sys.mjs", + Network: "chrome://remote/content/cdp/domains/parent/Network.sys.mjs", + Page: "chrome://remote/content/cdp/domains/parent/Page.sys.mjs", + Security: "chrome://remote/content/cdp/domains/parent/Security.sys.mjs", + SystemInfo: "chrome://remote/content/cdp/domains/parent/SystemInfo.sys.mjs", + Target: "chrome://remote/content/cdp/domains/parent/Target.sys.mjs", +}); diff --git a/remote/cdp/domains/content/DOM.sys.mjs b/remote/cdp/domains/content/DOM.sys.mjs new file mode 100644 index 0000000000..34c4e96ea9 --- /dev/null +++ b/remote/cdp/domains/content/DOM.sys.mjs @@ -0,0 +1,245 @@ +/* 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/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class DOM extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + /** + * Describes node given its id. + * + * Does not require domain to be enabled. Does not start tracking any objects. + * + * @param {object} options + * @param {number=} options.backendNodeId [not supported] + * Identifier of the backend node. + * @param {number=} options.depth [not supported] + * The maximum depth at which children should be retrieved, defaults to 1. + * Use -1 for the entire subtree or provide an integer larger than 0. + * @param {number=} options.nodeId [not supported] + * Identifier of the node. + * @param {string} options.objectId + * JavaScript object id of the node wrapper. + * @param {boolean=} options.pierce [not supported] + * Whether or not iframes and shadow roots should be traversed + * when returning the subtree, defaults to false. + * + * @returns {DOM.Node} + * Node description. + */ + describeNode(options = {}) { + const { objectId } = options; + + // Until nodeId/backendNodeId is supported force usage of the objectId + if (!["string"].includes(typeof objectId)) { + throw new TypeError("objectId: string value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error("Could not find object with given id"); + } + + if (typeof debuggerObj.nodeId == "undefined") { + throw new Error("Object id doesn't reference a Node"); + } + + const unsafeObj = debuggerObj.unsafeDereference(); + + const attributes = []; + if (unsafeObj.attributes) { + // Flatten the list of attributes for name and value + for (const attribute of unsafeObj.attributes) { + attributes.push(attribute.name, attribute.value); + } + } + + let context = this.docShell.browsingContext; + if (HTMLIFrameElement.isInstance(unsafeObj)) { + context = unsafeObj.contentWindow.docShell.browsingContext; + } + + const node = { + nodeId: debuggerObj.nodeId, + backendNodeId: debuggerObj.backendNodeId, + nodeType: unsafeObj.nodeType, + nodeName: unsafeObj.nodeName, + localName: unsafeObj.localName, + nodeValue: unsafeObj.nodeValue ? unsafeObj.nodeValue.toString() : "", + childNodeCount: unsafeObj.childElementCount, + attributes: attributes.length ? attributes : undefined, + frameId: context.id.toString(), + }; + + return { node }; + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } + + getContentQuads(options = {}) { + const { objectId } = options; + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error(`Cannot find object with id: ${objectId}`); + } + const unsafeObject = debuggerObj.unsafeDereference(); + if (!unsafeObject.getBoxQuads) { + throw new Error("RemoteObject is not a node"); + } + let quads = unsafeObject.getBoxQuads({ relativeTo: this.content.document }); + quads = quads.map(quad => { + return [ + quad.p1.x, + quad.p1.y, + quad.p2.x, + quad.p2.y, + quad.p3.x, + quad.p3.y, + quad.p4.x, + quad.p4.y, + ].map(Math.round); + }); + return { quads }; + } + + getBoxModel(options = {}) { + const { objectId } = options; + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error(`Cannot find object with id: ${objectId}`); + } + const unsafeObject = debuggerObj.unsafeDereference(); + const bounding = unsafeObject.getBoundingClientRect(); + const model = { + width: Math.round(bounding.width), + height: Math.round(bounding.height), + }; + for (const box of ["content", "padding", "border", "margin"]) { + const quads = unsafeObject.getBoxQuads({ + box, + relativeTo: this.content.document, + }); + + // getBoxQuads may return more than one element. In this case we have to compute the bounding box + // of all these boxes. + let bounding = { + p1: { x: Infinity, y: Infinity }, + p2: { x: -Infinity, y: Infinity }, + p3: { x: -Infinity, y: -Infinity }, + p4: { x: Infinity, y: -Infinity }, + }; + quads.forEach(quad => { + bounding = { + p1: { + x: Math.min(bounding.p1.x, quad.p1.x), + y: Math.min(bounding.p1.y, quad.p1.y), + }, + p2: { + x: Math.max(bounding.p2.x, quad.p2.x), + y: Math.min(bounding.p2.y, quad.p2.y), + }, + p3: { + x: Math.max(bounding.p3.x, quad.p3.x), + y: Math.max(bounding.p3.y, quad.p3.y), + }, + p4: { + x: Math.min(bounding.p4.x, quad.p4.x), + y: Math.max(bounding.p4.y, quad.p4.y), + }, + }; + }); + + model[box] = [ + bounding.p1.x, + bounding.p1.y, + bounding.p2.x, + bounding.p2.y, + bounding.p3.x, + bounding.p3.y, + bounding.p4.x, + bounding.p4.y, + ].map(Math.round); + } + return { + model, + }; + } + + /** + * Resolves the JavaScript node object for a given NodeId or BackendNodeId. + * + * @param {object} options + * @param {number} options.backendNodeId [required for now] + * Backend identifier of the node to resolve. + * @param {number=} options.executionContextId + * Execution context in which to resolve the node. + * @param {number=} options.nodeId [not supported] + * Id of the node to resolve. + * @param {string=} options.objectGroup [not supported] + * Symbolic group name that can be used to release multiple objects. + * + * @returns {Runtime.RemoteObject} + * JavaScript object wrapper for given node. + */ + resolveNode(options = {}) { + const { backendNodeId, executionContextId } = options; + + // Until nodeId is supported force usage of the backendNodeId + if (!["number"].includes(typeof backendNodeId)) { + throw new TypeError("backendNodeId: number value expected"); + } + if (!["undefined", "number"].includes(typeof executionContextId)) { + throw new TypeError("executionContextId: integer value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + + // Retrieve the node to resolve, and its context + const debuggerObj = Runtime._getRemoteObjectByNodeId(backendNodeId); + + if (!debuggerObj) { + throw new Error(`No node with given id found`); + } + + // If execution context isn't specified use the default one for the node + let context; + if (typeof executionContextId != "undefined") { + context = Runtime.contexts.get(executionContextId); + if (!context) { + throw new Error(`Node with given id does not belong to the document`); + } + } else { + context = Runtime._getDefaultContextForWindow(); + } + + Runtime._setRemoteObject(debuggerObj, context); + + return { + object: Runtime._serializeRemoteObject(debuggerObj, context.id), + }; + } +} diff --git a/remote/cdp/domains/content/Emulation.sys.mjs b/remote/cdp/domains/content/Emulation.sys.mjs new file mode 100644 index 0000000000..41bb0c76ea --- /dev/null +++ b/remote/cdp/domains/content/Emulation.sys.mjs @@ -0,0 +1,50 @@ +/* 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/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs", +}); + +export class Emulation extends ContentProcessDomain { + // commands + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + /** + * Waits until the viewport has reached the new dimensions. + */ + async _awaitViewportDimensions({ width, height }) { + const win = this.content; + let resized; + + // Updates for background tabs are throttled, and we also we have to make + // sure that the new browser dimensions have been received by the content + // process. As such wait for the next animation frame. + await lazy.AnimationFramePromise(win); + + const checkBrowserSize = () => { + if (win.innerWidth === width && win.innerHeight === height) { + resized(); + } + }; + + return new Promise(resolve => { + resized = resolve; + + win.addEventListener("resize", checkBrowserSize); + + // Trigger a layout flush in case none happened yet. + checkBrowserSize(); + }).finally(() => { + win.removeEventListener("resize", checkBrowserSize); + }); + } +} diff --git a/remote/cdp/domains/content/Input.sys.mjs b/remote/cdp/domains/content/Input.sys.mjs new file mode 100644 index 0000000000..e7a6ffc709 --- /dev/null +++ b/remote/cdp/domains/content/Input.sys.mjs @@ -0,0 +1,57 @@ +/* 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/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Input extends ContentProcessDomain { + constructor(session) { + super(session); + + // Internal id used to track existing event handlers. + this._eventId = 0; + + // Map of event id -> event handler promise. + this._eventPromises = new Map(); + } + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + /** + * Add an event listener in the content page for the provided eventName. + * This method will return a unique handler id that can be used to wait + * for the event. + * + * Example usage from a parent process domain: + * + * const id = await this.executeInChild("_addContentEventListener", "click"); + * // do something that triggers a click in content + * await this.executeInChild("_waitForContentEvent", id); + */ + _addContentEventListener(eventName) { + const eventPromise = new Promise(resolve => { + this.chromeEventHandler.addEventListener(eventName, resolve, { + mozSystemGroup: true, + once: true, + }); + }); + this._eventId++; + this._eventPromises.set(this._eventId, eventPromise); + return this._eventId; + } + + /** + * Wait for an event listener added via `addContentEventListener` to be fired. + */ + async _waitForContentEvent(eventId) { + const eventPromise = this._eventPromises.get(eventId); + if (!eventPromise) { + throw new Error("No event promise found for id " + eventId); + } + await eventPromise; + this._eventPromises.delete(eventId); + } +} diff --git a/remote/cdp/domains/content/Log.sys.mjs b/remote/cdp/domains/content/Log.sys.mjs new file mode 100644 index 0000000000..f8c561d429 --- /dev/null +++ b/remote/cdp/domains/content/Log.sys.mjs @@ -0,0 +1,86 @@ +/* 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/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const CONSOLE_MESSAGE_LEVEL_MAP = { + [Ci.nsIConsoleMessage.debug]: "verbose", + [Ci.nsIConsoleMessage.info]: "info", + [Ci.nsIConsoleMessage.warn]: "warning", + [Ci.nsIConsoleMessage.error]: "error", +}; + +export class Log extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + enable() { + if (!this.enabled) { + this.enabled = true; + + Services.console.registerListener(this); + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + + Services.console.unregisterListener(this); + } + } + + _getLogCategory(category) { + if (category.startsWith("CORS")) { + return "network"; + } else if (category.includes("javascript")) { + return "javascript"; + } + + return "other"; + } + + // nsIObserver + + /** + * Takes all script error messages that do not have an exception attached, + * and emits a "Log.entryAdded" event. + * + * @param {nsIConsoleMessage} message + * Message originating from the nsIConsoleService. + */ + observe(message) { + if (message instanceof Ci.nsIScriptError && !message.hasException) { + let url; + if (message.sourceName !== "debugger eval code") { + url = message.sourceName; + } + + const entry = { + source: this._getLogCategory(message.category), + level: CONSOLE_MESSAGE_LEVEL_MAP[message.logLevel], + text: message.errorMessage, + timestamp: message.timeStamp, + url, + lineNumber: message.lineNumber, + }; + + this.emit("Log.entryAdded", { entry }); + } + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } +} diff --git a/remote/cdp/domains/content/Network.sys.mjs b/remote/cdp/domains/content/Network.sys.mjs new file mode 100644 index 0000000000..91dc44cf46 --- /dev/null +++ b/remote/cdp/domains/content/Network.sys.mjs @@ -0,0 +1,18 @@ +/* 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/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Network extends ContentProcessDomain { + // commands + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + _updateLoadFlags(flags) { + this.docShell.defaultLoadFlags = flags; + } +} diff --git a/remote/cdp/domains/content/Page.sys.mjs b/remote/cdp/domains/content/Page.sys.mjs new file mode 100644 index 0000000000..8e1abefe56 --- /dev/null +++ b/remote/cdp/domains/content/Page.sys.mjs @@ -0,0 +1,453 @@ +/* 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/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +const { LOAD_FLAGS_BYPASS_CACHE, LOAD_FLAGS_BYPASS_PROXY, LOAD_FLAGS_NONE } = + Ci.nsIWebNavigation; + +export class Page extends ContentProcessDomain { + constructor(session) { + super(session); + + this.enabled = false; + this.lifecycleEnabled = false; + // script id => { source, worldName } + this.scriptsToEvaluateOnLoad = new Map(); + this.worldsToEvaluateOnLoad = new Set(); + + // This map is used to keep a reference to the loader id for + // those Page events, which do not directly rely on + // Network events. This might be a temporary solution until + // the Network observer could be queried for that. But right + // now this lives in the parent process. + this.frameIdToLoaderId = new Map(); + + this._onFrameAttached = this._onFrameAttached.bind(this); + this._onFrameDetached = this._onFrameDetached.bind(this); + this._onFrameNavigated = this._onFrameNavigated.bind(this); + this._onScriptLoaded = this._onScriptLoaded.bind(this); + + this.session.contextObserver.on("script-loaded", this._onScriptLoaded); + } + + destructor() { + this.setLifecycleEventsEnabled({ enabled: false }); + this.session.contextObserver.off("script-loaded", this._onScriptLoaded); + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.session.contextObserver.on("frame-attached", this._onFrameAttached); + this.session.contextObserver.on("frame-detached", this._onFrameDetached); + this.session.contextObserver.on( + "frame-navigated", + this._onFrameNavigated + ); + + this.chromeEventHandler.addEventListener("readystatechange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.addEventListener("unload", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("DOMContentLoaded", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.addEventListener("hashchange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("load", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("pageshow", this, { + mozSystemGroup: true, + }); + + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.session.contextObserver.off("frame-attached", this._onFrameAttached); + this.session.contextObserver.off("frame-detached", this._onFrameDetached); + this.session.contextObserver.off( + "frame-navigated", + this._onFrameNavigated + ); + + this.chromeEventHandler.removeEventListener("readystatechange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("unload", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("DOMContentLoaded", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("hashchange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("load", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.enabled = false; + } + } + + async reload(options = {}) { + const { ignoreCache } = options; + let flags = LOAD_FLAGS_NONE; + if (ignoreCache) { + flags |= LOAD_FLAGS_BYPASS_CACHE; + flags |= LOAD_FLAGS_BYPASS_PROXY; + } + this.docShell.reload(flags); + } + + getFrameTree() { + const getFrames = context => { + const frameTree = { + frame: this._getFrameDetails({ context }), + }; + + if (context.children.length) { + const frames = []; + for (const childContext of context.children) { + frames.push(getFrames(childContext)); + } + frameTree.childFrames = frames; + } + + return frameTree; + }; + + return { + frameTree: getFrames(this.docShell.browsingContext), + }; + } + + /** + * Enqueues given script to be evaluated in every frame upon creation + * + * If `worldName` is specified, creates an execution context with the given name + * and evaluates given script in it. + * + * At this time, queued scripts do not get evaluated, hence `source` is marked as + * "unsupported". + * + * @param {object} options + * @param {string} options.source (not supported) + * @param {string=} options.worldName + * @returns {string} Page.ScriptIdentifier + */ + addScriptToEvaluateOnNewDocument(options = {}) { + const { source, worldName } = options; + if (worldName) { + this.worldsToEvaluateOnLoad.add(worldName); + } + const identifier = lazy.generateUUID(); + this.scriptsToEvaluateOnLoad.set(identifier, { worldName, source }); + + return { identifier }; + } + + /** + * Creates an isolated world for the given frame. + * + * Really it just creates an execution context with label "isolated". + * + * @param {object} options + * @param {string} options.frameId + * Id of the frame in which the isolated world should be created. + * @param {string=} options.worldName + * An optional name which is reported in the Execution Context. + * @param {boolean=} options.grantUniversalAccess (not supported) + * This is a powerful option, use with caution. + * + * @returns {number} Runtime.ExecutionContextId + * Execution context of the isolated world. + */ + createIsolatedWorld(options = {}) { + const { frameId, worldName } = options; + + if (typeof frameId != "string") { + throw new TypeError("frameId: string value expected"); + } + + if (!["undefined", "string"].includes(typeof worldName)) { + throw new TypeError("worldName: string value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + const contexts = Runtime._getContextsForFrame(frameId); + if (!contexts.length) { + throw new Error("No frame for given id found"); + } + + const defaultContext = Runtime._getDefaultContextForWindow( + contexts[0].windowId + ); + const window = defaultContext.window; + + const executionContextId = Runtime._onContextCreated("context-created", { + windowId: window.windowGlobalChild.innerWindowId, + window, + isDefault: false, + contextName: worldName, + contextType: "isolated", + }); + + return { executionContextId }; + } + + /** + * Controls whether page will emit lifecycle events. + * + * @param {object} options + * @param {boolean} options.enabled + * If true, starts emitting lifecycle events. + */ + setLifecycleEventsEnabled(options = {}) { + const { enabled } = options; + + this.lifecycleEnabled = enabled; + } + + url() { + return this.content.location.href; + } + + _onFrameAttached(name, { frameId, window }) { + const bc = BrowsingContext.get(frameId); + + // Don't emit for top-level browsing contexts + if (!bc.parent) { + return; + } + + // TODO: Use a unique identifier for frames (bug 1605359) + this.emit("Page.frameAttached", { + frameId: frameId.toString(), + parentFrameId: bc.parent.id.toString(), + stack: null, + }); + + // Usually both events are emitted when the "pagehide" event is received. + // But this wont happen for a new window or frame when the initial + // about:blank page has already loaded, and is being replaced with the + // final document. + if (!window.document.isInitialDocument) { + this.emit("Page.frameStartedLoading", { frameId: frameId.toString() }); + + const loaderId = this.frameIdToLoaderId.get(frameId); + const timestamp = Date.now() / 1000; + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + } + } + + _onFrameDetached(name, { frameId }) { + const bc = BrowsingContext.get(frameId); + + // Don't emit for top-level browsing contexts + if (!bc.parent) { + return; + } + + // TODO: Use a unique identifier for frames (bug 1605359) + this.emit("Page.frameDetached", { frameId: frameId.toString() }); + } + + _onFrameNavigated(name, { frameId }) { + const bc = BrowsingContext.get(frameId); + + this.emit("Page.frameNavigated", { + frame: this._getFrameDetails({ context: bc }), + }); + } + + /** + * @param {string} name + * The event name. + * @param {object=} options + * @param {number} options.windowId + * The inner window id of the window the script has been loaded for. + * @param {Window} options.window + * The window object of the document. + */ + _onScriptLoaded(name, options = {}) { + const { windowId, window } = options; + + const Runtime = this.session.domains.get("Runtime"); + for (const world of this.worldsToEvaluateOnLoad) { + Runtime._onContextCreated("context-created", { + windowId, + window, + isDefault: false, + contextName: world, + contextType: "isolated", + }); + } + // TODO evaluate each onNewDoc script in the appropriate world + } + + emitLifecycleEvent(frameId, loaderId, name, timestamp) { + if (this.lifecycleEnabled) { + this.emit("Page.lifecycleEvent", { + frameId: frameId.toString(), + loaderId, + name, + timestamp, + }); + } + } + + handleEvent({ type, target }) { + const timestamp = Date.now() / 1000; + + // Some events such as "hashchange" use the window as the target, while + // others have a document. + const win = Window.isInstance(target) ? target : target.defaultView; + const frameId = win.docShell.browsingContext.id; + const isFrame = !!win.docShell.browsingContext.parent; + const loaderId = this.frameIdToLoaderId.get(frameId); + const url = win.location.href; + + switch (type) { + case "DOMContentLoaded": + if (!isFrame) { + this.emit("Page.domContentEventFired", { timestamp }); + } + this.emitLifecycleEvent( + frameId, + loaderId, + "DOMContentLoaded", + timestamp + ); + break; + + case "hashchange": + this.emit("Page.navigatedWithinDocument", { + frameId: frameId.toString(), + url, + }); + break; + + case "pagehide": + // Maybe better to bound to "unload" once we can register for this event + this.emit("Page.frameStartedLoading", { frameId: frameId.toString() }); + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + break; + + case "load": + if (!isFrame) { + this.emit("Page.loadEventFired", { timestamp }); + } + this.emitLifecycleEvent(frameId, loaderId, "load", timestamp); + + // XXX this should most likely be sent differently + this.emit("Page.frameStoppedLoading", { frameId: frameId.toString() }); + break; + + case "readystatechange": + if (this.content.document.readyState === "loading") { + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + } + } + } + + _updateLoaderId(data) { + const { frameId, loaderId } = data; + + this.frameIdToLoaderId.set(frameId, loaderId); + } + + _contentRect() { + const docEl = this.content.document.documentElement; + + return { + x: 0, + y: 0, + width: docEl.scrollWidth, + height: docEl.scrollHeight, + }; + } + + _devicePixelRatio() { + return ( + this.content.browsingContext.overrideDPPX || this.content.devicePixelRatio + ); + } + + _getFrameDetails({ context, id }) { + const bc = context || BrowsingContext.get(id); + const frame = bc.embedderElement; + + return { + id: bc.id.toString(), + parentId: bc.parent?.id.toString(), + loaderId: this.frameIdToLoaderId.get(bc.id), + url: bc.docShell.domWindow.location.href, + name: frame?.id || frame?.name, + securityOrigin: null, + mimeType: null, + }; + } + + _getScrollbarSize() { + const scrollbarHeight = {}; + const scrollbarWidth = {}; + + this.content.windowUtils.getScrollbarSize( + false, + scrollbarWidth, + scrollbarHeight + ); + + return { + width: scrollbarWidth.value, + height: scrollbarHeight.value, + }; + } + + _layoutViewport() { + const scrollbarSize = this._getScrollbarSize(); + + return { + pageX: this.content.pageXOffset, + pageY: this.content.pageYOffset, + clientWidth: this.content.innerWidth - scrollbarSize.width, + clientHeight: this.content.innerHeight - scrollbarSize.height, + }; + } +} diff --git a/remote/cdp/domains/content/Performance.sys.mjs b/remote/cdp/domains/content/Performance.sys.mjs new file mode 100644 index 0000000000..e5726725b5 --- /dev/null +++ b/remote/cdp/domains/content/Performance.sys.mjs @@ -0,0 +1,32 @@ +/* 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/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Performance extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } +} diff --git a/remote/cdp/domains/content/Runtime.sys.mjs b/remote/cdp/domains/content/Runtime.sys.mjs new file mode 100644 index 0000000000..35e0f16710 --- /dev/null +++ b/remote/cdp/domains/content/Runtime.sys.mjs @@ -0,0 +1,641 @@ +/* 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/. */ + +import { addDebuggerToGlobal } from "resource://gre/modules/jsdebugger.sys.mjs"; + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + executeSoon: "chrome://remote/content/shared/Sync.sys.mjs", + isChromeFrame: "chrome://remote/content/shared/Stack.sys.mjs", + ExecutionContext: + "chrome://remote/content/cdp/domains/content/runtime/ExecutionContext.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => { + return Cc["@mozilla.org/consoleAPI-storage;1"].getService( + Ci.nsIConsoleAPIStorage + ); +}); + +// Import the `Debugger` constructor in the current scope +// eslint-disable-next-line mozilla/reject-globalThis-modification +addDebuggerToGlobal(globalThis); + +const CONSOLE_API_LEVEL_MAP = { + warn: "warning", +}; + +// Bug 1786299: Puppeteer needs specific error messages. +const ERROR_CONTEXT_NOT_FOUND = "Cannot find context with specified id"; + +class SetMap extends Map { + constructor() { + super(); + this._count = 1; + } + // Every key in the map is associated with a Set. + // The first time `key` is used `obj.set(key, value)` maps `key` to + // to `Set(value)`. Subsequent calls add more values to the Set for `key`. + // Note that `obj.get(key)` will return undefined if there's no such key, + // as in a regular Map. + set(key, value) { + const innerSet = this.get(key); + if (innerSet) { + innerSet.add(value); + } else { + super.set(key, new Set([value])); + } + this._count++; + return this; + } + // used as ExecutionContext id + get count() { + return this._count; + } +} + +export class Runtime extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + + // Map of all the ExecutionContext instances: + // [id (Number) => ExecutionContext instance] + this.contexts = new Map(); + // [innerWindowId (Number) => Set of ExecutionContext instances] + this.innerWindowIdToContexts = new SetMap(); + + this._onContextCreated = this._onContextCreated.bind(this); + this._onContextDestroyed = this._onContextDestroyed.bind(this); + + // TODO Bug 1602083 + this.session.contextObserver.on("context-created", this._onContextCreated); + this.session.contextObserver.on( + "context-destroyed", + this._onContextDestroyed + ); + } + + destructor() { + this.disable(); + + this.session.contextObserver.off("context-created", this._onContextCreated); + this.session.contextObserver.off( + "context-destroyed", + this._onContextDestroyed + ); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + + Services.console.registerListener(this); + this.onConsoleLogEvent = this.onConsoleLogEvent.bind(this); + lazy.ConsoleAPIStorage.addLogEventListener( + this.onConsoleLogEvent, + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + + // Spin the event loop in order to send the `executionContextCreated` event right + // after we replied to `enable` request. + lazy.executeSoon(() => { + this._onContextCreated("context-created", { + windowId: this.content.windowGlobalChild.innerWindowId, + window: this.content, + isDefault: true, + }); + + for (const message of lazy.ConsoleAPIStorage.getEvents()) { + this.onConsoleLogEvent(message); + } + }); + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + + Services.console.unregisterListener(this); + lazy.ConsoleAPIStorage.removeLogEventListener(this.onConsoleLogEvent); + } + } + + releaseObject(options = {}) { + const { objectId } = options; + + let context = null; + for (const ctx of this.contexts.values()) { + if (ctx.hasRemoteObject(objectId)) { + context = ctx; + break; + } + } + if (!context) { + throw new Error(ERROR_CONTEXT_NOT_FOUND); + } + context.releaseObject(objectId); + } + + /** + * Calls function with given declaration on the given object. + * + * Object group of the result is inherited from the target object. + * + * @param {object} options + * @param {string} options.functionDeclaration + * Declaration of the function to call. + * @param {Array.<object>=} options.arguments + * Call arguments. All call arguments must belong to the same + * JavaScript world as the target object. + * @param {boolean=} options.awaitPromise + * Whether execution should `await` for resulting value + * and return once awaited promise is resolved. + * @param {number=} options.executionContextId + * Specifies execution context which global object will be used + * to call function on. Either executionContextId or objectId + * should be specified. + * @param {string=} options.objectId + * Identifier of the object to call function on. + * Either objectId or executionContextId should be specified. + * @param {boolean=} options.returnByValue + * Whether the result is expected to be a JSON object + * which should be sent by value. + * + * @returns {Object<RemoteObject, ExceptionDetails>} + */ + callFunctionOn(options = {}) { + if (typeof options.functionDeclaration != "string") { + throw new TypeError("functionDeclaration: string value expected"); + } + if ( + typeof options.arguments != "undefined" && + !Array.isArray(options.arguments) + ) { + throw new TypeError("arguments: array value expected"); + } + if (!["undefined", "boolean"].includes(typeof options.awaitPromise)) { + throw new TypeError("awaitPromise: boolean value expected"); + } + if (!["undefined", "number"].includes(typeof options.executionContextId)) { + throw new TypeError("executionContextId: number value expected"); + } + if (!["undefined", "string"].includes(typeof options.objectId)) { + throw new TypeError("objectId: string value expected"); + } + if (!["undefined", "boolean"].includes(typeof options.returnByValue)) { + throw new TypeError("returnByValue: boolean value expected"); + } + + if ( + typeof options.executionContextId == "undefined" && + typeof options.objectId == "undefined" + ) { + throw new Error( + "Either objectId or executionContextId must be specified" + ); + } + + let context = null; + // When an `objectId` is passed, we want to execute the function of a given object + // So we first have to find its ExecutionContext + if (options.objectId) { + for (const ctx of this.contexts.values()) { + if (ctx.hasRemoteObject(options.objectId)) { + context = ctx; + break; + } + } + } else { + context = this.contexts.get(options.executionContextId); + } + + if (!context) { + throw new Error(ERROR_CONTEXT_NOT_FOUND); + } + + return context.callFunctionOn( + options.functionDeclaration, + options.arguments, + options.returnByValue, + options.awaitPromise, + options.objectId + ); + } + + /** + * Evaluate expression on global object. + * + * @param {object} options + * @param {string} options.expression + * Expression to evaluate. + * @param {boolean=} options.awaitPromise + * Whether execution should `await` for resulting value + * and return once awaited promise is resolved. + * @param {number=} options.contextId + * Specifies in which execution context to perform evaluation. + * If the parameter is omitted the evaluation will be performed + * in the context of the inspected page. + * @param {boolean=} options.returnByValue + * Whether the result is expected to be a JSON object + * that should be sent by value. Defaults to false. + * @param {boolean=} options.userGesture [unsupported] + * Whether execution should be treated as initiated by user in the UI. + * + * @returns {Object<RemoteObject, exceptionDetails>} + * The evaluation result, and optionally exception details. + */ + evaluate(options = {}) { + const { + expression, + awaitPromise = false, + contextId, + returnByValue = false, + } = options; + + if (typeof expression != "string") { + throw new Error("expression: string value expected"); + } + if (!["undefined", "boolean"].includes(typeof options.awaitPromise)) { + throw new TypeError("awaitPromise: boolean value expected"); + } + if (typeof returnByValue != "boolean") { + throw new Error("returnByValue: boolean value expected"); + } + + let context; + if (typeof contextId != "undefined") { + context = this.contexts.get(contextId); + if (!context) { + throw new Error(ERROR_CONTEXT_NOT_FOUND); + } + } else { + context = this._getDefaultContextForWindow(); + } + + return context.evaluate(expression, awaitPromise, returnByValue); + } + + getProperties(options = {}) { + const { objectId, ownProperties } = options; + + for (const ctx of this.contexts.values()) { + const debuggerObj = ctx.getRemoteObject(objectId); + if (debuggerObj) { + return ctx.getProperties({ objectId, ownProperties }); + } + } + return null; + } + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + get _debugger() { + if (this.__debugger) { + return this.__debugger; + } + this.__debugger = new Debugger(); + return this.__debugger; + } + + _buildExceptionStackTrace(stack) { + const callFrames = []; + + while ( + stack && + stack.source !== "debugger eval code" && + !stack.source.startsWith("chrome://") + ) { + callFrames.push({ + functionName: stack.functionDisplayName, + scriptId: stack.sourceId.toString(), + url: stack.source, + lineNumber: stack.line - 1, + columnNumber: stack.column - 1, + }); + stack = stack.parent || stack.asyncParent; + } + + return { + callFrames, + }; + } + + _buildConsoleStackTrace(stack = []) { + const callFrames = stack + .filter(frame => !lazy.isChromeFrame(frame)) + .map(frame => { + return { + functionName: frame.functionName, + scriptId: frame.sourceId.toString(), + url: frame.filename, + lineNumber: frame.lineNumber - 1, + columnNumber: frame.columnNumber - 1, + }; + }); + + return { + callFrames, + }; + } + + _getRemoteObject(objectId) { + for (const ctx of this.contexts.values()) { + const debuggerObj = ctx.getRemoteObject(objectId); + if (debuggerObj) { + return debuggerObj; + } + } + return null; + } + + _serializeRemoteObject(debuggerObj, executionContextId) { + const ctx = this.contexts.get(executionContextId); + return ctx._toRemoteObject(debuggerObj); + } + + _getRemoteObjectByNodeId(nodeId, executionContextId) { + let debuggerObj = null; + + if (typeof executionContextId != "undefined") { + const ctx = this.contexts.get(executionContextId); + debuggerObj = ctx.getRemoteObjectByNodeId(nodeId); + } else { + for (const ctx of this.contexts.values()) { + const obj = ctx.getRemoteObjectByNodeId(nodeId); + if (obj) { + debuggerObj = obj; + break; + } + } + } + + return debuggerObj; + } + + _setRemoteObject(debuggerObj, context) { + return context.setRemoteObject(debuggerObj); + } + + _getDefaultContextForWindow(innerWindowId) { + if (!innerWindowId) { + innerWindowId = this.content.windowGlobalChild.innerWindowId; + } + const curContexts = this.innerWindowIdToContexts.get(innerWindowId); + if (curContexts) { + for (const ctx of curContexts) { + if (ctx.isDefault) { + return ctx; + } + } + } + return null; + } + + _getContextsForFrame(frameId) { + const frameContexts = []; + for (const ctx of this.contexts.values()) { + if (ctx.frameId == frameId) { + frameContexts.push(ctx); + } + } + return frameContexts; + } + + _emitConsoleAPICalled(payload) { + // Filter out messages that aren't coming from a valid inner window, or from + // a different browser tab. Also messages of type "time", which are not + // getting reported by Chrome. + const curBrowserId = this.session.browsingContext.browserId; + const win = Services.wm.getCurrentInnerWindowWithId(payload.innerWindowId); + if ( + !win || + BrowsingContext.getFromWindow(win).browserId != curBrowserId || + payload.type === "time" + ) { + return; + } + + const context = this._getDefaultContextForWindow(); + this.emit("Runtime.consoleAPICalled", { + args: payload.arguments.map(arg => context._toRemoteObject(arg)), + executionContextId: context?.id || 0, + timestamp: payload.timestamp, + type: payload.type, + stackTrace: this._buildConsoleStackTrace(payload.stack), + }); + } + + _emitExceptionThrown(payload) { + // Filter out messages that aren't coming from a valid inner window, or from + // a different browser tab. Also messages of type "time", which are not + // getting reported by Chrome. + const curBrowserId = this.session.browsingContext.browserId; + const win = Services.wm.getCurrentInnerWindowWithId(payload.innerWindowId); + if (!win || BrowsingContext.getFromWindow(win).browserId != curBrowserId) { + return; + } + + const context = this._getDefaultContextForWindow(); + this.emit("Runtime.exceptionThrown", { + timestamp: payload.timestamp, + exceptionDetails: { + // Temporary placeholder to return a number. + exceptionId: 0, + text: payload.text, + lineNumber: payload.lineNumber, + columnNumber: payload.columnNumber, + url: payload.url, + stackTrace: this._buildExceptionStackTrace(payload.stack), + executionContextId: context?.id || undefined, + }, + }); + } + + /** + * Helper method in order to instantiate the ExecutionContext for a given + * DOM Window as well as emitting the related + * `Runtime.executionContextCreated` event + * + * @param {string} name + * Event name + * @param {object=} options + * @param {number} options.windowId + * The inner window id of the newly instantiated document. + * @param {Window} options.window + * The window object of the newly instantiated document. + * @param {string=} options.contextName + * Human-readable name to describe the execution context. + * @param {boolean=} options.isDefault + * Whether the execution context is the default one. + * @param {string=} options.contextType + * "default" or "isolated" + * + * @returns {number} ID of created context + * + */ + _onContextCreated(name, options = {}) { + const { + windowId, + window, + contextName = "", + isDefault = true, + contextType = "default", + } = options; + + if (windowId === undefined) { + throw new Error("windowId is required"); + } + + // allow only one default context per inner window + if (isDefault && this.innerWindowIdToContexts.has(windowId)) { + for (const ctx of this.innerWindowIdToContexts.get(windowId)) { + if (ctx.isDefault) { + return null; + } + } + } + + const context = new lazy.ExecutionContext( + this._debugger, + window, + this.innerWindowIdToContexts.count, + isDefault + ); + this.contexts.set(context.id, context); + this.innerWindowIdToContexts.set(windowId, context); + + if (this.enabled) { + this.emit("Runtime.executionContextCreated", { + context: { + id: context.id, + origin: window.origin, + name: contextName, + auxData: { + isDefault, + frameId: context.frameId, + type: contextType, + }, + }, + }); + } + + return context.id; + } + + /** + * Helper method to destroy the ExecutionContext of the given id. Also emit + * the related `Runtime.executionContextDestroyed` and + * `Runtime.executionContextsCleared` events. + * ContextObserver will call this method with either `id` or `frameId` argument + * being set. + * + * @param {string} name + * Event name + * @param {object=} options + * @param {number} options.id + * The execution context id to destroy. + * @param {number} options.windowId + * The inner-window id of the execution context to destroy. + * @param {number} options.frameId + * The frame id of execution context to destroy. + * Either `id` or `frameId` or `windowId` is passed. + */ + _onContextDestroyed(name, { id, frameId, windowId }) { + let contexts; + if ([id, frameId, windowId].filter(id => !!id).length > 1) { + throw new Error("Expects only *one* of id, frameId, windowId"); + } + + if (id) { + contexts = [this.contexts.get(id)]; + } else if (frameId) { + contexts = this._getContextsForFrame(frameId); + } else { + contexts = this.innerWindowIdToContexts.get(windowId) || []; + } + + for (const ctx of contexts) { + const isFrame = !!BrowsingContext.get(ctx.frameId).parent; + + ctx.destructor(); + this.contexts.delete(ctx.id); + this.innerWindowIdToContexts.get(ctx.windowId).delete(ctx); + + if (this.enabled) { + this.emit("Runtime.executionContextDestroyed", { + executionContextId: ctx.id, + }); + } + + if (this.innerWindowIdToContexts.get(ctx.windowId).size == 0) { + this.innerWindowIdToContexts.delete(ctx.windowId); + // Only emit when all the exeuction contexts were cleared for the + // current browser / target, which means it should only be emitted + // for a top-level browsing context reference. + if (this.enabled && !isFrame) { + this.emit("Runtime.executionContextsCleared"); + } + } + } + } + + onConsoleLogEvent(message) { + // From sendConsoleAPIMessage (toolkit/modules/Console.sys.mjs) + this._emitConsoleAPICalled({ + arguments: message.arguments, + innerWindowId: message.innerID, + stack: message.stacktrace, + timestamp: message.timeStamp, + type: CONSOLE_API_LEVEL_MAP[message.level] || message.level, + }); + } + + // nsIObserver + + /** + * Takes a console message belonging to the current window and emits a + * "exceptionThrown" event if it's a Javascript error, otherwise a + * "consoleAPICalled" event. + * + * @param {nsIConsoleMessage} subject + * Console message. + */ + observe(subject, topic, data) { + if (subject instanceof Ci.nsIScriptError && subject.hasException) { + let entry = fromScriptError(subject); + this._emitExceptionThrown(entry); + } + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } +} + +function fromScriptError(error) { + // From dom/bindings/nsIScriptError.idl + return { + innerWindowId: error.innerWindowID, + columnNumber: error.columnNumber - 1, + lineNumber: error.lineNumber - 1, + stack: error.stack, + text: error.errorMessage, + timestamp: error.timeStamp, + url: error.sourceName, + }; +} diff --git a/remote/cdp/domains/content/Security.sys.mjs b/remote/cdp/domains/content/Security.sys.mjs new file mode 100644 index 0000000000..7d21d386b4 --- /dev/null +++ b/remote/cdp/domains/content/Security.sys.mjs @@ -0,0 +1,32 @@ +/* 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/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Security extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } +} diff --git a/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs b/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs new file mode 100644 index 0000000000..4d394f6bf9 --- /dev/null +++ b/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs @@ -0,0 +1,564 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +const TYPED_ARRAY_CLASSES = [ + "Uint8Array", + "Uint8ClampedArray", + "Uint16Array", + "Uint32Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Float32Array", + "Float64Array", +]; + +// Bug 1786299: Puppeteer expects specific error messages. +const ERROR_CYCLIC_REFERENCE = "Object reference chain is too long"; +const ERROR_CANNOT_RETURN_BY_VALUE = "Object couldn't be returned by value"; + +function randomInt() { + return crypto.getRandomValues(new Uint32Array(1))[0]; +} + +/** + * This class represent a debuggable context onto which we can evaluate Javascript. + * This is typically a document, but it could also be a worker, an add-on, ... or + * any kind of context involving JS scripts. + * + * @param {Debugger} dbg + * A Debugger instance that we can use to inspect the given global. + * @param {GlobalObject} debuggee + * The debuggable context's global object. This is typically the document window + * object. But it can also be any global object, like a worker global scope object. + */ +export class ExecutionContext { + constructor(dbg, debuggee, id, isDefault) { + this._debugger = dbg; + this._debuggee = this._debugger.addDebuggee(debuggee); + + // Here, we assume that debuggee is a window object and we will propably have + // to adapt that once we cover workers or contexts that aren't a document. + this.window = debuggee; + this.windowId = this.window.windowGlobalChild.innerWindowId; + this.id = id; + this.frameId = this.window.browsingContext.id.toString(); + this.isDefault = isDefault; + + // objectId => Debugger.Object + this._remoteObjects = new Map(); + } + + destructor() { + this._debugger.removeDebuggee(this._debuggee); + } + + get browsingContext() { + return this.window.browsingContext; + } + + hasRemoteObject(objectId) { + return this._remoteObjects.has(objectId); + } + + getRemoteObject(objectId) { + return this._remoteObjects.get(objectId); + } + + getRemoteObjectByNodeId(nodeId) { + for (const value of this._remoteObjects.values()) { + if (value.nodeId == nodeId) { + return value; + } + } + + return null; + } + + releaseObject(objectId) { + return this._remoteObjects.delete(objectId); + } + + /** + * Add a new debuggerObj to the object cache. + * + * Whenever an object is returned as reference, a new entry is added + * to the internal object cache. It means the same underlying object or node + * can be represented via multiple references. + */ + setRemoteObject(debuggerObj) { + const objectId = lazy.generateUUID(); + + // TODO: Wrap Symbol into an object, + // which would allow us to set the objectId. + if (typeof debuggerObj == "object") { + debuggerObj.objectId = objectId; + } + + // For node objects add an unique identifier. + if ( + debuggerObj instanceof Debugger.Object && + Node.isInstance(debuggerObj.unsafeDereference()) + ) { + debuggerObj.nodeId = randomInt(); + // We do not differentiate between backendNodeId and nodeId (yet) + debuggerObj.backendNodeId = debuggerObj.nodeId; + } + + this._remoteObjects.set(objectId, debuggerObj); + + return objectId; + } + + /** + * Evaluate a Javascript expression. + * + * @param {string} expression + * The JS expression to evaluate against the JS context. + * @param {boolean} awaitPromise + * Whether execution should `await` for resulting value + * and return once awaited promise is resolved. + * @param {boolean} returnByValue + * Whether the result is expected to be a JSON object + * that should be sent by value. + * + * @returns {object} A multi-form object depending if the execution + * succeed or failed. If the expression failed to evaluate, + * it will return an object with an `exceptionDetails` attribute + * matching the `ExceptionDetails` CDP type. Otherwise it will + * return an object with `result` attribute whose type is + * `RemoteObject` CDP type. + */ + async evaluate(expression, awaitPromise, returnByValue) { + let rv = this._debuggee.executeInGlobal(expression); + if (!rv) { + return { + exceptionDetails: { + text: "Evaluation terminated!", + }, + }; + } + + if (rv.throw) { + return this._returnError(rv.throw); + } + + let result = rv.return; + + if (result && result.isPromise && awaitPromise) { + if (result.promiseState === "fulfilled") { + result = result.promiseValue; + } else if (result.promiseState === "rejected") { + return this._returnError(result.promiseReason); + } else { + try { + const promiseResult = await result.unsafeDereference(); + result = this._debuggee.makeDebuggeeValue(promiseResult); + } catch (e) { + // The promise has been rejected + return this._returnError(this._debuggee.makeDebuggeeValue(e)); + } + } + } + + if (returnByValue) { + result = this._toRemoteObjectByValue(result); + } else { + result = this._toRemoteObject(result); + } + + return { result }; + } + + /** + * Given a Debugger.Object reference for an Exception, return a JSON object + * describing the exception by following CDP ExceptionDetails specification. + */ + _returnError(exception) { + if ( + this._debuggee.executeInGlobalWithBindings("exception instanceof Error", { + exception, + }).return + ) { + const text = this._debuggee.executeInGlobalWithBindings( + "exception.message", + { exception } + ).return; + return { + exceptionDetails: { + text, + }, + }; + } + + // If that isn't an Error, consider the exception as a JS value + return { + exceptionDetails: { + exception: this._toRemoteObject(exception), + }, + }; + } + + async callFunctionOn( + functionDeclaration, + callArguments = [], + returnByValue = false, + awaitPromise = false, + objectId = null + ) { + // Map the given objectId to a JS reference. + let thisArg = null; + if (objectId) { + thisArg = this.getRemoteObject(objectId); + if (!thisArg) { + throw new Error(`Unable to get target object with id: ${objectId}`); + } + } + + // First evaluate the function + const fun = this._debuggee.executeInGlobal("(" + functionDeclaration + ")"); + if (!fun) { + return { + exceptionDetails: { + text: "Evaluation terminated!", + }, + }; + } + if (fun.throw) { + return this._returnError(fun.throw); + } + + // Then map all input arguments, which are matching CDP's CallArguments type, + // into JS values + const args = callArguments.map(arg => this._fromCallArgument(arg)); + + // Finally, call the function with these arguments + const rv = fun.return.apply(thisArg, args); + if (rv.throw) { + return this._returnError(rv.throw); + } + + let result = rv.return; + + if (result && result.isPromise && awaitPromise) { + if (result.promiseState === "fulfilled") { + result = result.promiseValue; + } else if (result.promiseState === "rejected") { + return this._returnError(result.promiseReason); + } else { + try { + const promiseResult = await result.unsafeDereference(); + result = this._debuggee.makeDebuggeeValue(promiseResult); + } catch (e) { + // The promise has been rejected + return this._returnError(this._debuggee.makeDebuggeeValue(e)); + } + } + } + + if (returnByValue) { + result = this._toRemoteObjectByValue(result); + } else { + result = this._toRemoteObject(result); + } + + return { result }; + } + + getProperties({ objectId, ownProperties }) { + let debuggerObj = this.getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error("Could not find object with given id"); + } + + const result = []; + const serializeObject = (debuggerObj, isOwn) => { + for (const propertyName of debuggerObj.getOwnPropertyNames()) { + const descriptor = debuggerObj.getOwnPropertyDescriptor(propertyName); + result.push({ + name: propertyName, + + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + writable: descriptor.writable, + value: this._toRemoteObject(descriptor.value), + get: descriptor.get + ? this._toRemoteObject(descriptor.get) + : undefined, + set: descriptor.set + ? this._toRemoteObject(descriptor.set) + : undefined, + + isOwn, + }); + } + }; + + // When `ownProperties` is set to true, we only iterate over own properties. + // Otherwise, we also iterate over propreties inherited from the prototype chain. + serializeObject(debuggerObj, true); + + if (!ownProperties) { + while (true) { + debuggerObj = debuggerObj.proto; + if (!debuggerObj) { + break; + } + serializeObject(debuggerObj, false); + } + } + + return { + result, + }; + } + + /** + * Given a CDP `CallArgument`, return a JS value that represent this argument. + * Note that `CallArgument` is actually very similar to `RemoteObject` + */ + _fromCallArgument(arg) { + if (arg.objectId) { + if (!this.hasRemoteObject(arg.objectId)) { + throw new Error("Could not find object with given id"); + } + return this.getRemoteObject(arg.objectId); + } + + if (arg.unserializableValue) { + switch (arg.unserializableValue) { + case "-0": + return -0; + case "Infinity": + return Infinity; + case "-Infinity": + return -Infinity; + case "NaN": + return NaN; + default: + if (/^\d+n$/.test(arg.unserializableValue)) { + // eslint-disable-next-line no-undef + return BigInt(arg.unserializableValue.slice(0, -1)); + } + throw new Error("Couldn't parse value object in call argument"); + } + } + + return this._deserialize(arg.value); + } + + /** + * Given a JS value, create a copy of it within the debugee compartment. + */ + _deserialize(obj) { + if (typeof obj !== "object") { + return obj; + } + const result = this._debuggee.executeInGlobalWithBindings( + "JSON.parse(obj)", + { obj: JSON.stringify(obj) } + ); + if (result.throw) { + throw new Error("Unable to deserialize object"); + } + return result.return; + } + + /** + * Given a `Debugger.Object` object, return a JSON-serializable description of it + * matching `RemoteObject` CDP type. + * + * @param {Debugger.Object} debuggerObj + * The object to serialize + * @returns {RemoteObject} + * The serialized description of the given object + */ + _toRemoteObject(debuggerObj) { + const result = {}; + + // First handle all non-primitive values which are going to be wrapped by the + // Debugger API into Debugger.Object instances + if (debuggerObj instanceof Debugger.Object) { + const rawObj = debuggerObj.unsafeDereference(); + + result.objectId = this.setRemoteObject(debuggerObj); + result.type = typeof rawObj; + + // Map the Debugger API `class` attribute to CDP `subtype` + const cls = debuggerObj.class; + if (debuggerObj.isProxy) { + result.subtype = "proxy"; + } else if (cls == "Array") { + result.subtype = "array"; + } else if (cls == "RegExp") { + result.subtype = "regexp"; + } else if (cls == "Date") { + result.subtype = "date"; + } else if (cls == "Map") { + result.subtype = "map"; + } else if (cls == "Set") { + result.subtype = "set"; + } else if (cls == "WeakMap") { + result.subtype = "weakmap"; + } else if (cls == "WeakSet") { + result.subtype = "weakset"; + } else if (cls == "Error") { + result.subtype = "error"; + } else if (cls == "Promise") { + result.subtype = "promise"; + } else if (TYPED_ARRAY_CLASSES.includes(cls)) { + result.subtype = "typedarray"; + } else if (Node.isInstance(rawObj)) { + result.subtype = "node"; + result.className = ChromeUtils.getClassName(rawObj); + result.description = rawObj.localName || rawObj.nodeName; + if (rawObj.id) { + result.description += `#${rawObj.id}`; + } + } + return result; + } + + // Now, handle all values that Debugger API isn't wrapping into Debugger.API. + // This is all the primitive JS types. + result.type = typeof debuggerObj; + + // Symbol and BigInt are primitive values but aren't serializable. + // CDP expects them to be considered as objects, with an objectId to later inspect + // them. + if (result.type == "symbol") { + result.description = debuggerObj.toString(); + result.objectId = this.setRemoteObject(debuggerObj); + + return result; + } + + // A few primitive type can't be serialized and CDP has special case for them + if (Object.is(debuggerObj, NaN)) { + result.unserializableValue = "NaN"; + } else if (Object.is(debuggerObj, -0)) { + result.unserializableValue = "-0"; + } else if (Object.is(debuggerObj, Infinity)) { + result.unserializableValue = "Infinity"; + } else if (Object.is(debuggerObj, -Infinity)) { + result.unserializableValue = "-Infinity"; + } else if (result.type == "bigint") { + result.unserializableValue = `${debuggerObj}n`; + } + + if (result.unserializableValue) { + result.description = result.unserializableValue; + return result; + } + + // Otherwise, we serialize the primitive values as-is via `value` attribute + result.value = debuggerObj; + + // null is special as it has a dedicated subtype + if (debuggerObj === null) { + result.subtype = "null"; + } + + return result; + } + + /** + * Given a `Debugger.Object` object, return a JSON-serializable description of it + * matching `RemoteObject` CDP type. + * + * @param {Debugger.Object} debuggerObj + * The object to serialize + * @returns {RemoteObject} + * The serialized description of the given object + */ + _toRemoteObjectByValue(debuggerObj) { + const type = typeof debuggerObj; + + if (type == "undefined") { + return { type }; + } + + let unserializableValue; + if (Object.is(debuggerObj, -0)) { + unserializableValue = "-0"; + } else if (Object.is(debuggerObj, NaN)) { + unserializableValue = "NaN"; + } else if (Object.is(debuggerObj, Infinity)) { + unserializableValue = "Infinity"; + } else if (Object.is(debuggerObj, -Infinity)) { + unserializableValue = "-Infinity"; + } else if (typeof debuggerObj == "bigint") { + unserializableValue = `${debuggerObj}n`; + } + + if (unserializableValue) { + return { + type, + unserializableValue, + description: unserializableValue, + }; + } + + const value = this._serialize(debuggerObj); + return { + type: typeof value, + value, + description: value != null ? value.toString() : value, + }; + } + + /** + * Convert a given `Debugger.Object` to an object. + * + * @param {Debugger.Object} debuggerObj + * The object to convert + * + * @returns {object} + * The converted object + */ + _serialize(debuggerObj) { + const result = this._debuggee.executeInGlobalWithBindings( + ` + JSON.stringify(e, (key, value) => { + if (typeof value === "symbol") { + // CDP cannot return Symbols + throw new Error(); + } + + return value; + }); + `, + { e: debuggerObj } + ); + if (result.throw) { + const exception = this._toRawObject(result.throw); + if (exception.message === "cyclic object value") { + throw new Error(ERROR_CYCLIC_REFERENCE); + } + + throw new Error(ERROR_CANNOT_RETURN_BY_VALUE); + } + + return JSON.parse(result.return); + } + + _toRawObject(maybeDebuggerObject) { + if (maybeDebuggerObject instanceof Debugger.Object) { + // Retrieve the referent for the provided Debugger.object. + // See https://firefox-source-docs.mozilla.org/devtools-user/debugger-api/debugger.object/index.html + const rawObject = maybeDebuggerObject.unsafeDereference(); + return Cu.waiveXrays(rawObject); + } + + // If maybeDebuggerObject was not a Debugger.Object, it is a primitive value + // which can be used as is. + return maybeDebuggerObject; + } +} diff --git a/remote/cdp/domains/parent/Browser.sys.mjs b/remote/cdp/domains/parent/Browser.sys.mjs new file mode 100644 index 0000000000..ecf93d4d8d --- /dev/null +++ b/remote/cdp/domains/parent/Browser.sys.mjs @@ -0,0 +1,40 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +export class Browser extends Domain { + getVersion() { + const { isHeadless } = Cc["@mozilla.org/gfx/info;1"].getService( + Ci.nsIGfxInfo + ); + const { userAgent } = Cc[ + "@mozilla.org/network/protocol;1?name=http" + ].getService(Ci.nsIHttpProtocolHandler); + return { + jsVersion: Services.appinfo.version, + protocolVersion: "1.3", + product: + (isHeadless ? "Headless" : "") + + `${Services.appinfo.name}/${Services.appinfo.version}`, + revision: Services.appinfo.sourceURL.split("/").pop(), + userAgent, + }; + } + + close() { + // Notify all windows that an application quit has been requested. + const cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested"); + + // If the shutdown of the application is prevented force quit it instead. + const mode = cancelQuit.data + ? Ci.nsIAppStartup.eForceQuit + : Ci.nsIAppStartup.eAttemptQuit; + + Services.startup.quit(mode); + } +} diff --git a/remote/cdp/domains/parent/Emulation.sys.mjs b/remote/cdp/domains/parent/Emulation.sys.mjs new file mode 100644 index 0000000000..21aaf2f965 --- /dev/null +++ b/remote/cdp/domains/parent/Emulation.sys.mjs @@ -0,0 +1,175 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", +}); + +const MAX_WINDOW_SIZE = 10000000; + +export class Emulation extends Domain { + destructor() { + this.setUserAgentOverride({ userAgent: "", platform: "" }); + + super.destructor(); + } + + /** + * Overrides the values of device screen dimensions. + * + * Values as modified are: + * - window.screen.width + * - window.screen.height + * - window.innerWidth + * - window.innerHeight + * - "device-width"/"device-height"-related CSS media query results + * + * @param {object} options + * @param {number} options.width + * Overriding width value in pixels. 0 disables the override. + * @param {number} options.height + * Overriding height value in pixels. 0 disables the override. + * @param {number} options.deviceScaleFactor + * Overriding device scale factor value. 0 disables the override. + * @param {number} options.mobile [not supported] + * Whether to emulate a mobile device. This includes viewport meta tag, + * overlay scrollbars, text autosizing and more. + * @param {number} options.screenOrientation + * Screen orientation override [not supported] + */ + async setDeviceMetricsOverride(options = {}) { + const { width, height, deviceScaleFactor } = options; + + if ( + width < 0 || + width > MAX_WINDOW_SIZE || + height < 0 || + height > MAX_WINDOW_SIZE + ) { + throw new TypeError( + `Width and height values must be positive, not greater than ${MAX_WINDOW_SIZE}` + ); + } + + if (typeof deviceScaleFactor != "number") { + throw new TypeError("deviceScaleFactor: number expected"); + } + + if (deviceScaleFactor < 0) { + throw new TypeError("deviceScaleFactor: must be positive"); + } + + const { tab } = this.session.target; + const { linkedBrowser: browser } = tab; + + const { browsingContext } = this.session.target; + browsingContext.overrideDPPX = deviceScaleFactor; + + // With a value of 0 the current size is used + const { layoutViewport } = await this.session.execute( + this.session.id, + "Page", + "getLayoutMetrics" + ); + + const targetWidth = width > 0 ? width : layoutViewport.clientWidth; + const targetHeight = height > 0 ? height : layoutViewport.clientHeight; + + browser.style.setProperty("min-width", targetWidth + "px"); + browser.style.setProperty("max-width", targetWidth + "px"); + browser.style.setProperty("min-height", targetHeight + "px"); + browser.style.setProperty("max-height", targetHeight + "px"); + + // Wait until the viewport has been resized + await this.executeInChild("_awaitViewportDimensions", { + width: targetWidth, + height: targetHeight, + }); + } + + /** + * Enables touch on platforms which do not support them. + * + * @param {object} options + * @param {boolean} options.enabled + * Whether the touch event emulation should be enabled. + * @param {number=} options.maxTouchPoints [not yet supported] + * Maximum touch points supported. Defaults to one. + */ + async setTouchEmulationEnabled(options = {}) { + const { enabled } = options; + + if (typeof enabled != "boolean") { + throw new TypeError( + "Invalid parameters (enabled: boolean value expected)" + ); + } + + const { browsingContext } = this.session.target; + if (enabled) { + browsingContext.touchEventsOverride = "enabled"; + } else { + browsingContext.touchEventsOverride = "none"; + } + } + + /** + * Allows overriding user agent with the given string. + * + * @param {object} options + * @param {string} options.userAgent + * User agent to use. + * @param {string=} options.acceptLanguage [not yet supported] + * Browser langugage to emulate. + * @param {string=} options.platform + * The platform navigator.platform should return. + */ + async setUserAgentOverride(options = {}) { + const { userAgent, platform } = options; + + if (typeof userAgent != "string") { + throw new TypeError( + "Invalid parameters (userAgent: string value expected)" + ); + } + + if (!["undefined", "string"].includes(typeof platform)) { + throw new TypeError("platform: string value expected"); + } + + const { browsingContext } = this.session.target; + + if (!userAgent.length) { + browsingContext.customUserAgent = null; + } else if (this._isValidHTTPRequestHeaderValue(userAgent)) { + browsingContext.customUserAgent = userAgent; + } else { + throw new TypeError("Invalid characters found in userAgent"); + } + + if (platform?.length > 0) { + browsingContext.customPlatform = platform; + } else { + browsingContext.customPlatform = null; + } + } + + _isValidHTTPRequestHeaderValue(value) { + try { + const channel = lazy.NetUtil.newChannel({ + uri: "http://localhost", + loadUsingSystemPrincipal: true, + }); + channel.QueryInterface(Ci.nsIHttpChannel); + channel.setRequestHeader("X-check", value, false); + return true; + } catch (e) { + return false; + } + } +} diff --git a/remote/cdp/domains/parent/Fetch.sys.mjs b/remote/cdp/domains/parent/Fetch.sys.mjs new file mode 100644 index 0000000000..39e6965ccd --- /dev/null +++ b/remote/cdp/domains/parent/Fetch.sys.mjs @@ -0,0 +1,30 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +// Note: For now this domain has only been added so that clients using CDP +// (like Selenium) don't break when trying to disable Fetch events. + +export class Fetch extends Domain { + constructor(session) { + super(session); + + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + disable() { + if (!this.enabled) { + return; + } + + this.enabled = false; + } +} diff --git a/remote/cdp/domains/parent/IO.sys.mjs b/remote/cdp/domains/parent/IO.sys.mjs new file mode 100644 index 0000000000..b7eeb75774 --- /dev/null +++ b/remote/cdp/domains/parent/IO.sys.mjs @@ -0,0 +1,103 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; +import { StreamRegistry } from "chrome://remote/content/cdp/StreamRegistry.sys.mjs"; + +const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; + +// Global singleton for managing open streams +export const streamRegistry = new StreamRegistry(); + +export class IO extends Domain { + // commands + + /** + * Close the stream, discard any temporary backing storage. + * + * @param {object} options + * @param {string} options.handle + * Handle of the stream to close. + */ + async close(options = {}) { + const { handle } = options; + + if (typeof handle != "string") { + throw new TypeError(`handle: string value expected`); + } + + await streamRegistry.remove(handle); + } + + /** + * Read a chunk of the stream. + * + * @param {object} options + * @param {string} options.handle + * Handle of the stream to read. + * @param {number=} options.offset + * Seek to the specified offset before reading. If not specificed, + * proceed with offset following the last read. + * Some types of streams may only support sequential reads. + * @param {number=} options.size + * Maximum number of bytes to read (left upon the agent + * discretion if not specified). + * + * @returns {object} + * Data that were read, including flags for base64-encoded, and end-of-file reached. + */ + async read(options = {}) { + const { handle, offset, size } = options; + + if (typeof handle != "string") { + throw new TypeError(`handle: string value expected`); + } + + const stream = streamRegistry.get(handle); + + if (typeof offset != "undefined") { + if (typeof offset != "number") { + throw new TypeError(`offset: integer value expected`); + } + + await stream.seek(offset); + } + + const remainingBytes = await stream.available(); + + let chunkSize; + if (typeof size != "undefined") { + if (typeof size != "number") { + throw new TypeError(`size: integer value expected`); + } + + // Chromium currently crashes for negative sizes (https://bit.ly/2P6h0Fv), + // but might behave similar to the offset and clip invalid values + chunkSize = Math.max(0, Math.min(size, remainingBytes)); + } else { + chunkSize = Math.min(DEFAULT_CHUNK_SIZE, remainingBytes); + } + + const bytes = await stream.readBytes(chunkSize); + + // Each UCS2 character has an upper byte of 0 and a lower byte matching + // the binary data. Using a loop here prevents us from hitting the browser's + // internal `arguments.length` limit. + const ARGS_MAX = 262144; + const stringData = []; + for (let i = 0; i < bytes.length; i += ARGS_MAX) { + let argsChunk = Math.min(bytes.length, i + ARGS_MAX); + stringData.push( + String.fromCharCode.apply(null, bytes.slice(i, argsChunk)) + ); + } + const data = btoa(stringData.join("")); + + return { + data, + base64Encoded: true, + eof: remainingBytes - bytes.length == 0, + }; + } +} diff --git a/remote/cdp/domains/parent/Input.sys.mjs b/remote/cdp/domains/parent/Input.sys.mjs new file mode 100644 index 0000000000..4121298d50 --- /dev/null +++ b/remote/cdp/domains/parent/Input.sys.mjs @@ -0,0 +1,168 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +export class Input extends Domain { + // commands + + /** + * Simulate key events. + * + * @param {object} options + * - autoRepeat (not supported) + * - code (not supported) + * - key + * - isKeypad (not supported) + * - location (not supported) + * - modifiers + * - text (not supported) + * - type + * - unmodifiedText (not supported) + * - windowsVirtualKeyCode + * - nativeVirtualKeyCode (not supported) + * - keyIdentifier (not supported) + * - isSystemKey (not supported) + */ + async dispatchKeyEvent(options = {}) { + // missing code, text, unmodifiedText, autorepeat, location, iskeypad + const { key, modifiers, type, windowsVirtualKeyCode } = options; + const { alt, ctrl, meta, shift } = Input.Modifier; + + let domType; + if (type == "keyDown" || type == "rawKeyDown") { + // 'rawKeyDown' is passed as type by puppeteer for all non-text keydown events: + // See https://github.com/GoogleChrome/puppeteer/blob/2d99d85976dcb28cc6e3bad4b6a00cd61a67a2cf/lib/Input.js#L52 + // For now we simply map rawKeyDown to keydown. + domType = "keydown"; + } else if (type == "keyUp" || type == "char") { + // 'char' is fired as a single key event. Behind the scenes it will trigger keydown, + // keypress and keyup. `domType` will only be used as the event to wait for. + domType = "keyup"; + } else { + throw new Error(`Unknown key event type ${type}`); + } + + const { browser } = this.session.target; + const browserWindow = browser.ownerGlobal; + + const EventUtils = this._getEventUtils(browserWindow); + const eventId = await this.executeInChild( + "_addContentEventListener", + domType + ); + + if (type == "char") { + // type == "char" is used when doing `await page.keyboard.type( 'I’m a list' );` + // the ’ character will be calling dispatchKeyEvent only once with type=char. + EventUtils.synthesizeKey(key, {}, browserWindow); + } else { + // Non printable keys should be prefixed with `KEY_` + const eventUtilsKey = key.length == 1 ? key : "KEY_" + key; + const eventInfo = { + keyCode: windowsVirtualKeyCode, + type: domType, + altKey: !!(modifiers & alt), + ctrlKey: !!(modifiers & ctrl), + metaKey: !!(modifiers & meta), + shiftKey: !!(modifiers & shift), + }; + EventUtils.synthesizeKey(eventUtilsKey, eventInfo, browserWindow); + } + + await this.executeInChild("_waitForContentEvent", eventId); + } + + /** + * Simulate mouse events. + * + * @param {object} options + * @param {string} options.type + * @param {number} options.x + * @param {number} options.y + * @param {number} options.modifiers + * @param {number} options.timestamp [Not Supported] + * @param {string} options.button + * @param {number} options.buttons [Not Supported] + * @param {string} options.clickCount + * @param {number} options.deltaX [Not Supported] + * @param {number} options.deltaY [Not Supported] + * @param {string} options.pointerType [Not Supported] + */ + async dispatchMouseEvent(options = {}) { + const { button, clickCount, modifiers, type, x, y } = options; + const { alt, ctrl, meta, shift } = Input.Modifier; + + let domType; + if (type === "mousePressed") { + domType = "mousedown"; + } else if (type === "mouseReleased") { + domType = "mouseup"; + } else if (type === "mouseMoved") { + domType = "mousemove"; + } else { + throw new Error(`Mouse type is not supported: ${type}`); + } + + if (domType === "mousedown" && button === "right") { + domType = "contextmenu"; + } + + const buttonID = Input.Button[button] || Input.Button.left; + const { browser } = this.session.target; + const currentWindow = browser.ownerGlobal; + + const EventUtils = this._getEventUtils(currentWindow); + const eventId = await this.executeInChild( + "_addContentEventListener", + domType + ); + + EventUtils.synthesizeMouse(browser, x, y, { + type: domType, + button: buttonID, + clickCount: clickCount || 1, + altKey: !!(modifiers & alt), + ctrlKey: !!(modifiers & ctrl), + metaKey: !!(modifiers & meta), + shiftKey: !!(modifiers & shift), + }); + + await this.executeInChild("_waitForContentEvent", eventId); + } + + /** + * Memoized EventUtils getter. + */ + _getEventUtils(win) { + if (!this._eventUtils) { + this._eventUtils = { + window: win, + parent: win, + _EU_Ci: Ci, + _EU_Cc: Cc, + }; + Services.scriptloader.loadSubScript( + "chrome://remote/content/external/EventUtils.js", + this._eventUtils + ); + } + return this._eventUtils; + } +} + +Input.Button = { + left: 0, + middle: 1, + right: 2, + back: 3, + forward: 4, +}; + +Input.Modifier = { + alt: 1, + ctrl: 2, + meta: 4, + shift: 8, +}; diff --git a/remote/cdp/domains/parent/Network.sys.mjs b/remote/cdp/domains/parent/Network.sys.mjs new file mode 100644 index 0000000000..4d36cf994e --- /dev/null +++ b/remote/cdp/domains/parent/Network.sys.mjs @@ -0,0 +1,538 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const MAX_COOKIE_EXPIRY = Number.MAX_SAFE_INTEGER; + +const LOAD_CAUSE_STRINGS = { + [Ci.nsIContentPolicy.TYPE_INVALID]: "Invalid", + [Ci.nsIContentPolicy.TYPE_OTHER]: "Other", + [Ci.nsIContentPolicy.TYPE_SCRIPT]: "Script", + [Ci.nsIContentPolicy.TYPE_IMAGE]: "Img", + [Ci.nsIContentPolicy.TYPE_STYLESHEET]: "Stylesheet", + [Ci.nsIContentPolicy.TYPE_OBJECT]: "Object", + [Ci.nsIContentPolicy.TYPE_DOCUMENT]: "Document", + [Ci.nsIContentPolicy.TYPE_SUBDOCUMENT]: "Subdocument", + [Ci.nsIContentPolicy.TYPE_PING]: "Ping", + [Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST]: "Xhr", + [Ci.nsIContentPolicy.TYPE_OBJECT_SUBREQUEST]: "ObjectSubdoc", + [Ci.nsIContentPolicy.TYPE_DTD]: "Dtd", + [Ci.nsIContentPolicy.TYPE_FONT]: "Font", + [Ci.nsIContentPolicy.TYPE_MEDIA]: "Media", + [Ci.nsIContentPolicy.TYPE_WEBSOCKET]: "Websocket", + [Ci.nsIContentPolicy.TYPE_CSP_REPORT]: "Csp", + [Ci.nsIContentPolicy.TYPE_XSLT]: "Xslt", + [Ci.nsIContentPolicy.TYPE_BEACON]: "Beacon", + [Ci.nsIContentPolicy.TYPE_FETCH]: "Fetch", + [Ci.nsIContentPolicy.TYPE_IMAGESET]: "Imageset", + [Ci.nsIContentPolicy.TYPE_WEB_MANIFEST]: "WebManifest", + [Ci.nsIContentPolicy.TYPE_WEB_IDENTITY]: "Webidentity", +}; + +export class Network extends Domain { + constructor(session) { + super(session); + this.enabled = false; + + this._onRequest = this._onRequest.bind(this); + this._onResponse = this._onResponse.bind(this); + } + + destructor() { + this.disable(); + + super.destructor(); + } + + enable() { + if (this.enabled) { + return; + } + this.enabled = true; + this.session.networkObserver.startTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.on("request", this._onRequest); + this.session.networkObserver.on("response", this._onResponse); + } + + disable() { + if (!this.enabled) { + return; + } + this.session.networkObserver.stopTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.off("request", this._onRequest); + this.session.networkObserver.off("response", this._onResponse); + this.enabled = false; + } + + /** + * Deletes browser cookies with matching name and url or domain/path pair. + * + * @param {object} options + * @param {string} options.name + * Name of the cookies to remove. + * @param {string=} options.url + * If specified, deletes all the cookies with the given name + * where domain and path match provided URL. + * @param {string=} options.domain + * If specified, deletes only cookies with the exact domain. + * @param {string=} options.path + * If specified, deletes only cookies with the exact path. + */ + async deleteCookies(options = {}) { + const { domain, name, path = "/", url } = options; + + if (typeof name != "string") { + throw new TypeError("name: string value expected"); + } + + if (!url && !domain) { + throw new TypeError( + "At least one of the url and domain needs to be specified" + ); + } + + // Retrieve host. Check domain first because it has precedence. + let hostname = domain || ""; + if (!hostname.length) { + const cookieURL = new URL(url); + if (!["http:", "https:"].includes(cookieURL.protocol)) { + throw new TypeError("An http or https url must be specified"); + } + hostname = cookieURL.hostname; + } + + const cookiesFound = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify({}), + hostname + ); + + for (const cookie of cookiesFound) { + if (cookie.name == name && cookie.path.startsWith(path)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + } + } + } + + /** + * Activates emulation of network conditions. + * + * @param {object} options + * @param {boolean} options.offline + * True to emulate internet disconnection. + */ + emulateNetworkConditions(options = {}) { + const { offline } = options; + + if (typeof offline != "boolean") { + throw new TypeError("offline: boolean value expected"); + } + + Services.io.offline = offline; + } + + /** + * Returns all browser cookies. + * + * Depending on the backend support, will return detailed cookie information in the cookies field. + * + * @param {object} options + * + * @returns {Array<Cookie>} + * Array of cookie objects. + */ + async getAllCookies(options = {}) { + const cookies = []; + for (const cookie of Services.cookies.cookies) { + cookies.push(_buildCookie(cookie)); + } + + return { cookies }; + } + + /** + * Returns all browser cookies for the current URL. + * + * @param {object} options + * @param {Array<string>=} options.urls + * The list of URLs for which applicable cookies will be fetched. + * Defaults to the currently open URL. + * + * @returns {Array<Cookie>} + * Array of cookie objects. + */ + async getCookies(options = {}) { + const { urls = this._getDefaultUrls() } = options; + + if (!Array.isArray(urls)) { + throw new TypeError("urls: array expected"); + } + + for (const [index, url] of urls.entries()) { + if (typeof url !== "string") { + throw new TypeError(`urls: string value expected at index ${index}`); + } + } + + const cookies = []; + for (let url of urls) { + url = new URL(url); + + const secureProtocol = ["https:", "wss:"].includes(url.protocol); + + const cookiesFound = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify({}), + url.hostname + ); + + for (const cookie of cookiesFound) { + // Ignore secure cookies for non-secure protocols + if (cookie.isSecure && !secureProtocol) { + continue; + } + + // Ignore cookies which do not match the given path + if (!url.pathname.startsWith(cookie.path)) { + continue; + } + + const builtCookie = _buildCookie(cookie); + const duplicateCookie = cookies.some(value => { + return ( + value.name === builtCookie.name && + value.path === builtCookie.path && + value.domain === builtCookie.domain + ); + }); + + if (duplicateCookie) { + continue; + } + + cookies.push(builtCookie); + } + } + + return { cookies }; + } + + /** + * Sets a cookie with the given cookie data. + * + * Note that it may overwrite equivalent cookies if they exist. + * + * @param {object} cookie + * @param {string} cookie.name + * Cookie name. + * @param {string} cookie.value + * Cookie value. + * @param {string=} cookie.domain + * Cookie domain. + * @param {number=} cookie.expires + * Cookie expiration date, session cookie if not set. + * @param {boolean=} cookie.httpOnly + * True if cookie is http-only. + * @param {string=} cookie.path + * Cookie path. + * @param {string=} cookie.sameSite + * Cookie SameSite type. + * @param {boolean=} cookie.secure + * True if cookie is secure. + * @param {string=} cookie.url + * The request-URI to associate with the setting of the cookie. + * This value can affect the default domain and path values of the + * created cookie. + * + * @returns {boolean} + * True if successfully set cookie. + */ + setCookie(cookie) { + if (typeof cookie.name != "string") { + throw new TypeError("name: string value expected"); + } + + if (typeof cookie.value != "string") { + throw new TypeError("value: string value expected"); + } + + if ( + typeof cookie.url == "undefined" && + typeof cookie.domain == "undefined" + ) { + throw new TypeError( + "At least one of the url and domain needs to be specified" + ); + } + + // Retrieve host. Check domain first because it has precedence. + let hostname = cookie.domain || ""; + let cookieURL; + let schemeType = Ci.nsICookie.SCHEME_UNSET; + if (!hostname.length) { + try { + cookieURL = new URL(cookie.url); + } catch (e) { + return { success: false }; + } + + if (!["http:", "https:"].includes(cookieURL.protocol)) { + throw new TypeError(`Invalid protocol ${cookieURL.protocol}`); + } + + if (cookieURL.protocol == "https:") { + cookie.secure = true; + schemeType = Ci.nsICookie.SCHEME_HTTPS; + } else { + schemeType = Ci.nsICookie.SCHEME_HTTP; + } + + hostname = cookieURL.hostname; + } + + if (typeof cookie.path == "undefined") { + cookie.path = "/"; + } + + let isSession = false; + if (typeof cookie.expires == "undefined") { + isSession = true; + cookie.expires = MAX_COOKIE_EXPIRY; + } + + const sameSiteMap = new Map([ + ["None", Ci.nsICookie.SAMESITE_NONE], + ["Lax", Ci.nsICookie.SAMESITE_LAX], + ["Strict", Ci.nsICookie.SAMESITE_STRICT], + ]); + + let success = true; + try { + Services.cookies.add( + hostname, + cookie.path, + cookie.name, + cookie.value, + cookie.secure, + cookie.httpOnly || false, + isSession, + cookie.expires, + {} /* originAttributes */, + sameSiteMap.get(cookie.sameSite), + schemeType + ); + } catch (e) { + success = false; + } + + return { success }; + } + + /** + * Sets given cookies. + * + * @param {object} options + * @param {Array.<Cookie>} options.cookies + * Cookies to be set. + */ + setCookies(options = {}) { + const { cookies } = options; + + if (!Array.isArray(cookies)) { + throw new TypeError("Invalid parameters (cookies: array expected)"); + } + + cookies.forEach(cookie => { + const { success } = this.setCookie(cookie); + if (!success) { + throw new Error("Invalid cookie fields"); + } + }); + } + + /** + * Toggles ignoring cache for each request. If true, cache will not be used. + * + * @param {object} options + * @param {boolean} options.cacheDisabled + * Cache disabled state. + */ + async setCacheDisabled(options = {}) { + const { cacheDisabled = false } = options; + + const { INHIBIT_CACHING, LOAD_BYPASS_CACHE, LOAD_NORMAL } = Ci.nsIRequest; + + let loadFlags = LOAD_NORMAL; + if (cacheDisabled) { + loadFlags = LOAD_BYPASS_CACHE | INHIBIT_CACHING; + } + + await this.executeInChild("_updateLoadFlags", loadFlags); + } + + /** + * Allows overriding user agent with the given string. + * + * Redirected to Emulation.setUserAgentOverride. + */ + setUserAgentOverride(options = {}) { + const { id } = this.session; + this.session.execute(id, "Emulation", "setUserAgentOverride", options); + } + + _onRequest(eventName, httpChannel, data) { + const wrappedChannel = ChannelWrapper.get(httpChannel); + const urlFragment = httpChannel.URI.hasRef + ? "#" + httpChannel.URI.ref + : undefined; + + const request = { + url: httpChannel.URI.specIgnoringRef, + urlFragment, + method: httpChannel.requestMethod, + headers: headersAsObject(data.headers), + postData: undefined, + hasPostData: false, + mixedContentType: undefined, + initialPriority: undefined, + referrerPolicy: undefined, + isLinkPreload: false, + }; + this.emit("Network.requestWillBeSent", { + requestId: data.requestId, + loaderId: data.loaderId, + documentURL: + wrappedChannel.documentURL || httpChannel.URI.specIgnoringRef, + request, + timestamp: Date.now() / 1000, + wallTime: undefined, + initiator: undefined, + redirectResponse: undefined, + type: LOAD_CAUSE_STRINGS[data.cause] || "unknown", + frameId: data.frameId.toString(), + hasUserGesture: undefined, + }); + } + + _onResponse(eventName, httpChannel, data) { + const wrappedChannel = ChannelWrapper.get(httpChannel); + const headers = headersAsObject(data.headers); + + this.emit("Network.responseReceived", { + requestId: data.requestId, + loaderId: data.loaderId, + timestamp: Date.now() / 1000, + type: LOAD_CAUSE_STRINGS[data.cause] || "unknown", + response: { + url: httpChannel.URI.spec, + status: data.status, + statusText: data.statusText, + headers, + mimeType: wrappedChannel.contentType, + requestHeaders: headersAsObject(data.requestHeaders), + connectionReused: undefined, + connectionId: undefined, + remoteIPAddress: data.remoteIPAddress, + remotePort: data.remotePort, + fromDiskCache: data.fromCache, + encodedDataLength: undefined, + protocol: httpChannel.protocolVersion, + securityDetails: data.securityDetails, + // unknown, neutral, insecure, secure, info, insecure-broken + securityState: "unknown", + }, + frameId: data.frameId.toString(), + }); + } + + /** + * Creates an array of all Urls in the page context + * + * @returns {Array<string>=} + */ + _getDefaultUrls() { + const urls = this.session.target.browsingContext + .getAllBrowsingContextsInSubtree() + .map(context => context.currentURI.spec); + + return urls; + } +} + +/** + * Creates a CDP Network.Cookie from our internal cookie values + * + * @param {nsICookie} cookie + * + * @returns {Network.Cookie} + * A CDP Cookie + */ +function _buildCookie(cookie) { + const data = { + name: cookie.name, + value: cookie.value, + domain: cookie.host, + path: cookie.path, + expires: cookie.isSession ? -1 : cookie.expiry, + // The size is the combined length of both the cookie name and value + size: cookie.name.length + cookie.value.length, + httpOnly: cookie.isHttpOnly, + secure: cookie.isSecure, + session: cookie.isSession, + }; + + if (cookie.sameSite) { + const sameSiteMap = new Map([ + [Ci.nsICookie.SAMESITE_LAX, "Lax"], + [Ci.nsICookie.SAMESITE_STRICT, "Strict"], + ]); + + data.sameSite = sameSiteMap.get(cookie.sameSite); + } + + return data; +} + +/** + * Given a array of possibly repeating header names, merge the values for + * duplicate headers into a comma-separated list, or in some cases a + * newline-separated list. + * + * e.g. { "Cache-Control": "no-cache,no-store" } + * + * Based on + * https://hg.mozilla.org/mozilla-central/file/56c09d42f411246e407fe30418c27e67a6a44d29/netwerk/protocol/http/nsHttpHeaderArray.h + * + * @param {Array} headers + * Array of {name, value} + * @returns {object} + * Object where each key is a header name. + */ +function headersAsObject(headers) { + const rv = {}; + headers.forEach(({ name, value }) => { + name = name.toLowerCase(); + if (rv[name]) { + const separator = [ + "set-cookie", + "www-authenticate", + "proxy-authenticate", + ].includes(name) + ? "\n" + : ","; + rv[name] += `${separator}${value}`; + } else { + rv[name] = value; + } + }); + return rv; +} diff --git a/remote/cdp/domains/parent/Page.sys.mjs b/remote/cdp/domains/parent/Page.sys.mjs new file mode 100644 index 0000000000..d050277aff --- /dev/null +++ b/remote/cdp/domains/parent/Page.sys.mjs @@ -0,0 +1,756 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + + DialogHandler: + "chrome://remote/content/cdp/domains/parent/page/DialogHandler.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", + print: "chrome://remote/content/shared/PDF.sys.mjs", + streamRegistry: "chrome://remote/content/cdp/domains/parent/IO.sys.mjs", + Stream: "chrome://remote/content/cdp/StreamRegistry.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + UnsupportedError: "chrome://remote/content/cdp/Error.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +const MAX_CANVAS_DIMENSION = 32767; +const MAX_CANVAS_AREA = 472907776; + +const PRINT_MAX_SCALE_VALUE = 2.0; +const PRINT_MIN_SCALE_VALUE = 0.1; + +const PDF_TRANSFER_MODES = { + base64: "ReturnAsBase64", + stream: "ReturnAsStream", +}; + +const TIMEOUT_SET_HISTORY_INDEX = 1000; + +export class Page extends Domain { + constructor(session) { + super(session); + + this._onDialogLoaded = this._onDialogLoaded.bind(this); + this._onRequest = this._onRequest.bind(this); + + this.enabled = false; + + this.session.networkObserver.startTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.on("request", this._onRequest); + } + + destructor() { + // Flip a flag to avoid to disable the content domain from this.disable() + this._isDestroyed = false; + this.disable(); + + this.session.networkObserver.off("request", this._onRequest); + this.session.networkObserver.stopTrackingBrowserNetwork( + this.session.target.browser + ); + super.destructor(); + } + + // commands + + /** + * Navigates current page to given URL. + * + * @param {object} options + * @param {string} options.url + * destination URL + * @param {string=} options.frameId + * frame id to navigate (not supported), + * if not specified navigate top frame + * @param {string=} options.referrer + * referred URL (optional) + * @param {string=} options.transitionType + * intended transition type + * @returns {object} + * - frameId {string} frame id that has navigated (or failed to) + * - errorText {string=} error message if navigation has failed + * - loaderId {string} (not supported) + */ + async navigate(options = {}) { + const { url, frameId, referrer, transitionType } = options; + if (typeof url != "string") { + throw new TypeError("url: string value expected"); + } + let validURL; + try { + validURL = Services.io.newURI(url); + } catch (e) { + throw new Error("Error: Cannot navigate to invalid URL"); + } + const topFrameId = this.session.browsingContext.id.toString(); + if (frameId && frameId != topFrameId) { + throw new lazy.UnsupportedError("frameId not supported"); + } + + const hitsNetwork = ["https", "http"].includes(validURL.scheme); + let networkLessLoaderId; + if (!hitsNetwork) { + // This navigation will not hit the network, use a randomly generated id. + networkLessLoaderId = lazy.generateUUID(); + + // Update the content process map of loader ids. + await this.executeInChild("_updateLoaderId", { + frameId: this.session.browsingContext.id, + loaderId: networkLessLoaderId, + }); + } + + const currentURI = this.session.browsingContext.currentURI; + + const isSameDocumentNavigation = + // The "host", "query" and "ref" getters can throw if the URLs are not + // http/https, so verify first that both currentURI and validURL are + // using http/https. + hitsNetwork && + ["https", "http"].includes(currentURI.scheme) && + currentURI.host === validURL.host && + currentURI.query === validURL.query && + !!validURL.ref; + + const requestDone = new Promise(resolve => { + if (isSameDocumentNavigation) { + // Per CDP documentation, same-document navigations should not emit any + // loader id (https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-navigate) + resolve({}); + return; + } + + if (!hitsNetwork) { + // This navigation will not hit the network, use a randomly generated id. + resolve({ navigationRequestId: networkLessLoaderId }); + return; + } + let navigationRequestId, redirectedRequestId; + const _onNavigationRequest = function (_type, _ch, data) { + const { + url: requestURL, + requestId, + redirectedFrom = null, + isNavigationRequest, + } = data; + if (!isNavigationRequest) { + return; + } + if (validURL.spec === requestURL) { + navigationRequestId = redirectedRequestId = requestId; + } else if (redirectedFrom === redirectedRequestId) { + redirectedRequestId = requestId; + } + }; + + const _onRequestFinished = function (_type, _ch, data) { + const { requestId, errorCode } = data; + if ( + redirectedRequestId !== requestId || + errorCode == "NS_BINDING_REDIRECTED" + ) { + // handle next request in redirection chain + return; + } + this.session.networkObserver.off("request", _onNavigationRequest); + this.session.networkObserver.off("requestfinished", _onRequestFinished); + resolve({ errorCode, navigationRequestId }); + }.bind(this); + + this.session.networkObserver.on("request", _onNavigationRequest); + this.session.networkObserver.on("requestfinished", _onRequestFinished); + }); + + const opts = { + loadFlags: transitionToLoadFlag(transitionType), + referrerURI: referrer, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }; + this.session.browsingContext.loadURI(validURL, opts); + // clients expect loaderId == requestId for a document navigation request + const { navigationRequestId: loaderId, errorCode } = await requestDone; + const result = { + frameId: topFrameId, + loaderId, + }; + if (errorCode) { + result.errorText = errorCode; + } + return result; + } + + /** + * Capture page screenshot. + * + * @param {object} options + * @param {Viewport=} options.clip + * Capture the screenshot of a given region only. + * @param {string=} options.format + * Image compression format. Defaults to "png". + * @param {number=} options.quality + * Compression quality from range [0..100] (jpeg only). Defaults to 80. + * + * @returns {string} + * Base64-encoded image data. + */ + async captureScreenshot(options = {}) { + const { clip, format = "png", quality = 80 } = options; + + if (options.fromSurface) { + throw new lazy.UnsupportedError("fromSurface not supported"); + } + + let rect; + let scale = await this.executeInChild("_devicePixelRatio"); + + if (clip) { + for (const prop of ["x", "y", "width", "height", "scale"]) { + if (clip[prop] == undefined) { + throw new TypeError(`clip.${prop}: double value expected`); + } + } + + const contentRect = await this.executeInChild("_contentRect"); + + // For invalid scale values default to full page + if (clip.scale <= 0) { + Object.assign(clip, { + x: 0, + y: 0, + width: contentRect.width, + height: contentRect.height, + scale: 1, + }); + } else { + if (clip.x < 0 || clip.x > contentRect.width - 1) { + clip.x = 0; + } + if (clip.y < 0 || clip.y > contentRect.height - 1) { + clip.y = 0; + } + if (clip.width <= 0) { + clip.width = contentRect.width; + } + if (clip.height <= 0) { + clip.height = contentRect.height; + } + } + + rect = new DOMRect(clip.x, clip.y, clip.width, clip.height); + scale *= clip.scale; + } else { + // If no specific clipping region has been specified, + // fallback to the layout (fixed) viewport, and the + // default pixel ratio. + const { pageX, pageY, clientWidth, clientHeight } = + await this.executeInChild("_layoutViewport"); + + rect = new DOMRect(pageX, pageY, clientWidth, clientHeight); + } + + let canvasWidth = rect.width * scale; + let canvasHeight = rect.height * scale; + + // Cap the screenshot size based on maximum allowed canvas sizes. + // Using higher dimensions would trigger exceptions in Gecko. + // + // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#Maximum_canvas_size + if (canvasWidth > MAX_CANVAS_DIMENSION) { + rect.width = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasWidth = rect.width * scale; + } + if (canvasHeight > MAX_CANVAS_DIMENSION) { + rect.height = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasHeight = rect.height * scale; + } + // If the area is larger, reduce the height to keep the full width. + if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) { + rect.height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale)); + canvasHeight = rect.height * scale; + } + + const { browsingContext, window } = this.session.target; + const snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + "rgb(255,255,255)" + ); + + const canvas = window.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + const ctx = canvas.getContext("2d"); + ctx.drawImage(snapshot, 0, 0); + + // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies + // of the bitmap will exist in memory. Force the removal of the snapshot + // because it is no longer needed. + snapshot.close(); + + const url = canvas.toDataURL(`image/${format}`, quality / 100); + if (!url.startsWith(`data:image/${format}`)) { + throw new lazy.UnsupportedError(`Unsupported MIME type: image/${format}`); + } + + // only return the base64 encoded data without the data URL prefix + const data = url.substring(url.indexOf(",") + 1); + + return { data }; + } + + async enable() { + if (this.enabled) { + return; + } + + this.enabled = true; + + const { browser } = this.session.target; + this._dialogHandler = new lazy.DialogHandler(browser); + this._dialogHandler.on("dialog-loaded", this._onDialogLoaded); + await this.executeInChild("enable"); + } + + async disable() { + if (!this.enabled) { + return; + } + + this._dialogHandler.destructor(); + this._dialogHandler = null; + this.enabled = false; + + if (!this._isDestroyed) { + // Only call disable in the content domain if we are not destroying the domain. + // If we are destroying the domain, the content domains will be destroyed + // independently after firing the remote:destroy event. + await this.executeInChild("disable"); + } + } + + async bringToFront() { + const { tab, window } = this.session.target; + + // Focus the window, and select the corresponding tab + await lazy.windowManager.focusWindow(window); + await lazy.TabManager.selectTab(tab); + } + + /** + * Return metrics relating to the layouting of the page. + * + * The returned object contains the following entries: + * + * layoutViewport: + * {number} pageX + * Horizontal offset relative to the document (CSS pixels) + * {number} pageY + * Vertical offset relative to the document (CSS pixels) + * {number} clientWidth + * Width (CSS pixels), excludes scrollbar if present + * {number} clientHeight + * Height (CSS pixels), excludes scrollbar if present + * + * visualViewport: + * {number} offsetX + * Horizontal offset relative to the layout viewport (CSS pixels) + * {number} offsetY + * Vertical offset relative to the layout viewport (CSS pixels) + * {number} pageX + * Horizontal offset relative to the document (CSS pixels) + * {number} pageY + * Vertical offset relative to the document (CSS pixels) + * {number} clientWidth + * Width (CSS pixels), excludes scrollbar if present + * {number} clientHeight + * Height (CSS pixels), excludes scrollbar if present + * {number} scale + * Scale relative to the ideal viewport (size at width=device-width) + * {number} zoom + * Page zoom factor (CSS to device independent pixels ratio) + * + * contentSize: + * {number} x + * X coordinate + * {number} y + * Y coordinate + * {number} width + * Width of scrollable area + * {number} height + * Height of scrollable area + * + * @returns {Promise<object>} + * Promise which resolves with an object with the following properties + * layoutViewport and contentSize + */ + async getLayoutMetrics() { + return { + layoutViewport: await this.executeInChild("_layoutViewport"), + contentSize: await this.executeInChild("_contentRect"), + }; + } + + /** + * Returns navigation history for the current page. + * + * @returns {Promise<object>} + * Promise which resolves with an object with the following properties + * currentIndex (number) and entries (Array<NavigationEntry>). + */ + async getNavigationHistory() { + const { window } = this.session.target; + + return new Promise(resolve => { + function updateSessionHistory(sessionHistory) { + const entries = sessionHistory.entries.map(entry => { + return { + id: entry.ID, + url: entry.url, + userTypedURL: entry.originalURI || entry.url, + title: entry.title, + // TODO: Bug 1609514 + transitionType: null, + }; + }); + + resolve({ + currentIndex: sessionHistory.index, + entries, + }); + } + + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + updateSessionHistory + ); + }); + } + + /** + * Interact with the currently opened JavaScript dialog (alert, confirm, + * prompt) for this page. This will always close the dialog, either accepting + * or rejecting it, with the optional prompt filled. + * + * @param {object} options + * @param {boolean=} options.accept + * for "confirm", "prompt", "beforeunload" dialogs true will accept + * the dialog, false will cancel it. For "alert" dialogs, true or + * false closes the dialog in the same way. + * @param {string=} options.promptText + * for "prompt" dialogs, used to fill the prompt input. + */ + async handleJavaScriptDialog(options = {}) { + const { accept, promptText } = options; + + if (!this.enabled) { + throw new Error("Page domain is not enabled"); + } + await this._dialogHandler.handleJavaScriptDialog({ accept, promptText }); + } + + /** + * Navigates current page to the given history entry. + * + * @param {object} options + * @param {number} options.entryId + * Unique id of the entry to navigate to. + */ + async navigateToHistoryEntry(options = {}) { + const { entryId } = options; + + const index = await this._getIndexForHistoryEntryId(entryId); + + if (index == null) { + throw new Error("No entry with passed id"); + } + + const { window } = this.session.target; + window.gBrowser.gotoIndex(index); + + // On some platforms the requested index isn't set immediately. + await lazy.PollPromise( + async (resolve, reject) => { + const currentIndex = await this._getCurrentHistoryIndex(); + if (currentIndex == index) { + resolve(); + } else { + reject(); + } + }, + { timeout: TIMEOUT_SET_HISTORY_INDEX } + ); + } + + /** + * Print page as PDF. + * + * @param {object} options + * @param {boolean=} options.displayHeaderFooter + * Display header and footer. Defaults to false. + * @param {string=} options.footerTemplate (not supported) + * HTML template for the print footer. + * @param {string=} options.headerTemplate (not supported) + * HTML template for the print header. Should use the same format + * as the footerTemplate. + * @param {boolean=} options.ignoreInvalidPageRanges + * Whether to silently ignore invalid but successfully parsed page ranges, + * such as '3-2'. Defaults to false. + * @param {boolean=} options.landscape + * Paper orientation. Defaults to false. + * @param {number=} options.marginBottom + * Bottom margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginLeft + * Left margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginRight + * Right margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginTop + * Top margin in inches. Defaults to 1cm (~0.4 inches). + * @param {string=} options.pageRanges (not supported) + * Paper ranges to print, e.g., '1-5, 8, 11-13'. + * Defaults to the empty string, which means print all pages. + * @param {number=} options.paperHeight + * Paper height in inches. Defaults to 11 inches. + * @param {number=} options.paperWidth + * Paper width in inches. Defaults to 8.5 inches. + * @param {boolean=} options.preferCSSPageSize + * Whether or not to prefer page size as defined by CSS. + * Defaults to false, in which case the content will be scaled + * to fit the paper size. + * @param {boolean=} options.printBackground + * Print background graphics. Defaults to false. + * @param {number=} options.scale + * Scale of the webpage rendering. Defaults to 1. + * @param {string=} options.transferMode + * Return as base64-encoded string (ReturnAsBase64), + * or stream (ReturnAsStream). Defaults to ReturnAsBase64. + * + * @returns {Promise<{data:string, stream:Stream}>} + * Based on the transferMode setting data is a base64-encoded string, + * or stream is a Stream. + */ + async printToPDF(options = {}) { + const { + displayHeaderFooter = false, + // Bug 1601570 - Implement templates for header and footer + // headerTemplate = "", + // footerTemplate = "", + landscape = false, + marginBottom = 0.39, + marginLeft = 0.39, + marginRight = 0.39, + marginTop = 0.39, + // Bug 1601571 - Implement handling of page ranges + // TODO: pageRanges = "", + // TODO: ignoreInvalidPageRanges = false, + paperHeight = 11.0, + paperWidth = 8.5, + preferCSSPageSize = false, + printBackground = false, + scale = 1.0, + transferMode = PDF_TRANSFER_MODES.base64, + } = options; + + if (marginBottom < 0) { + throw new TypeError("marginBottom is negative"); + } + if (marginLeft < 0) { + throw new TypeError("marginLeft is negative"); + } + if (marginRight < 0) { + throw new TypeError("marginRight is negative"); + } + if (marginTop < 0) { + throw new TypeError("marginTop is negative"); + } + if (scale < PRINT_MIN_SCALE_VALUE || scale > PRINT_MAX_SCALE_VALUE) { + throw new TypeError("scale is outside [0.1 - 2] range"); + } + if (paperHeight <= 0) { + throw new TypeError("paperHeight is zero or negative"); + } + if (paperWidth <= 0) { + throw new TypeError("paperWidth is zero or negative"); + } + + const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + + const printSettings = psService.createNewPrintSettings(); + printSettings.isInitializedFromPrinter = true; + printSettings.isInitializedFromPrefs = true; + printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; + printSettings.printerName = ""; + printSettings.printSilent = true; + + printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches; + printSettings.paperWidth = paperWidth; + printSettings.paperHeight = paperHeight; + + // Override any os-specific unwriteable margins + printSettings.unwriteableMarginTop = 0; + printSettings.unwriteableMarginLeft = 0; + printSettings.unwriteableMarginBottom = 0; + printSettings.unwriteableMarginRight = 0; + + printSettings.marginBottom = marginBottom; + printSettings.marginLeft = marginLeft; + printSettings.marginRight = marginRight; + printSettings.marginTop = marginTop; + + printSettings.printBGColors = printBackground; + printSettings.printBGImages = printBackground; + printSettings.scaling = scale; + printSettings.shrinkToFit = preferCSSPageSize; + + if (!displayHeaderFooter) { + printSettings.headerStrCenter = ""; + printSettings.headerStrLeft = ""; + printSettings.headerStrRight = ""; + printSettings.footerStrCenter = ""; + printSettings.footerStrLeft = ""; + printSettings.footerStrRight = ""; + } + + if (landscape) { + printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation; + } + + const retval = { data: null, stream: null }; + const { linkedBrowser } = this.session.target.tab; + + if (transferMode === PDF_TRANSFER_MODES.stream) { + // If we are returning a stream, we write the PDF to disk so that we don't + // keep (potentially very large) PDFs in memory. We can then stream them + // to the client via the returned Stream. + // + // NOTE: This is a potentially premature optimization -- it might be fine + // to keep these PDFs in memory, but we don't have specifics on how CDP is + // used in the field so it is possible that leaving the PDFs in memory + // could cause a regression. + const path = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "remote-agent.pdf" + ); + + printSettings.outputDestination = + Ci.nsIPrintSettings.kOutputDestinationFile; + printSettings.toFileName = path; + + await linkedBrowser.browsingContext.print(printSettings); + + retval.stream = lazy.streamRegistry.add(new lazy.Stream(path)); + } else { + const binaryString = await lazy.print.printToBinaryString( + linkedBrowser.browsingContext, + printSettings + ); + + retval.data = btoa(binaryString); + } + + return retval; + } + + /** + * Intercept file chooser requests and transfer control to protocol clients. + * + * When file chooser interception is enabled, + * the native file chooser dialog is not shown. + * Instead, a protocol event Page.fileChooserOpened is emitted. + * + * @param {object} options + * @param {boolean=} options.enabled + * Enabled state of file chooser interception. + */ + setInterceptFileChooserDialog(options = {}) {} + + _getCurrentHistoryIndex() { + const { window } = this.session.target; + + return new Promise(resolve => { + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + history => { + resolve(history.index); + } + ); + }); + } + + _getIndexForHistoryEntryId(id) { + const { window } = this.session.target; + + return new Promise(resolve => { + function updateSessionHistory(sessionHistory) { + sessionHistory.entries.forEach((entry, index) => { + if (entry.ID == id) { + resolve(index); + } + }); + + resolve(null); + } + + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + updateSessionHistory + ); + }); + } + + /** + * Emit the proper CDP event javascriptDialogOpening when a javascript dialog + * opens for the current target. + */ + _onDialogLoaded(e, data) { + const { message, type } = data; + // XXX: We rely on the common-dialog-loaded event (see DialogHandler.jsm) + // which is inconsistent with the name "javascriptDialogOpening". + // For correctness we should rely on an event fired _before_ the prompt is + // visible, such as DOMWillOpenModalDialog. However the payload of this + // event does not contain enough data to populate javascriptDialogOpening. + // + // Since the event is fired asynchronously, this should not have an impact + // on the actual tests relying on this API. + this.emit("Page.javascriptDialogOpening", { message, type }); + } + + /** + * Handles HTTP request to propagate loaderId to events emitted from + * content process + */ + _onRequest(_type, _ch, data) { + if (!data.loaderId) { + return; + } + this.executeInChild("_updateLoaderId", { + loaderId: data.loaderId, + frameId: data.frameId, + }); + } +} + +function transitionToLoadFlag(transitionType) { + switch (transitionType) { + case "reload": + return Ci.nsIWebNavigation.LOAD_FLAGS_IS_REFRESH; + case "link": + default: + return Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK; + } +} diff --git a/remote/cdp/domains/parent/Security.sys.mjs b/remote/cdp/domains/parent/Security.sys.mjs new file mode 100644 index 0000000000..77e00acc8c --- /dev/null +++ b/remote/cdp/domains/parent/Security.sys.mjs @@ -0,0 +1,54 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + sss: ["@mozilla.org/ssservice;1", "nsISiteSecurityService"], + certOverrideService: [ + "@mozilla.org/security/certoverride;1", + "nsICertOverrideService", + ], +}); + +const CERT_PINNING_ENFORCEMENT_PREF = "security.cert_pinning.enforcement_level"; +const HSTS_PRELOAD_LIST_PREF = "network.stricttransportsecurity.preloadlist"; + +export class Security extends Domain { + destructor() { + this.setIgnoreCertificateErrors({ ignore: false }); + } + + /** + * Enable/disable whether all certificate errors should be ignored + * + * @param {object} options + * @param {boolean=} options.ignore + * if true, all certificate errors will be ignored. + */ + setIgnoreCertificateErrors(options = {}) { + const { ignore } = options; + + if (ignore) { + // make it possible to register certificate overrides for domains + // that use HSTS or HPKP + Services.prefs.setBoolPref(HSTS_PRELOAD_LIST_PREF, false); + Services.prefs.setIntPref(CERT_PINNING_ENFORCEMENT_PREF, 0); + } else { + Services.prefs.clearUserPref(HSTS_PRELOAD_LIST_PREF); + Services.prefs.clearUserPref(CERT_PINNING_ENFORCEMENT_PREF); + + // clear collected HSTS and HPKP state + lazy.sss.clearAll(); + } + + lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + ignore + ); + } +} diff --git a/remote/cdp/domains/parent/SystemInfo.sys.mjs b/remote/cdp/domains/parent/SystemInfo.sys.mjs new file mode 100644 index 0000000000..8b5e4a27c2 --- /dev/null +++ b/remote/cdp/domains/parent/SystemInfo.sys.mjs @@ -0,0 +1,48 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +export class SystemInfo extends Domain { + async getProcessInfo() { + const procInfo = await ChromeUtils.requestProcInfo(); + + // Add child processes + const processInfo = procInfo.children.map(proc => ({ + type: this.#getProcessType(proc.type), + id: proc.pid, + cpuTime: this.#getCpuTime(proc.cpuTime), + })); + + // Add parent process + processInfo.unshift({ + type: "browser", + id: procInfo.pid, + cpuTime: this.#getCpuTime(procInfo.cpuTime), + }); + + return processInfo; + } + + #getProcessType(type) { + // Map internal types to CDP types if applicable + switch (type) { + case "gpu": + return "GPU"; + + case "web": + case "webIsolated": + case "privilegedabout": + return "renderer"; + + default: + return type; + } + } + + #getCpuTime(cpuTime) { + // cpuTime is tracked internally as nanoseconds, CDP is in seconds + return cpuTime / 1000 / 1000 / 1000; + } +} diff --git a/remote/cdp/domains/parent/Target.sys.mjs b/remote/cdp/domains/parent/Target.sys.mjs new file mode 100644 index 0000000000..3f4588038b --- /dev/null +++ b/remote/cdp/domains/parent/Target.sys.mjs @@ -0,0 +1,290 @@ +/* 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/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + TabSession: "chrome://remote/content/cdp/sessions/TabSession.sys.mjs", + UserContextManager: + "chrome://remote/content/shared/UserContextManager.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +let browserContextIds = 1; + +// Default filter from CDP specification +const defaultFilter = [ + { type: "browser", exclude: true }, + { type: "tab", exclude: true }, + {}, +]; + +export class Target extends Domain { + #browserContextIds; + #discoverTargetFilter; + + constructor(session) { + super(session); + + this.#browserContextIds = new Set(); + + this._onTargetCreated = this._onTargetCreated.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + } + + getBrowserContexts() { + const browserContextIds = + lazy.ContextualIdentityService.getPublicUserContextIds().filter(id => + this.#browserContextIds.has(id) + ); + + return { browserContextIds }; + } + + createBrowserContext() { + const identity = lazy.ContextualIdentityService.create( + "remote-agent-" + browserContextIds++ + ); + + this.#browserContextIds.add(identity.userContextId); + return { browserContextId: identity.userContextId }; + } + + disposeBrowserContext(options = {}) { + const { browserContextId } = options; + + lazy.ContextualIdentityService.remove(browserContextId); + lazy.ContextualIdentityService.closeContainerTabs(browserContextId); + + this.#browserContextIds.delete(browserContextId); + } + + getTargets(options = {}) { + const { filter = defaultFilter } = options; + const { targetList } = this.session.target; + + this._validateTargetFilter(filter); + + const targetInfos = [...targetList] + .filter(target => this._filterIncludesTarget(target, filter)) + .map(target => this._getTargetInfo(target)); + + return { + targetInfos, + }; + } + + setDiscoverTargets(options = {}) { + const { discover, filter } = options; + const { targetList } = this.session.target; + + if (typeof discover !== "boolean") { + throw new TypeError("discover: boolean value expected"); + } + + if (discover === false && filter !== undefined) { + throw new Error("filter: should not be present when discover is false"); + } + + // null filter should not be defaulted + const targetFilter = filter === undefined ? defaultFilter : filter; + this._validateTargetFilter(targetFilter); + + // Store active filter for filtering in event listeners (targetCreated, targetDestroyed, targetInfoChanged) + this.#discoverTargetFilter = targetFilter; + + if (discover) { + targetList.on("target-created", this._onTargetCreated); + targetList.on("target-destroyed", this._onTargetDestroyed); + + for (const target of targetList) { + this._onTargetCreated("target-created", target); + } + } else { + targetList.off("target-created", this._onTargetCreated); + targetList.off("target-destroyed", this._onTargetDestroyed); + } + } + + async createTarget(options = {}) { + const { browserContextId, url } = options; + + if (typeof url !== "string") { + throw new TypeError("url: string value expected"); + } + + let validURL; + try { + validURL = Services.io.newURI(url); + } catch (e) { + // If we failed to parse given URL, use about:blank instead + validURL = Services.io.newURI("about:blank"); + } + + const { targetList, window } = this.session.target; + const onTarget = targetList.once("target-created"); + const tab = await lazy.TabManager.addTab({ + focus: true, + userContextId: + // Bug 1878649: Use UserContextManager ids consistently in CDP. + lazy.UserContextManager.getIdByInternalId(browserContextId), + window, + }); + + const target = await onTarget; + if (tab.linkedBrowser != target.browser) { + throw new Error( + "Unexpected tab opened: " + tab.linkedBrowser.currentURI.spec + ); + } + + target.browsingContext.loadURI(validURL, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + return { targetId: target.id }; + } + + async closeTarget(options = {}) { + const { targetId } = options; + const { targetList } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + await lazy.TabManager.removeTab(target.tab); + } + + async activateTarget(options = {}) { + const { targetId } = options; + const { targetList, window } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + // Focus the window, and select the corresponding tab + await lazy.windowManager.focusWindow(window); + await lazy.TabManager.selectTab(target.tab); + } + + attachToTarget(options = {}) { + const { targetId } = options; + const { targetList } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + const tabSession = new lazy.TabSession( + this.session.connection, + target, + lazy.generateUUID() + ); + this.session.connection.registerSession(tabSession); + + this._emitAttachedToTarget(target, tabSession); + + return { + sessionId: tabSession.id, + }; + } + + setAutoAttach() {} + + sendMessageToTarget(options = {}) { + const { sessionId, message } = options; + const { connection } = this.session; + connection.sendMessageToTarget(sessionId, message); + } + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + _emitAttachedToTarget(target, tabSession) { + const targetInfo = this._getTargetInfo(target); + this.emit("Target.attachedToTarget", { + targetInfo, + sessionId: tabSession.id, + waitingForDebugger: false, + }); + } + + _getTargetInfo(target) { + const attached = [...this.session.connection.sessions.values()].some( + session => session.target.id === target.id + ); + + return { + targetId: target.id, + type: target.type, + title: target.title, + url: target.url, + attached, + browserContextId: target.browserContextId, + }; + } + + _filterIncludesTarget(target, filter) { + for (const entry of filter) { + if ([undefined, target.type].includes(entry.type)) { + return !entry.exclude; + } + } + + return false; + } + + _validateTargetFilter(filter) { + if (!Array.isArray(filter)) { + throw new TypeError("filter: array value expected"); + } + + for (const entry of filter) { + if (entry === null || Array.isArray(entry) || typeof entry !== "object") { + throw new TypeError("filter: object values expected in array"); + } + + if (!["undefined", "string"].includes(typeof entry.type)) { + throw new TypeError("filter: type: string value expected"); + } + + if (!["undefined", "boolean"].includes(typeof entry.exclude)) { + throw new TypeError("filter: exclude: boolean value expected"); + } + } + } + + _onTargetCreated(eventName, target) { + if (!this._filterIncludesTarget(target, this.#discoverTargetFilter)) { + return; + } + + const targetInfo = this._getTargetInfo(target); + this.emit("Target.targetCreated", { targetInfo }); + } + + _onTargetDestroyed(eventName, target) { + if (!this._filterIncludesTarget(target, this.#discoverTargetFilter)) { + return; + } + + this.emit("Target.targetDestroyed", { + targetId: target.id, + }); + } +} diff --git a/remote/cdp/domains/parent/page/DialogHandler.sys.mjs b/remote/cdp/domains/parent/page/DialogHandler.sys.mjs new file mode 100644 index 0000000000..c5c70cb17f --- /dev/null +++ b/remote/cdp/domains/parent/page/DialogHandler.sys.mjs @@ -0,0 +1,118 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +const DIALOG_TYPES = { + ALERT: "alert", + BEFOREUNLOAD: "beforeunload", + CONFIRM: "confirm", + PROMPT: "prompt", +}; + +/** + * Helper dedicated to detect and interact with browser dialogs such as `alert`, + * `confirm` etc. The current implementation only supports tabmodal dialogs, + * not full window dialogs. + * + * Emits "dialog-loaded" when a javascript dialog is opened for the current + * browser. + * + * @param {BrowserElement} browser + */ +export class DialogHandler { + constructor(browser) { + lazy.EventEmitter.decorate(this); + this._dialog = null; + this._browser = browser; + + this._onCommonDialogLoaded = this._onCommonDialogLoaded.bind(this); + + Services.obs.addObserver( + this._onCommonDialogLoaded, + "common-dialog-loaded" + ); + } + + destructor() { + this._dialog = null; + this._pageTarget = null; + + Services.obs.removeObserver( + this._onCommonDialogLoaded, + "common-dialog-loaded" + ); + } + + async handleJavaScriptDialog({ accept, promptText }) { + if (!this._dialog) { + throw new Error("No dialog available for handleJavaScriptDialog"); + } + + const type = this._getDialogType(); + if (promptText && type === "prompt") { + this._dialog.ui.loginTextbox.value = promptText; + } + + const onDialogClosed = new Promise(r => { + this._browser.addEventListener("DOMModalDialogClosed", r, { + once: true, + }); + }); + + // 0 corresponds to the OK callback, 1 to the CANCEL callback. + if (accept) { + this._dialog.ui.button0.click(); + } else { + this._dialog.ui.button1.click(); + } + + await onDialogClosed; + + // Resetting dialog to null here might be racy and lead to errors if the + // content page is triggering several prompts in a row. + // See Bug 1569578. + this._dialog = null; + } + + _getDialogType() { + const { inPermitUnload, promptType } = this._dialog.args; + + if (inPermitUnload) { + return DIALOG_TYPES.BEFOREUNLOAD; + } + + switch (promptType) { + case "alert": + return DIALOG_TYPES.ALERT; + case "confirm": + return DIALOG_TYPES.CONFIRM; + case "prompt": + return DIALOG_TYPES.PROMPT; + default: + throw new Error("Unsupported dialog type: " + promptType); + } + } + + _onCommonDialogLoaded(dialogWindow) { + const dialogs = + this._browser.tabDialogBox.getContentDialogManager().dialogs; + const dialog = dialogs.find(d => d.frameContentWindow === dialogWindow); + + if (!dialog) { + // The dialog is not for the current tab. + return; + } + + this._dialog = dialogWindow.Dialog; + const message = this._dialog.args.text; + const type = this._getDialogType(); + + this.emit("dialog-loaded", { message, type }); + } +} diff --git a/remote/cdp/jar.mn b/remote/cdp/jar.mn new file mode 100644 index 0000000000..2b3780ba48 --- /dev/null +++ b/remote/cdp/jar.mn @@ -0,0 +1,55 @@ +# 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/. + +remote.jar: +% content remote %content/ + content/cdp/CDP.sys.mjs (CDP.sys.mjs) + content/cdp/CDPConnection.sys.mjs (CDPConnection.sys.mjs) + content/cdp/Error.sys.mjs (Error.sys.mjs) + content/cdp/JSONHandler.sys.mjs (JSONHandler.sys.mjs) + content/cdp/Protocol.sys.mjs (Protocol.sys.mjs) + content/cdp/StreamRegistry.sys.mjs (StreamRegistry.sys.mjs) + # domains + content/cdp/domains/ContentProcessDomain.sys.mjs (domains/ContentProcessDomain.sys.mjs) + content/cdp/domains/ContentProcessDomains.sys.mjs (domains/ContentProcessDomains.sys.mjs) + content/cdp/domains/Domain.sys.mjs (domains/Domain.sys.mjs) + content/cdp/domains/DomainCache.sys.mjs (domains/DomainCache.sys.mjs) + content/cdp/domains/ParentProcessDomains.sys.mjs (domains/ParentProcessDomains.sys.mjs) + content/cdp/domains/content/DOM.sys.mjs (domains/content/DOM.sys.mjs) + content/cdp/domains/content/Emulation.sys.mjs (domains/content/Emulation.sys.mjs) + content/cdp/domains/content/Input.sys.mjs (domains/content/Input.sys.mjs) + content/cdp/domains/content/Log.sys.mjs (domains/content/Log.sys.mjs) + content/cdp/domains/content/Network.sys.mjs (domains/content/Network.sys.mjs) + content/cdp/domains/content/Page.sys.mjs (domains/content/Page.sys.mjs) + content/cdp/domains/content/Performance.sys.mjs (domains/content/Performance.sys.mjs) + content/cdp/domains/content/Runtime.sys.mjs (domains/content/Runtime.sys.mjs) + content/cdp/domains/content/runtime/ExecutionContext.sys.mjs (domains/content/runtime/ExecutionContext.sys.mjs) + content/cdp/domains/content/Security.sys.mjs (domains/content/Security.sys.mjs) + content/cdp/domains/parent/Browser.sys.mjs (domains/parent/Browser.sys.mjs) + content/cdp/domains/parent/Emulation.sys.mjs (domains/parent/Emulation.sys.mjs) + content/cdp/domains/parent/Fetch.sys.mjs (domains/parent/Fetch.sys.mjs) + content/cdp/domains/parent/Input.sys.mjs (domains/parent/Input.sys.mjs) + content/cdp/domains/parent/IO.sys.mjs (domains/parent/IO.sys.mjs) + content/cdp/domains/parent/Network.sys.mjs (domains/parent/Network.sys.mjs) + content/cdp/domains/parent/Page.sys.mjs (domains/parent/Page.sys.mjs) + content/cdp/domains/parent/page/DialogHandler.sys.mjs (domains/parent/page/DialogHandler.sys.mjs) + content/cdp/domains/parent/Security.sys.mjs (domains/parent/Security.sys.mjs) + content/cdp/domains/parent/SystemInfo.sys.mjs (domains/parent/SystemInfo.sys.mjs) + content/cdp/domains/parent/Target.sys.mjs (domains/parent/Target.sys.mjs) + # observers + content/cdp/observers/ChannelEventSink.sys.mjs (observers/ChannelEventSink.sys.mjs) + content/cdp/observers/ContextObserver.sys.mjs (observers/ContextObserver.sys.mjs) + content/cdp/observers/NetworkObserver.sys.mjs (observers/NetworkObserver.sys.mjs) + content/cdp/observers/TargetObserver.sys.mjs (observers/TargetObserver.sys.mjs) + # sessions + content/cdp/sessions/frame-script.js (sessions/frame-script.js) + content/cdp/sessions/ContentProcessSession.sys.mjs (sessions/ContentProcessSession.sys.mjs) + content/cdp/sessions/MainProcessSession.sys.mjs (sessions/MainProcessSession.sys.mjs) + content/cdp/sessions/Session.sys.mjs (sessions/Session.sys.mjs) + content/cdp/sessions/TabSession.sys.mjs (sessions/TabSession.sys.mjs) + # targets + content/cdp/targets/MainProcessTarget.sys.mjs (targets/MainProcessTarget.sys.mjs) + content/cdp/targets/TabTarget.sys.mjs (targets/TabTarget.sys.mjs) + content/cdp/targets/Target.sys.mjs (targets/Target.sys.mjs) + content/cdp/targets/TargetList.sys.mjs (targets/TargetList.sys.mjs) diff --git a/remote/cdp/moz.build b/remote/cdp/moz.build new file mode 100644 index 0000000000..f1966f0101 --- /dev/null +++ b/remote/cdp/moz.build @@ -0,0 +1,28 @@ +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Remote Protocol", "CDP") + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.toml", + "test/browser/browser_cdp_only.toml", + "test/browser/dom/browser.toml", + "test/browser/emulation/browser.toml", + "test/browser/fetch/browser.toml", + "test/browser/input/browser.toml", + "test/browser/io/browser.toml", + "test/browser/log/browser.toml", + "test/browser/network/browser.toml", + "test/browser/page/browser.toml", + "test/browser/runtime/browser.toml", + "test/browser/runtime/browser_with_default_prefs.toml", + "test/browser/security/browser.toml", + "test/browser/systemInfo/browser.toml", + "test/browser/target/browser.toml", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] diff --git a/remote/cdp/observers/ChannelEventSink.sys.mjs b/remote/cdp/observers/ChannelEventSink.sys.mjs new file mode 100644 index 0000000000..48e7c6ee64 --- /dev/null +++ b/remote/cdp/observers/ChannelEventSink.sys.mjs @@ -0,0 +1,100 @@ +/* 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/. */ + +import { ComponentUtils } from "resource://gre/modules/ComponentUtils.sys.mjs"; + +const Cm = Components.manager; + +/** + * This is a nsIChannelEventSink implementation that monitors channel redirects. + * This has been forked from: + * https://searchfox.org/mozilla-central/source/devtools/server/actors/network-monitor/channel-event-sink.js + * The rest of this module is also more or less forking: + * https://searchfox.org/mozilla-central/source/devtools/server/actors/network-monitor/network-observer.js + * TODO(try to re-unify /remote/ with /devtools code) + */ +const SINK_CLASS_DESCRIPTION = "NetworkMonitor Channel Event Sink"; +const SINK_CLASS_ID = Components.ID("{c2b4c83e-607a-405a-beab-0ef5dbfb7617}"); +const SINK_CONTRACT_ID = "@mozilla.org/network/monitor/channeleventsink;1"; +const SINK_CATEGORY_NAME = "net-channel-event-sinks"; + +function ChannelEventSink() { + this.wrappedJSObject = this; + this.collectors = new Set(); +} + +ChannelEventSink.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIChannelEventSink"]), + + registerCollector(collector) { + this.collectors.add(collector); + }, + + unregisterCollector(collector) { + this.collectors.delete(collector); + + if (this.collectors.size == 0) { + ChannelEventSinkFactory.unregister(); + } + }, + + // eslint-disable-next-line no-shadow + asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { + for (const collector of this.collectors) { + try { + collector._onChannelRedirect(oldChannel, newChannel, flags); + } catch (ex) { + console.error( + "StackTraceCollector.onChannelRedirect threw an exception", + ex + ); + } + } + callback.onRedirectVerifyCallback(Cr.NS_OK); + }, +}; + +export const ChannelEventSinkFactory = + ComponentUtils.generateSingletonFactory(ChannelEventSink); + +ChannelEventSinkFactory.register = function () { + const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + if (registrar.isCIDRegistered(SINK_CLASS_ID)) { + return; + } + + registrar.registerFactory( + SINK_CLASS_ID, + SINK_CLASS_DESCRIPTION, + SINK_CONTRACT_ID, + ChannelEventSinkFactory + ); + + Services.catMan.addCategoryEntry( + SINK_CATEGORY_NAME, + SINK_CONTRACT_ID, + SINK_CONTRACT_ID, + false, + true + ); +}; + +ChannelEventSinkFactory.unregister = function () { + const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(SINK_CLASS_ID, ChannelEventSinkFactory); + + Services.catMan.deleteCategoryEntry( + SINK_CATEGORY_NAME, + SINK_CONTRACT_ID, + false + ); +}; + +ChannelEventSinkFactory.getService = function () { + // Make sure the ChannelEventSink service is registered before accessing it + ChannelEventSinkFactory.register(); + + return Cc[SINK_CONTRACT_ID].getService(Ci.nsIChannelEventSink) + .wrappedJSObject; +}; diff --git a/remote/cdp/observers/ContextObserver.sys.mjs b/remote/cdp/observers/ContextObserver.sys.mjs new file mode 100644 index 0000000000..d0d6fd1f93 --- /dev/null +++ b/remote/cdp/observers/ContextObserver.sys.mjs @@ -0,0 +1,178 @@ +/* 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/. */ + +/** + * Helper class to coordinate Runtime and Page events. + * Events have to be sent in the following order: + * - Runtime.executionContextDestroyed + * - Page.frameNavigated + * - Runtime.executionContextCreated + * + * This class also handles the special case of Pages going from/to the BF cache. + * When you navigate to a new URL, the previous document may be stored in the BF Cache. + * All its asynchronous operations are frozen (XHR, timeouts, ...) and a `pagehide` event + * is fired for this document. We then navigate to the new URL. + * If the user navigates back to the previous page, the page is resurected from the + * cache. A `pageshow` event is fired and its asynchronous operations are resumed. + * + * When a page is in the BF Cache, we should consider it as frozen and shouldn't try + * to execute any javascript. So that the ExecutionContext should be considered as + * being destroyed and the document navigated. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + executeSoon: "chrome://remote/content/shared/Sync.sys.mjs", +}); + +export class ContextObserver { + constructor(chromeEventHandler) { + this.chromeEventHandler = chromeEventHandler; + lazy.EventEmitter.decorate(this); + + this._fissionEnabled = Services.appinfo.fissionAutostart; + + this.chromeEventHandler.addEventListener("DOMWindowCreated", this, { + mozSystemGroup: true, + }); + + // Listen for pageshow and pagehide to track pages going in/out to/from the BF Cache + this.chromeEventHandler.addEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.addEventListener("pagehide", this, { + mozSystemGroup: true, + }); + + Services.obs.addObserver(this, "document-element-inserted"); + Services.obs.addObserver(this, "inner-window-destroyed"); + + // With Fission disabled the `DOMWindowCreated` event is fired too late. + // Use the `webnavigation-create` notification instead. + if (!this._fissionEnabled) { + Services.obs.addObserver(this, "webnavigation-create"); + } + Services.obs.addObserver(this, "webnavigation-destroy"); + } + + destructor() { + this.chromeEventHandler.removeEventListener("DOMWindowCreated", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("pagehide", this, { + mozSystemGroup: true, + }); + + Services.obs.removeObserver(this, "document-element-inserted"); + Services.obs.removeObserver(this, "inner-window-destroyed"); + + if (!this._fissionEnabled) { + Services.obs.removeObserver(this, "webnavigation-create"); + } + Services.obs.removeObserver(this, "webnavigation-destroy"); + } + + handleEvent({ type, target, persisted }) { + const window = target.defaultView; + const frameId = window.browsingContext.id; + const id = window.windowGlobalChild.innerWindowId; + + switch (type) { + case "DOMWindowCreated": + // Do not pass `id` here as that's the new document ID instead of the old one + // that is destroyed. Instead, pass the frameId and let the listener figure out + // what ExecutionContext(s) to destroy. + this.emit("context-destroyed", { frameId }); + + // With Fission enabled the frame is attached early enough so that + // expected network requests and responses are handles afterward. + // Otherwise send the event when `webnavigation-create` is received. + if (this._fissionEnabled) { + this.emit("frame-attached", { frameId, window }); + } + + break; + + case "pageshow": + // `persisted` is true when this is about a page being resurected from BF Cache + if (!persisted) { + return; + } + // XXX(ochameau) we might have to emit FrameNavigate here to properly handle BF Cache + // scenario in Page domain events + this.emit("context-created", { windowId: id, window }); + this.emit("script-loaded", { windowId: id, window }); + break; + + case "pagehide": + // `persisted` is true when this is about a page being frozen into BF Cache + if (!persisted) { + return; + } + this.emit("context-destroyed", { windowId: id }); + break; + } + } + + observe(subject, topic, data) { + switch (topic) { + case "document-element-inserted": + const window = subject.defaultView; + + // Ignore events without a window and those from other tabs + if ( + !window || + window.docShell.chromeEventHandler !== this.chromeEventHandler + ) { + return; + } + + // Send when the document gets attached to the window, and its location + // is available. + this.emit("frame-navigated", { + frameId: window.browsingContext.id, + window, + }); + + const id = window.windowGlobalChild.innerWindowId; + this.emit("context-created", { windowId: id, window }); + // Delay script-loaded to allow context cleanup to happen first + lazy.executeSoon(() => { + this.emit("script-loaded", { windowId: id, window }); + }); + break; + case "inner-window-destroyed": + const windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + this.emit("context-destroyed", { windowId }); + break; + case "webnavigation-create": + subject.QueryInterface(Ci.nsIDocShell); + this.onDocShellCreated(subject); + break; + case "webnavigation-destroy": + subject.QueryInterface(Ci.nsIDocShell); + this.onDocShellDestroyed(subject); + break; + } + } + + onDocShellCreated(docShell) { + this.emit("frame-attached", { + frameId: docShell.browsingContext.id, + window: docShell.browsingContext.window, + }); + } + + onDocShellDestroyed(docShell) { + this.emit("frame-detached", { + frameId: docShell.browsingContext.id, + }); + } +} diff --git a/remote/cdp/observers/NetworkObserver.sys.mjs b/remote/cdp/observers/NetworkObserver.sys.mjs new file mode 100644 index 0000000000..ffd8028ba7 --- /dev/null +++ b/remote/cdp/observers/NetworkObserver.sys.mjs @@ -0,0 +1,630 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CommonUtils: "resource://services-common/utils.sys.mjs", + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + + ChannelEventSinkFactory: + "chrome://remote/content/cdp/observers/ChannelEventSink.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gActivityDistributor", + "@mozilla.org/network/http-activity-distributor;1", + "nsIHttpActivityDistributor" +); + +const CC = Components.Constructor; + +ChromeUtils.defineLazyGetter(lazy, "BinaryInputStream", () => { + return CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" + ); +}); + +ChromeUtils.defineLazyGetter(lazy, "BinaryOutputStream", () => { + return CC( + "@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream" + ); +}); + +ChromeUtils.defineLazyGetter(lazy, "StorageStream", () => { + return CC("@mozilla.org/storagestream;1", "nsIStorageStream", "init"); +}); + +// Cap response storage with 100Mb per tracked tab. +const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024; + +export class NetworkObserver { + constructor() { + lazy.EventEmitter.decorate(this); + this._browserSessionCount = new Map(); + lazy.gActivityDistributor.addObserver(this); + lazy.ChannelEventSinkFactory.getService().registerCollector(this); + + this._redirectMap = new Map(); + + // Request interception state. + this._browserSuspendedChannels = new Map(); + this._extraHTTPHeaders = new Map(); + this._browserResponseStorages = new Map(); + + this._onRequest = this._onRequest.bind(this); + this._onExamineResponse = this._onResponse.bind( + this, + false /* fromCache */ + ); + this._onCachedResponse = this._onResponse.bind(this, true /* fromCache */); + } + + dispose() { + lazy.gActivityDistributor.removeObserver(this); + lazy.ChannelEventSinkFactory.getService().unregisterCollector(this); + + Services.obs.removeObserver(this._onRequest, "http-on-modify-request"); + Services.obs.removeObserver( + this._onExamineResponse, + "http-on-examine-response" + ); + Services.obs.removeObserver( + this._onCachedResponse, + "http-on-examine-cached-response" + ); + Services.obs.removeObserver( + this._onCachedResponse, + "http-on-examine-merged-response" + ); + } + + setExtraHTTPHeaders(browser, headers) { + if (!headers) { + this._extraHTTPHeaders.delete(browser); + } else { + this._extraHTTPHeaders.set(browser, headers); + } + } + + enableRequestInterception(browser) { + if (!this._browserSuspendedChannels.has(browser)) { + this._browserSuspendedChannels.set(browser, new Map()); + } + } + + disableRequestInterception(browser) { + const suspendedChannels = this._browserSuspendedChannels.get(browser); + if (!suspendedChannels) { + return; + } + this._browserSuspendedChannels.delete(browser); + for (const channel of suspendedChannels.values()) { + channel.resume(); + } + } + + resumeSuspendedRequest(browser, requestId, headers) { + const suspendedChannels = this._browserSuspendedChannels.get(browser); + if (!suspendedChannels) { + throw new Error(`Request interception is not enabled`); + } + const httpChannel = suspendedChannels.get(requestId); + if (!httpChannel) { + throw new Error(`Cannot find request "${requestId}"`); + } + if (headers) { + // 1. Clear all previous headers. + for (const header of requestHeaders(httpChannel)) { + httpChannel.setRequestHeader(header.name, "", false /* merge */); + } + // 2. Set new headers. + for (const header of headers) { + httpChannel.setRequestHeader( + header.name, + header.value, + false /* merge */ + ); + } + } + suspendedChannels.delete(requestId); + httpChannel.resume(); + } + + getResponseBody(browser, requestId) { + const responseStorage = this._browserResponseStorages.get(browser); + if (!responseStorage) { + throw new Error("Responses are not tracked for the given browser"); + } + return responseStorage.getBase64EncodedResponse(requestId); + } + + abortSuspendedRequest(browser, aRequestId) { + const suspendedChannels = this._browserSuspendedChannels.get(browser); + if (!suspendedChannels) { + throw new Error(`Request interception is not enabled`); + } + const httpChannel = suspendedChannels.get(aRequestId); + if (!httpChannel) { + throw new Error(`Cannot find request "${aRequestId}"`); + } + suspendedChannels.delete(aRequestId); + httpChannel.cancel(Cr.NS_ERROR_FAILURE); + httpChannel.resume(); + this.emit("requestfailed", httpChannel, { + requestId: requestId(httpChannel), + errorCode: getNetworkErrorStatusText(httpChannel.status), + }); + } + + _onChannelRedirect(oldChannel, newChannel) { + // We can be called with any nsIChannel, but are interested only in HTTP channels + try { + oldChannel.QueryInterface(Ci.nsIHttpChannel); + newChannel.QueryInterface(Ci.nsIHttpChannel); + } catch (ex) { + return; + } + + const httpChannel = oldChannel.QueryInterface(Ci.nsIHttpChannel); + const loadContext = getLoadContext(httpChannel); + if ( + !loadContext || + !this._browserSessionCount.has(loadContext.topFrameElement) + ) { + return; + } + this._redirectMap.set(newChannel, oldChannel); + } + + _onRequest(channel, topic) { + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + const loadContext = getLoadContext(httpChannel); + const browser = loadContext?.topFrameElement; + if (!loadContext || !this._browserSessionCount.has(browser)) { + return; + } + + const extraHeaders = this._extraHTTPHeaders.get(browser); + if (extraHeaders) { + for (const header of extraHeaders) { + httpChannel.setRequestHeader( + header.name, + header.value, + false /* merge */ + ); + } + } + const causeType = httpChannel.loadInfo + ? httpChannel.loadInfo.externalContentPolicyType + : Ci.nsIContentPolicy.TYPE_OTHER; + + const suspendedChannels = this._browserSuspendedChannels.get(browser); + if (suspendedChannels) { + httpChannel.suspend(); + suspendedChannels.set(requestId(httpChannel), httpChannel); + } + + const oldChannel = this._redirectMap.get(httpChannel); + this._redirectMap.delete(httpChannel); + + // Install response body hooks. + new ResponseBodyListener(this, browser, httpChannel); + + this.emit("request", httpChannel, { + url: httpChannel.URI.spec, + suspended: suspendedChannels ? true : undefined, + requestId: requestId(httpChannel), + redirectedFrom: oldChannel ? requestId(oldChannel) : undefined, + postData: readRequestPostData(httpChannel), + headers: requestHeaders(httpChannel), + method: httpChannel.requestMethod, + isNavigationRequest: httpChannel.isMainDocumentChannel, + cause: causeType, + causeString: causeTypeToString(causeType), + frameId: this.frameId(httpChannel), + // clients expect loaderId == requestId for document navigation + loaderId: [ + Ci.nsIContentPolicy.TYPE_DOCUMENT, + Ci.nsIContentPolicy.TYPE_SUBDOCUMENT, + ].includes(causeType) + ? requestId(httpChannel) + : undefined, + }); + } + + _onResponse(fromCache, httpChannel, topic) { + const loadContext = getLoadContext(httpChannel); + if ( + !loadContext || + !this._browserSessionCount.has(loadContext.topFrameElement) + ) { + return; + } + httpChannel.QueryInterface(Ci.nsIHttpChannelInternal); + const causeType = httpChannel.loadInfo + ? httpChannel.loadInfo.externalContentPolicyType + : Ci.nsIContentPolicy.TYPE_OTHER; + let remoteIPAddress; + let remotePort; + try { + remoteIPAddress = httpChannel.remoteAddress; + remotePort = httpChannel.remotePort; + } catch (e) { + // remoteAddress is not defined for cached requests. + } + + this.emit("response", httpChannel, { + requestId: requestId(httpChannel), + securityDetails: getSecurityDetails(httpChannel), + fromCache, + headers: responseHeaders(httpChannel), + requestHeaders: requestHeaders(httpChannel), + remoteIPAddress, + remotePort, + status: httpChannel.responseStatus, + statusText: httpChannel.responseStatusText, + cause: causeType, + causeString: causeTypeToString(causeType), + frameId: this.frameId(httpChannel), + // clients expect loaderId == requestId for document navigation + loaderId: [ + Ci.nsIContentPolicy.TYPE_DOCUMENT, + Ci.nsIContentPolicy.TYPE_SUBDOCUMENT, + ].includes(causeType) + ? requestId(httpChannel) + : undefined, + }); + } + + _onResponseFinished(browser, httpChannel, body) { + const responseStorage = this._browserResponseStorages.get(browser); + if (!responseStorage) { + return; + } + responseStorage.addResponseBody(httpChannel, body); + this.emit("requestfinished", httpChannel, { + requestId: requestId(httpChannel), + errorCode: getNetworkErrorStatusText(httpChannel.status), + }); + } + + isActive(browser) { + return !!this._browserSessionCount.get(browser); + } + + startTrackingBrowserNetwork(browser) { + const value = this._browserSessionCount.get(browser) || 0; + this._browserSessionCount.set(browser, value + 1); + if (value === 0) { + Services.obs.addObserver(this._onRequest, "http-on-modify-request"); + Services.obs.addObserver( + this._onExamineResponse, + "http-on-examine-response" + ); + Services.obs.addObserver( + this._onCachedResponse, + "http-on-examine-cached-response" + ); + Services.obs.addObserver( + this._onCachedResponse, + "http-on-examine-merged-response" + ); + this._browserResponseStorages.set( + browser, + new ResponseStorage( + MAX_RESPONSE_STORAGE_SIZE, + MAX_RESPONSE_STORAGE_SIZE / 10 + ) + ); + } + return () => this.stopTrackingBrowserNetwork(browser); + } + + stopTrackingBrowserNetwork(browser) { + const value = this._browserSessionCount.get(browser); + if (value) { + this._browserSessionCount.set(browser, value - 1); + } else { + this._browserSessionCount.delete(browser); + this._browserResponseStorages.delete(browser); + this.dispose(); + } + } + + /** + * Returns the frameId of the current httpChannel. + */ + frameId(httpChannel) { + const loadInfo = httpChannel.loadInfo; + return loadInfo.frameBrowsingContext?.id || loadInfo.browsingContext.id; + } +} + +const protocolVersionNames = { + [Ci.nsITransportSecurityInfo.TLS_VERSION_1]: "TLS 1", + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_1]: "TLS 1.1", + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_2]: "TLS 1.2", + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_3]: "TLS 1.3", +}; + +function getSecurityDetails(httpChannel) { + const securityInfo = httpChannel.securityInfo; + if (!securityInfo) { + return null; + } + if (!securityInfo.serverCert) { + return null; + } + return { + protocol: protocolVersionNames[securityInfo.protocolVersion] || "<unknown>", + subjectName: securityInfo.serverCert.commonName, + issuer: securityInfo.serverCert.issuerCommonName, + // Convert to seconds. + validFrom: securityInfo.serverCert.validity.notBefore / 1000 / 1000, + validTo: securityInfo.serverCert.validity.notAfter / 1000 / 1000, + }; +} + +function readRequestPostData(httpChannel) { + if (!(httpChannel instanceof Ci.nsIUploadChannel)) { + return undefined; + } + const iStream = httpChannel.uploadStream; + if (!iStream) { + return undefined; + } + const isSeekableStream = iStream instanceof Ci.nsISeekableStream; + + let prevOffset; + if (isSeekableStream) { + prevOffset = iStream.tell(); + iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + } + + // Read data from the stream. + let text; + try { + text = lazy.NetUtil.readInputStreamToString(iStream, iStream.available()); + const converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + text = converter.ConvertToUnicode(text); + } catch (err) { + text = undefined; + } + + // Seek locks the file, so seek to the beginning only if necko hasn"t + // read it yet, since necko doesn"t seek to 0 before reading (at lest + // not till 459384 is fixed). + if (isSeekableStream && prevOffset == 0) { + iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + } + return text; +} + +function getLoadContext(httpChannel) { + let loadContext = null; + try { + if (httpChannel.notificationCallbacks) { + loadContext = httpChannel.notificationCallbacks.getInterface( + Ci.nsILoadContext + ); + } + } catch (e) {} + try { + if (!loadContext && httpChannel.loadGroup) { + loadContext = httpChannel.loadGroup.notificationCallbacks.getInterface( + Ci.nsILoadContext + ); + } + } catch (e) {} + return loadContext; +} + +function requestId(httpChannel) { + return String(httpChannel.channelId); +} + +function requestHeaders(httpChannel) { + const headers = []; + httpChannel.visitRequestHeaders({ + visitHeader: (name, value) => headers.push({ name, value }), + }); + return headers; +} + +function responseHeaders(httpChannel) { + const headers = []; + httpChannel.visitResponseHeaders({ + visitHeader: (name, value) => headers.push({ name, value }), + }); + return headers; +} + +function causeTypeToString(causeType) { + for (let key in Ci.nsIContentPolicy) { + if (Ci.nsIContentPolicy[key] === causeType) { + return key; + } + } + return "TYPE_OTHER"; +} + +class ResponseStorage { + constructor(maxTotalSize, maxResponseSize) { + this._totalSize = 0; + this._maxResponseSize = maxResponseSize; + this._maxTotalSize = maxTotalSize; + this._responses = new Map(); + } + + addResponseBody(httpChannel, body) { + if (body.length > this._maxResponseSize) { + this._responses.set(requestId, { + evicted: true, + body: "", + }); + return; + } + let encodings = []; + if ( + httpChannel instanceof Ci.nsIEncodedChannel && + httpChannel.contentEncodings && + !httpChannel.applyConversion + ) { + const encodingHeader = httpChannel.getResponseHeader("Content-Encoding"); + encodings = encodingHeader.split(/\s*\t*,\s*\t*/); + } + this._responses.set(requestId(httpChannel), { body, encodings }); + this._totalSize += body.length; + if (this._totalSize > this._maxTotalSize) { + for (let [, response] of this._responses) { + this._totalSize -= response.body.length; + response.body = ""; + response.evicted = true; + if (this._totalSize < this._maxTotalSize) { + break; + } + } + } + } + + getBase64EncodedResponse(requestId) { + const response = this._responses.get(requestId); + if (!response) { + throw new Error(`Request "${requestId}" is not found`); + } + if (response.evicted) { + return { base64body: "", evicted: true }; + } + let result = response.body; + if (response.encodings && response.encodings.length) { + for (const encoding of response.encodings) { + result = lazy.CommonUtils.convertString( + result, + encoding, + "uncompressed" + ); + } + } + return { base64body: btoa(result) }; + } +} + +class ResponseBodyListener { + constructor(networkObserver, browser, httpChannel) { + this._networkObserver = networkObserver; + this._browser = browser; + this._httpChannel = httpChannel; + this._chunks = []; + this.QueryInterface = ChromeUtils.generateQI(["nsIStreamListener"]); + httpChannel.QueryInterface(Ci.nsITraceableChannel); + this.originalListener = httpChannel.setNewListener(this); + } + + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + const iStream = new lazy.BinaryInputStream(aInputStream); + const sStream = new lazy.StorageStream(8192, aCount, null); + const oStream = new lazy.BinaryOutputStream(sStream.getOutputStream(0)); + + // Copy received data as they come. + const data = iStream.readBytes(aCount); + this._chunks.push(data); + + oStream.writeBytes(data, aCount); + this.originalListener.onDataAvailable( + aRequest, + sStream.newInputStream(0), + aOffset, + aCount + ); + } + + onStartRequest(aRequest) { + this.originalListener.onStartRequest(aRequest); + } + + onStopRequest(aRequest, aStatusCode) { + this.originalListener.onStopRequest(aRequest, aStatusCode); + const body = this._chunks.join(""); + delete this._chunks; + this._networkObserver._onResponseFinished( + this._browser, + this._httpChannel, + body + ); + } +} + +function getNetworkErrorStatusText(status) { + if (!status) { + return null; + } + for (const key of Object.keys(Cr)) { + if (Cr[key] === status) { + return key; + } + } + // Security module. The following is taken from + // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/How_to_check_the_secruity_state_of_an_XMLHTTPRequest_over_SSL + if ((status & 0xff0000) === 0x5a0000) { + // NSS_SEC errors (happen below the base value because of negative vals) + if ( + (status & 0xffff) < + Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) + ) { + // The bases are actually negative, so in our positive numeric space, we + // need to subtract the base off our value. + const nssErr = + Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff); + switch (nssErr) { + case 11: + return "SEC_ERROR_EXPIRED_CERTIFICATE"; + case 12: + return "SEC_ERROR_REVOKED_CERTIFICATE"; + case 13: + return "SEC_ERROR_UNKNOWN_ISSUER"; + case 20: + return "SEC_ERROR_UNTRUSTED_ISSUER"; + case 21: + return "SEC_ERROR_UNTRUSTED_CERT"; + case 36: + return "SEC_ERROR_CA_CERT_INVALID"; + case 90: + return "SEC_ERROR_INADEQUATE_KEY_USAGE"; + case 176: + return "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED"; + default: + return "SEC_ERROR_UNKNOWN"; + } + } + const sslErr = + Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff); + switch (sslErr) { + case 3: + return "SSL_ERROR_NO_CERTIFICATE"; + case 4: + return "SSL_ERROR_BAD_CERTIFICATE"; + case 8: + return "SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE"; + case 9: + return "SSL_ERROR_UNSUPPORTED_VERSION"; + case 12: + return "SSL_ERROR_BAD_CERT_DOMAIN"; + default: + return "SSL_ERROR_UNKNOWN"; + } + } + return "<unknown error>"; +} diff --git a/remote/cdp/observers/TargetObserver.sys.mjs b/remote/cdp/observers/TargetObserver.sys.mjs new file mode 100644 index 0000000000..dfd9e2d9dc --- /dev/null +++ b/remote/cdp/observers/TargetObserver.sys.mjs @@ -0,0 +1,142 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", +}); + +// TODO(ato): +// +// The DOM team is working on pulling browsing context related behaviour, +// such as window and tab handling, out of product code and into the platform. +// This will have implication for the remote agent, +// and as the platform gains support for product-independent events +// we can likely get rid of this entire module. + +/** + * Observe Firefox tabs as they open and close. + * + * "open" fires when a tab opens. + * "close" fires when a tab closes. + */ +export class TabObserver { + /** + * @param {boolean?} [false] registerExisting + * Events will be fired for ChromeWIndows and their respective tabs + * at the time when the observer is started. + */ + constructor({ registerExisting = false } = {}) { + lazy.EventEmitter.decorate(this); + + this.registerExisting = registerExisting; + + this.onTabOpen = this.onTabOpen.bind(this); + this.onTabClose = this.onTabClose.bind(this); + } + + async start() { + Services.wm.addListener(this); + + if (this.registerExisting) { + // Start listening for events on already open windows + for (const win of Services.wm.getEnumerator("navigator:browser")) { + this._registerDOMWindow(win); + } + } + } + + stop() { + Services.wm.removeListener(this); + + // Stop listening for events on still opened windows + for (const win of Services.wm.getEnumerator("navigator:browser")) { + this._unregisterDOMWindow(win); + } + } + + // Event emitters + + onTabOpen({ target }) { + this.emit("open", target); + } + + onTabClose({ target }) { + this.emit("close", target); + } + + // Internal methods + + _registerDOMWindow(win) { + for (const tab of win.gBrowser.tabs) { + // a missing linkedBrowser means the tab is still initialising, + // and a TabOpen event will fire once it is ready + if (!tab.linkedBrowser) { + continue; + } + + this.onTabOpen({ target: tab }); + } + + win.gBrowser.tabContainer.addEventListener("TabOpen", this.onTabOpen); + win.gBrowser.tabContainer.addEventListener("TabClose", this.onTabClose); + } + + _unregisterDOMWindow(win) { + for (const tab of win.gBrowser.tabs) { + // a missing linkedBrowser means the tab is still initialising + if (!tab.linkedBrowser) { + continue; + } + + // Emulate custom "TabClose" events because that event is not + // fired for each of the tabs when the window closes. + this.onTabClose({ target: tab }); + } + + win.gBrowser.tabContainer.removeEventListener("TabOpen", this.onTabOpen); + win.gBrowser.tabContainer.removeEventListener("TabClose", this.onTabClose); + } + + // nsIWindowMediatorListener + + async onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + + await new lazy.EventPromise(win, "load"); + + // Return early if it's not a browser window + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + + this._registerDOMWindow(win); + } + + onCloseWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + + // Return early if it's not a browser window + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + + this._unregisterDOMWindow(win); + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIWindowMediatorListener"]); + } +} diff --git a/remote/cdp/sessions/ContentProcessSession.sys.mjs b/remote/cdp/sessions/ContentProcessSession.sys.mjs new file mode 100644 index 0000000000..d7aa3de57b --- /dev/null +++ b/remote/cdp/sessions/ContentProcessSession.sys.mjs @@ -0,0 +1,104 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentProcessDomains: + "chrome://remote/content/cdp/domains/ContentProcessDomains.sys.mjs", + ContextObserver: + "chrome://remote/content/cdp/observers/ContextObserver.sys.mjs", + DomainCache: "chrome://remote/content/cdp/domains/DomainCache.sys.mjs", +}); + +export class ContentProcessSession { + constructor(messageManager, browsingContext, content, docShell) { + this.messageManager = messageManager; + this.browsingContext = browsingContext; + this.content = content; + this.docShell = docShell; + // Most children or sibling classes are going to assume that docShell + // implements the following interface. So do the QI only once from here. + this.docShell.QueryInterface(Ci.nsIWebNavigation); + + this.domains = new lazy.DomainCache(this, lazy.ContentProcessDomains); + this.messageManager.addMessageListener("remote:request", this); + this.messageManager.addMessageListener("remote:destroy", this); + } + + destructor() { + this._contextObserver?.destructor(); + + this.messageManager.removeMessageListener("remote:request", this); + this.messageManager.removeMessageListener("remote:destroy", this); + this.domains.clear(); + } + + get contextObserver() { + if (!this._contextObserver) { + this._contextObserver = new lazy.ContextObserver( + this.docShell.chromeEventHandler + ); + } + return this._contextObserver; + } + + // Domain event listener + + onEvent(eventName, params) { + this.messageManager.sendAsyncMessage("remote:event", { + browsingContextId: this.browsingContext.id, + event: { + eventName, + params, + }, + }); + } + + // nsIMessageListener + + async receiveMessage({ name, data }) { + const { browsingContextId } = data; + + // We may have more than one tab loaded in the same process, + // and debug the two at the same time. We want to ensure not + // mixing up the requests made against two such tabs. + // Each tab is going to have its own frame script instance + // and two communication channels are going to be set up via + // the two message managers. + if (browsingContextId != this.browsingContext.id) { + return; + } + + switch (name) { + case "remote:request": + try { + const { id, domain, command, params } = data.request; + + const result = await this.domains.execute(domain, command, params); + + this.messageManager.sendAsyncMessage("remote:result", { + browsingContextId, + id, + result, + }); + } catch (e) { + this.messageManager.sendAsyncMessage("remote:error", { + browsingContextId, + id: data.request.id, + error: { + name: e.name || "exception", + message: e.message || String(e), + stack: e.stack, + }, + }); + } + break; + + case "remote:destroy": + this.destructor(); + break; + } + } +} diff --git a/remote/cdp/sessions/MainProcessSession.sys.mjs b/remote/cdp/sessions/MainProcessSession.sys.mjs new file mode 100644 index 0000000000..259e1312ac --- /dev/null +++ b/remote/cdp/sessions/MainProcessSession.sys.mjs @@ -0,0 +1,12 @@ +/* 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/. */ + +import { Session } from "chrome://remote/content/cdp/sessions/Session.sys.mjs"; + +/** + * A session, dedicated to the main process target. + * For some reason, it doesn't need any specific code and can share the base Session class + * aside TabSession. + */ +export class MainProcessSession extends Session {} diff --git a/remote/cdp/sessions/Session.sys.mjs b/remote/cdp/sessions/Session.sys.mjs new file mode 100644 index 0000000000..cbc82fb097 --- /dev/null +++ b/remote/cdp/sessions/Session.sys.mjs @@ -0,0 +1,80 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DomainCache: "chrome://remote/content/cdp/domains/DomainCache.sys.mjs", + NetworkObserver: + "chrome://remote/content/cdp/observers/NetworkObserver.sys.mjs", + ParentProcessDomains: + "chrome://remote/content/cdp/domains/ParentProcessDomains.sys.mjs", +}); + +/** + * A session represents exactly one client WebSocket connection. + * + * Every new WebSocket connections is associated with one session that + * deals with dispatching incoming command requests to the right + * target, sending back responses, and propagating events originating + * from domains. + * Then, some subsequent Sessions may be created over a single WebSocket + * connection. In this case, the subsequent session will have an `id` + * being passed to their constructor and each packet of these sessions + * will have a `sessionId` attribute in order to filter the packets + * by session both on client and server side. + */ +export class Session { + /** + * @param {Connection} connection + * The connection used to communicate with the server. + * @param {Target} target + * The target to which this session communicates with. + * @param {number=} id + * If this session isn't the default one used for the HTTP endpoint we + * connected to, the session requires an id to distinguish it from the default + * one. This id is used to filter our request, responses and events between + * all active sessions. For now, this is only passed by `Target.attachToTarget()`. + */ + constructor(connection, target, id) { + this.connection = connection; + this.target = target; + this.id = id; + + this.domains = new lazy.DomainCache(this, lazy.ParentProcessDomains); + } + + destructor() { + if ( + this.networkObserver && + this.networkObserver.isActive(this.target.browser) + ) { + this.networkObserver.dispose(); + } + this.domains.clear(); + } + + execute(id, domain, command, params) { + return this.domains.execute(domain, command, params); + } + + get networkObserver() { + if (!this._networkObserver) { + this._networkObserver = new lazy.NetworkObserver(); + } + return this._networkObserver; + } + + /** + * Domains event listener. Called when an event is fired + * by any Domain and has to be sent to the client. + */ + onEvent(eventName, params) { + this.connection.sendEvent(eventName, params, this.id); + } + + toString() { + return `[object ${this.constructor.name} ${this.connection.id}]`; + } +} diff --git a/remote/cdp/sessions/TabSession.sys.mjs b/remote/cdp/sessions/TabSession.sys.mjs new file mode 100644 index 0000000000..8c8b5f585e --- /dev/null +++ b/remote/cdp/sessions/TabSession.sys.mjs @@ -0,0 +1,148 @@ +/* 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/. */ + +import { Session } from "chrome://remote/content/cdp/sessions/Session.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.CDP) +); + +/** + * A session to communicate with a given tab + */ +export class TabSession extends Session { + /** + * @param {Connection} connection + * The connection used to communicate with the server. + * @param {TabTarget} target + * The tab target to which this session communicates with. + * @param {number=} id + * If this session isn't the default one used for the HTTP endpoint we + * connected to, the session requires an id to distinguish it from the default + * one. This id is used to filter our request, responses and events between + * all active sessions. + * For now, this is only passed by `Target.attachToTarget()`. + * Otherwise it will be undefined when you are connecting directly to + * a given Tab. i.e. connect directly to the WebSocket URL provided by + * /json/list HTTP endpoint. + */ + constructor(connection, target, id) { + super(connection, target, id); + + // Request id => { resolve, reject } + this.requestPromises = new Map(); + + this.registerFramescript(this.mm); + + this.target.browser.addEventListener("XULFrameLoaderCreated", this); + } + + destructor() { + super.destructor(); + + this.requestPromises.clear(); + + this.target.browser.removeEventListener("XULFrameLoaderCreated", this); + + // this.mm might be null if the browser of the TabTarget was already closed. + // See Bug 1747301. + this.mm?.sendAsyncMessage("remote:destroy", { + browsingContextId: this.browsingContext.id, + }); + + this.mm?.removeMessageListener("remote:event", this); + this.mm?.removeMessageListener("remote:result", this); + this.mm?.removeMessageListener("remote:error", this); + } + + execute(id, domain, command, params) { + // Check if the domain and command is implemented in the parent + // and execute it there. Otherwise forward the command to the content process + // in order to try to execute it in the content process. + if (this.domains.domainSupportsMethod(domain, command)) { + return super.execute(id, domain, command, params); + } + return this.executeInChild(id, domain, command, params); + } + + executeInChild(id, domain, command, params) { + return new Promise((resolve, reject) => { + // Save the promise's resolution and rejection handler in order to later + // resolve this promise once we receive the reply back from the content process. + this.requestPromises.set(id, { resolve, reject }); + + this.mm.sendAsyncMessage("remote:request", { + browsingContextId: this.browsingContext.id, + request: { id, domain, command, params }, + }); + }); + } + + get mm() { + return this.target.mm; + } + + get browsingContext() { + return this.target.browsingContext; + } + + /** + * Register the framescript and listeners for the given message manager. + * + * @param {MessageManager} messageManager + * The message manager to use. + */ + registerFramescript(messageManager) { + messageManager.loadFrameScript( + "chrome://remote/content/cdp/sessions/frame-script.js", + false + ); + + messageManager.addMessageListener("remote:event", this); + messageManager.addMessageListener("remote:result", this); + messageManager.addMessageListener("remote:error", this); + } + + // Event handler + handleEvent = function ({ target, type }) { + switch (type) { + case "XULFrameLoaderCreated": + if (target === this.target.browser) { + lazy.logger.trace("Remoteness change detected"); + this.registerFramescript(this.mm); + } + break; + } + }; + + // nsIMessageListener + + receiveMessage({ name, data }) { + const { id, result, event, error } = data; + + switch (name) { + case "remote:result": + const { resolve } = this.requestPromises.get(id); + resolve(result); + this.requestPromises.delete(id); + break; + + case "remote:event": + this.connection.sendEvent(event.eventName, event.params, this.id); + break; + + case "remote:error": + const { reject } = this.requestPromises.get(id); + reject(error); + this.requestPromises.delete(id); + break; + } + } +} diff --git a/remote/cdp/sessions/frame-script.js b/remote/cdp/sessions/frame-script.js new file mode 100644 index 0000000000..88071086cb --- /dev/null +++ b/remote/cdp/sessions/frame-script.js @@ -0,0 +1,13 @@ +/* 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/. */ + +/* eslint-env mozilla/frame-script */ + +"use strict"; + +const { ContentProcessSession } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/sessions/ContentProcessSession.sys.mjs" +); + +new ContentProcessSession(this, docShell.browsingContext, content, docShell); diff --git a/remote/cdp/targets/MainProcessTarget.sys.mjs b/remote/cdp/targets/MainProcessTarget.sys.mjs new file mode 100644 index 0000000000..1f32b5bde5 --- /dev/null +++ b/remote/cdp/targets/MainProcessTarget.sys.mjs @@ -0,0 +1,55 @@ +/* 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/. */ + +import { Target } from "chrome://remote/content/cdp/targets/Target.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MainProcessSession: + "chrome://remote/content/cdp/sessions/MainProcessSession.sys.mjs", + RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", +}); + +/** + * The main process Target. + * + * Matches BrowserDevToolsAgentHost from chromium, and only support a couple of Domains: + * https://cs.chromium.org/chromium/src/content/browser/devtools/browser_devtools_agent_host.cc?dr=CSs&g=0&l=80-91 + */ +export class MainProcessTarget extends Target { + /* + * @param TargetList targetList + */ + constructor(targetList) { + super(targetList, lazy.MainProcessSession); + + this.type = "browser"; + + // Define the HTTP path to query this target + this.path = `/devtools/browser/${this.id}`; + } + + get wsDebuggerURL() { + const { host, port } = lazy.RemoteAgent; + return `ws://${host}:${port}${this.path}`; + } + + toString() { + return `[object MainProcessTarget]`; + } + + toJSON() { + return { + description: "Main process target", + devtoolsFrontendUrl: "", + faviconUrl: "", + id: this.id, + title: "Main process target", + type: this.type, + url: "", + webSocketDebuggerUrl: this.wsDebuggerURL, + }; + } +} diff --git a/remote/cdp/targets/TabTarget.sys.mjs b/remote/cdp/targets/TabTarget.sys.mjs new file mode 100644 index 0000000000..a35b0c3f07 --- /dev/null +++ b/remote/cdp/targets/TabTarget.sys.mjs @@ -0,0 +1,161 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Target } from "chrome://remote/content/cdp/targets/Target.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + TabSession: "chrome://remote/content/cdp/sessions/TabSession.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "Favicons", + "@mozilla.org/browser/favicon-service;1", + "nsIFaviconService" +); + +/** + * Target for a local tab or a remoted frame. + */ +export class TabTarget extends Target { + /** + * @param {TargetList} targetList + * @param {BrowserElement} browser + */ + constructor(targetList, browser) { + super(targetList, lazy.TabSession); + + this.browser = browser; + + // The tab target uses a unique id as shared with WebDriver to reference + // a specific tab. + this.id = lazy.TabManager.getIdForBrowser(browser); + + // Define the HTTP path to query this target + this.path = `/devtools/page/${this.id}`; + + Services.obs.addObserver(this, "message-manager-disconnect"); + } + + destructor() { + Services.obs.removeObserver(this, "message-manager-disconnect"); + super.destructor(); + } + + get browserContextId() { + return parseInt(this.browser.getAttribute("usercontextid")); + } + + get browsingContext() { + return this.browser.browsingContext; + } + + get mm() { + return this.browser.messageManager; + } + + get window() { + return this.browser.ownerGlobal; + } + + get tab() { + return this.window.gBrowser.getTabForBrowser(this.browser); + } + + /** + * Determines if the content browser remains attached + * to its parent chrome window. + * + * We determine this by checking if the <browser> element + * is still attached to the DOM. + * + * @returns {boolean} + * True if target's browser is still attached, + * false if it has been disconnected. + */ + get closed() { + return !this.browser || !this.browser.isConnected; + } + + get description() { + return ""; + } + + get frontendURL() { + return null; + } + + /** @returns {Promise<string|null>} */ + get faviconUrl() { + return new Promise((resolve, reject) => { + lazy.Favicons.getFaviconURLForPage(this.browser.currentURI, url => { + if (url) { + resolve(url.spec); + } else { + resolve(null); + } + }); + }); + } + + get title() { + return this.browsingContext.currentWindowGlobal.documentTitle; + } + + get type() { + return "page"; + } + + get url() { + return this.browser.currentURI.spec; + } + + get wsDebuggerURL() { + const { host, port } = lazy.RemoteAgent; + return `ws://${host}:${port}${this.path}`; + } + + toString() { + return `[object Target ${this.id}]`; + } + + toJSON() { + return { + description: this.description, + devtoolsFrontendUrl: this.frontendURL, + // TODO(ato): toJSON cannot be marked async )-: + faviconUrl: "", + id: this.id, + // Bug 1680817: Fails to encode some UTF-8 characters + // title: this.title, + type: this.type, + url: this.url, + webSocketDebuggerUrl: this.wsDebuggerURL, + }; + } + + // nsIObserver + + observe(subject, topic, data) { + if (subject === this.mm && subject == "message-manager-disconnect") { + // disconnect debugging target if <browser> is disconnected, + // otherwise this is just a host process change + if (this.closed) { + this.disconnect(); + } + } + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIHttpRequestHandler", "nsIObserver"]); + } +} diff --git a/remote/cdp/targets/Target.sys.mjs b/remote/cdp/targets/Target.sys.mjs new file mode 100644 index 0000000000..9264110c37 --- /dev/null +++ b/remote/cdp/targets/Target.sys.mjs @@ -0,0 +1,62 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CDPConnection: "chrome://remote/content/cdp/CDPConnection.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + WebSocketHandshake: + "chrome://remote/content/server/WebSocketHandshake.sys.mjs", +}); + +/** + * Base class for all the targets. + */ +export class Target { + /** + * @param {TargetList} targetList + * @param {Class} sessionClass + */ + constructor(targetList, sessionClass) { + // Save a reference to TargetList instance in order to expose it to: + // domains/parent/Target.jsm + this.targetList = targetList; + + // When a new connection is made to this target, + // we will instantiate a new session based on this given class. + // The session class is specific to each target's kind and is passed + // by the inheriting class. + this.sessionClass = sessionClass; + + // There can be more than one connection if multiple clients connect to the remote agent. + this.connections = new Set(); + this.id = lazy.generateUUID(); + } + + /** + * Close all active connections made to this target. + */ + destructor() { + for (const conn of this.connections) { + conn.close(); + } + } + + // nsIHttpRequestHandler + + async handle(request, response) { + const webSocket = await lazy.WebSocketHandshake.upgrade(request, response); + const conn = new lazy.CDPConnection(webSocket, response._connection); + const session = new this.sessionClass(conn, this); + conn.registerSession(session); + this.connections.add(conn); + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIHttpRequestHandler"]); + } +} diff --git a/remote/cdp/targets/TargetList.sys.mjs b/remote/cdp/targets/TargetList.sys.mjs new file mode 100644 index 0000000000..a68b36763f --- /dev/null +++ b/remote/cdp/targets/TargetList.sys.mjs @@ -0,0 +1,159 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + MainProcessTarget: + "chrome://remote/content/cdp/targets/MainProcessTarget.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + TabObserver: "chrome://remote/content/cdp/observers/TargetObserver.sys.mjs", + TabTarget: "chrome://remote/content/cdp/targets/TabTarget.sys.mjs", +}); + +export class TargetList { + constructor() { + // Target ID -> Target + this._targets = new Map(); + + lazy.EventEmitter.decorate(this); + } + + /** + * Start listing and listening for all the debuggable targets + */ + async watchForTargets() { + await this.watchForTabs(); + } + + unwatchForTargets() { + this.unwatchForTabs(); + } + + /** + * Watch for all existing and new tabs being opened. + * So that we can create the related TabTarget instance for + * each of them. + */ + async watchForTabs() { + if (this.tabObserver) { + throw new Error("Targets is already watching for new tabs"); + } + + this.tabObserver = new lazy.TabObserver({ registerExisting: true }); + + // Handle creation of tab targets for opened tabs. + this.tabObserver.on("open", async (eventName, tab) => { + const target = new lazy.TabTarget(this, tab.linkedBrowser); + this.registerTarget(target); + }); + + // Handle removal of tab targets when tabs are closed. + this.tabObserver.on("close", (eventName, tab) => { + const browser = tab.linkedBrowser; + + // Ignore unloaded tabs. + if (browser.browserId === 0) { + return; + } + + const id = lazy.TabManager.getIdForBrowser(browser); + const target = Array.from(this._targets.values()).find( + target => target.id == id + ); + if (target) { + this.destroyTarget(target); + } + }); + await this.tabObserver.start(); + } + + unwatchForTabs() { + if (this.tabObserver) { + this.tabObserver.stop(); + this.tabObserver = null; + } + } + + /** + * To be called right after instantiating a new Target instance. + * This will hold the new instance in the list and notify about + * its creation. + */ + registerTarget(target) { + this._targets.set(target.id, target); + this.emit("target-created", target); + } + + /** + * To be called when the debuggable target has been destroy. + * So that we can notify it no longer exists and disconnect + * all connecting made to debug it. + */ + destroyTarget(target) { + target.destructor(); + this._targets.delete(target.id); + this.emit("target-destroyed", target); + } + + /** + * Destroy all the registered target of all kinds. + * This will end up dropping all connections made to debug any of them. + */ + destructor() { + for (const target of this) { + this.destroyTarget(target); + } + this._targets.clear(); + if (this.mainProcessTarget) { + this.mainProcessTarget = null; + } + + this.unwatchForTargets(); + } + + get size() { + return this._targets.size; + } + + /** + * Get Target instance by target id + * + * @param {string} id + * Target id + * + * @returns {Target} + */ + getById(id) { + return this._targets.get(id); + } + + /** + * Get the Target instance for the main process. + * This target is a singleton and only exposes a subset of domains. + */ + getMainProcessTarget() { + if (!this.mainProcessTarget) { + this.mainProcessTarget = new lazy.MainProcessTarget(this); + this.registerTarget(this.mainProcessTarget); + } + return this.mainProcessTarget; + } + + *[Symbol.iterator]() { + for (const target of this._targets.values()) { + yield target; + } + } + + toJSON() { + return [...this]; + } + + toString() { + return `[object TargetList ${this.size}]`; + } +} diff --git a/remote/cdp/test/browser/README.md b/remote/cdp/test/browser/README.md new file mode 100644 index 0000000000..65ed2b862d --- /dev/null +++ b/remote/cdp/test/browser/README.md @@ -0,0 +1,11 @@ +Update chrome-remote-interface.js +================================= + +Upstream instructions on +https://github.com/cyrus-and/chrome-remote-interface#using-vanilla-javascript: + + % git clone https://github.com/cyrus-and/chrome-remote-interface.git + % cd chrome-remote-interface + % npm install + % TARGET=var DEBUG=true npm run webpack + % cp chrome-remote-interface.js ../ diff --git a/remote/cdp/test/browser/browser.toml b/remote/cdp/test/browser/browser.toml new file mode 100644 index 0000000000..83c1fcd607 --- /dev/null +++ b/remote/cdp/test/browser/browser.toml @@ -0,0 +1,30 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "chrome-remote-interface.js", + "head.js", +] + +["browser_agent.js"] + +["browser_cdp.js"] + +["browser_httpd.js"] + +["browser_main_target.js"] + +["browser_session.js"] + +["browser_tabs.js"] diff --git a/remote/cdp/test/browser/browser_agent.js b/remote/cdp/test/browser/browser_agent.js new file mode 100644 index 0000000000..faa4ce7fbc --- /dev/null +++ b/remote/cdp/test/browser/browser_agent.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// To fully test the Remote Agent's capabilities an instance of the interface +// also needs to be used. +const remoteAgentInstance = Cc["@mozilla.org/remote/agent;1"].createInstance( + Ci.nsIRemoteAgent +); + +add_task(async function running() { + is(remoteAgentInstance.running, true, "Agent is running"); +}); diff --git a/remote/cdp/test/browser/browser_cdp.js b/remote/cdp/test/browser/browser_cdp.js new file mode 100644 index 0000000000..bbc593c39e --- /dev/null +++ b/remote/cdp/test/browser/browser_cdp.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test very basic CDP features. +add_task(async function testCDP({ client }) { + const { Browser, Log, Page } = client; + + ok("Browser" in client, "Browser domain is available"); + ok("Log" in client, "Log domain is available"); + ok("Page" in client, "Page domain is available"); + + const version = await Browser.getVersion(); + const { isHeadless } = Cc["@mozilla.org/gfx/info;1"].getService( + Ci.nsIGfxInfo + ); + is( + version.product, + (isHeadless ? "Headless" : "") + + `${Services.appinfo.name}/${Services.appinfo.version}`, + "Browser.getVersion works and depends on headless mode" + ); + is( + version.userAgent, + window.navigator.userAgent, + "Browser.getVersion().userAgent is correct" + ); + + is( + version.revision, + Services.appinfo.sourceURL.split("/").pop(), + "Browser.getVersion().revision is correct" + ); + + is( + version.jsVersion, + Services.appinfo.version, + "Browser.getVersion().jsVersion is correct" + ); + + // receive console.log messages and print them + let result = await Log.enable(); + info("Log domain has been enabled"); + Assert.deepEqual(result, {}, "Got expected result value"); + + Log.entryAdded(({ entry }) => { + const { timestamp, level, text, args } = entry; + const msg = text || args.join(" "); + console.log(`${new Date(timestamp)}\t${level.toUpperCase()}\t${msg}`); + }); + + // turn on navigation related events, such as DOMContentLoaded et al. + result = await Page.enable(); + info("Page domain has been enabled"); + Assert.deepEqual(result, {}, "Got expected result value"); + + const frameStoppedLoading = Page.frameStoppedLoading(); + const frameNavigated = Page.frameNavigated(); + const loadEventFired = Page.loadEventFired(); + await Page.navigate({ + url: toDataURL(`<script>console.log("foo")</script>`), + }); + info("A new page has been requested"); + + await loadEventFired; + info("`Page.loadEventFired` fired"); + + await frameStoppedLoading; + info("`Page.frameStoppedLoading` fired"); + + await frameNavigated; + info("`Page.frameNavigated` fired"); +}); diff --git a/remote/cdp/test/browser/browser_cdp_only.toml b/remote/cdp/test/browser/browser_cdp_only.toml new file mode 100644 index 0000000000..e4f6145922 --- /dev/null +++ b/remote/cdp/test/browser/browser_cdp_only.toml @@ -0,0 +1,22 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] + +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", + "remote.active-protocols=2", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "chrome-remote-interface.js", + "head.js", +] + +["browser_interface.js"] diff --git a/remote/cdp/test/browser/browser_httpd.js b/remote/cdp/test/browser/browser_httpd.js new file mode 100644 index 0000000000..fb74ae8b70 --- /dev/null +++ b/remote/cdp/test/browser/browser_httpd.js @@ -0,0 +1,288 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { JSONHandler } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/JSONHandler.sys.mjs" +); + +// Get list of supported routes from JSONHandler +const routes = new JSONHandler().routes; + +add_task(async function json_version() { + const { userAgent } = Cc[ + "@mozilla.org/network/protocol;1?name=http" + ].getService(Ci.nsIHttpProtocolHandler); + + const json = await requestJSON("/json/version"); + is( + json.Browser, + `${Services.appinfo.name}/${Services.appinfo.version}`, + "Browser name and version found" + ); + is(json["Protocol-Version"], "1.3", "Protocol version found"); + is(json["User-Agent"], userAgent, "User agent found"); + is(json["V8-Version"], "1.0", "V8 version found"); + is(json["WebKit-Version"], "1.0", "Webkit version found"); + is( + json.webSocketDebuggerUrl, + RemoteAgent.cdp.targetList.getMainProcessTarget().wsDebuggerURL, + "Websocket URL for main process target found" + ); +}); + +add_task(async function check_routes() { + for (const route in routes) { + const { parameter, method } = routes[route]; + // Skip routes expecting parameter + if (parameter) { + continue; + } + + // Check request succeeded (200) and responded with valid JSON + info(`Checking ${route}`); + await requestJSON(route, { method }); + + // Check with trailing slash + info(`Checking ${route + "/"}`); + await requestJSON(route + "/", { method }); + + // Test routes expecting a certain method + if (method) { + const responseText = await requestJSON(route, { + method: "DELETE", + status: 405, + json: false, + }); + is( + responseText, + `Using unsafe HTTP verb DELETE to invoke ${route}. This action supports only ${method} verb.`, + "/json/new fails with 405 when using GET" + ); + } + } +}); + +add_task(async function json_list({ client }) { + const { Target } = client; + const { targetInfos } = await Target.getTargets(); + + const json = await requestJSON("/json/list"); + const jsonAlias = await requestJSON("/json"); + + Assert.deepEqual(json, jsonAlias, "/json/list and /json return the same"); + + ok(Array.isArray(json), "Target list is an array"); + + is( + json.length, + targetInfos.length, + "Targets as listed on /json/list are equal to Target.getTargets" + ); + + for (let i = 0; i < json.length; i++) { + const jsonTarget = json[i]; + const wsTarget = targetInfos[i]; + + is( + jsonTarget.id, + wsTarget.targetId, + "Target id matches between HTTP and Target.getTargets" + ); + is( + jsonTarget.type, + wsTarget.type, + "Target type matches between HTTP and Target.getTargets" + ); + is( + jsonTarget.url, + wsTarget.url, + "Target url matches between HTTP and Target.getTargets" + ); + + // Ensure expected values specifically for JSON endpoint + // and that type is always "page" as main process target should not be included + is( + jsonTarget.type, + "page", + `Target (${jsonTarget.id}) from list has expected type (page)` + ); + is( + jsonTarget.webSocketDebuggerUrl, + `ws://${RemoteAgent.debuggerAddress}/devtools/page/${wsTarget.targetId}`, + `Target (${jsonTarget.id}) from list has expected webSocketDebuggerUrl value` + ); + } +}); + +add_task(async function json_new_target({ client }) { + const newUrl = "https://example.com"; + + let getError = await requestJSON("/json/new?" + newUrl, { + status: 405, + json: false, + }); + is( + getError, + "Using unsafe HTTP verb GET to invoke /json/new. This action supports only PUT verb.", + "/json/new fails with 405 when using GET" + ); + + const newTarget = await requestJSON("/json/new?" + newUrl, { method: "PUT" }); + + is(newTarget.type, "page", "Returned target type is 'page'"); + is(newTarget.url, newUrl, "Returned target URL matches"); + ok(!!newTarget.id, "Returned target has id"); + ok( + !!newTarget.webSocketDebuggerUrl, + "Returned target has webSocketDebuggerUrl" + ); + + const { Target } = client; + const targets = await getDiscoveredTargets(Target); + const foundTarget = targets.find(target => target.targetId === newTarget.id); + + ok(!!foundTarget, "Returned target id was found"); +}); + +add_task(async function json_activate_target({ client, tab }) { + const { Target, target } = client; + + const currentTargetId = target.id; + const targets = await getDiscoveredTargets(Target); + const initialTarget = targets.find( + target => target.targetId === currentTargetId + ); + ok(!!initialTarget, "The current target has been found"); + + // open some more tabs in the initial window + await openTab(Target); + await openTab(Target); + + const lastTabFirstWindow = await openTab(Target); + is( + gBrowser.selectedTab, + lastTabFirstWindow.newTab, + "Selected tab has changed to a new tab" + ); + + const activateResponse = await requestJSON( + "/json/activate/" + initialTarget.targetId, + { json: false } + ); + + is( + activateResponse, + "Target activated", + "Activate endpoint returned expected string" + ); + + is(gBrowser.selectedTab, tab, "Selected tab is the initial tab again"); + + const invalidResponse = await requestJSON("/json/activate/does-not-exist", { + status: 404, + json: false, + }); + + is(invalidResponse, "No such target id: does-not-exist"); +}); + +add_task(async function json_close_target({ CDP, client, tab }) { + const { Target } = client; + + const { targetInfo, newTab } = await openTab(Target); + + const targetListBefore = await CDP.List(); + const beforeTarget = targetListBefore.find( + target => target.id === targetInfo.targetId + ); + + ok(!!beforeTarget, "New target has been found"); + + const tabClosed = BrowserTestUtils.waitForEvent(newTab, "TabClose"); + const targetDestroyed = Target.targetDestroyed(); + + const activateResponse = await requestJSON( + "/json/close/" + targetInfo.targetId, + { json: false } + ); + is( + activateResponse, + "Target is closing", + "Close endpoint returned expected string" + ); + + await tabClosed; + info("Tab was closed"); + + await targetDestroyed; + info("Received the Target.targetDestroyed event"); + + const targetListAfter = await CDP.List(); + const afterTarget = targetListAfter.find( + target => target.id === targetInfo.targetId + ); + + ok(afterTarget == null, "New target is gone"); + + const invalidResponse = await requestJSON("/json/close/does-not-exist", { + status: 404, + json: false, + }); + + is(invalidResponse, "No such target id: does-not-exist"); +}); + +add_task(async function json_prevent_load_in_iframe({ client }) { + const { Page } = client; + + const PAGE = `https://example.com/document-builder.sjs?html=${encodeURIComponent( + '<iframe src="http://localhost:9222/json/version"></iframe>`' + )}`; + + await Page.enable(); + + const NAVIGATED = "Page.frameNavigated"; + + const history = new RecordEvents(2); + history.addRecorder({ + event: Page.frameNavigated, + eventName: NAVIGATED, + messageFn: payload => { + return `Received ${NAVIGATED} for frame id ${payload.frame.id}`; + }, + }); + + await loadURL(PAGE); + + const frameNavigatedEvents = await history.record(); + + const frames = frameNavigatedEvents + .map(({ payload }) => payload.frame) + .filter(frame => frame.parentId !== undefined); + + const windowGlobal = BrowsingContext.get(frames[0].id).currentWindowGlobal; + ok( + windowGlobal.documentURI.spec.startsWith("about:neterror?e=cspBlocked"), + "Expected page not be loaded within an iframe" + ); +}); + +async function requestJSON(path, options = {}) { + const { method = "GET", status = 200, json = true } = options; + + info(`${method} http://${RemoteAgent.debuggerAddress}${path}`); + + const response = await fetch(`http://${RemoteAgent.debuggerAddress}${path}`, { + method, + }); + + is(response.status, status, `JSON response is ${status}`); + + if (json) { + return response.json(); + } + + return response.text(); +} diff --git a/remote/cdp/test/browser/browser_interface.js b/remote/cdp/test/browser/browser_interface.js new file mode 100644 index 0000000000..d2e42362e1 --- /dev/null +++ b/remote/cdp/test/browser/browser_interface.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function navigator_webdriver({ client }) { + const { Runtime } = client; + + const url = toDataURL("default-test-page"); + await loadURL(url); + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "navigator.webdriver", + }); + + is(result.type, "boolean", "The returned type is correct"); + is(result.value, true, "navigator.webdriver is enabled"); +}); diff --git a/remote/cdp/test/browser/browser_main_target.js b/remote/cdp/test/browser/browser_main_target.js new file mode 100644 index 0000000000..b3b18b4c0c --- /dev/null +++ b/remote/cdp/test/browser/browser_main_target.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test very basic CDP features. + +add_task(async function ({ CDP }) { + const { mainProcessTarget } = RemoteAgent.cdp.targetList; + ok( + mainProcessTarget, + "The main process target is instantiated after the call to `listen`" + ); + + const targetURL = mainProcessTarget.wsDebuggerURL; + + const client = await CDP({ target: targetURL }); + info("CDP client has been instantiated"); + + try { + const { Browser, Target } = client; + ok(Browser, "The main process target exposes Browser domain"); + ok(Target, "The main process target exposes Target domain"); + + const version = await Browser.getVersion(); + + const { isHeadless } = Cc["@mozilla.org/gfx/info;1"].getService( + Ci.nsIGfxInfo + ); + const expectedProduct = + (isHeadless ? "Headless" : "") + + `${Services.appinfo.name}/${Services.appinfo.version}`; + is(version.product, expectedProduct, "Browser.getVersion works"); + + is( + version.revision, + Services.appinfo.sourceURL.split("/").pop(), + "Browser.getVersion().revision is correct" + ); + + is( + version.jsVersion, + Services.appinfo.version, + "Browser.getVersion().jsVersion is correct" + ); + + const { webSocketDebuggerUrl } = await CDP.Version(); + is( + webSocketDebuggerUrl, + targetURL, + "Version endpoint refers to the same Main process target" + ); + } finally { + await client.close(); + } +}); diff --git a/remote/cdp/test/browser/browser_session.js b/remote/cdp/test/browser/browser_session.js new file mode 100644 index 0000000000..2afff10330 --- /dev/null +++ b/remote/cdp/test/browser/browser_session.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function ({ CDP }) { + const { webSocketDebuggerUrl } = await CDP.Version(); + const client = await CDP({ target: webSocketDebuggerUrl }); + + await Assert.rejects( + client.send("Hoobaflooba"), + /Invalid method format/, + `Fails with invalid method format` + ); + + await Assert.rejects( + client.send("Hooba.flooba"), + /UnknownMethodError/, + `Fails with UnknownMethodError` + ); + + await client.close(); +}); diff --git a/remote/cdp/test/browser/browser_tabs.js b/remote/cdp/test/browser/browser_tabs.js new file mode 100644 index 0000000000..9b0cd6ea96 --- /dev/null +++ b/remote/cdp/test/browser/browser_tabs.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test very basic CDP features. + +const TEST_URL = toDataURL("default-test-page"); + +add_task(async function ({ CDP }) { + // Use gBrowser.addTab instead of BrowserTestUtils as it creates the tab differently. + // It demonstrates a race around tab.linkedBrowser.browsingContext being undefined + // when accessing this property early. + const tab = gBrowser.addTab(TEST_URL, { + skipAnimation: true, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + let targets = await getTargets(CDP); + ok( + targets.some(target => target.url == TEST_URL), + "Found the tab in target list" + ); + + BrowserTestUtils.removeTab(tab); + + targets = await getTargets(CDP); + ok( + !targets.some(target => target.url == TEST_URL), + "Tab has been removed from the target list" + ); +}); diff --git a/remote/cdp/test/browser/chrome-remote-interface.js b/remote/cdp/test/browser/chrome-remote-interface.js new file mode 100644 index 0000000000..2df9c701fe --- /dev/null +++ b/remote/cdp/test/browser/chrome-remote-interface.js @@ -0,0 +1,8 @@ +var CDP=function(e){var t={};function n(r){if(t[r])return t[r].exports;var i=t[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var i in e)n.d(r,i,function(t){return e[t]}.bind(null,i));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=152)}([function(e,t,n){var r=n(2),i=n(19),o=n(12),a=n(13),s=n(20),c=function(e,t,n){var p,d,u,l,m=e&c.F,f=e&c.G,h=e&c.S,g=e&c.P,y=e&c.B,b=f?r:h?r[t]||(r[t]={}):(r[t]||{}).prototype,v=f?i:i[t]||(i[t]={}),w=v.prototype||(v.prototype={});for(p in f&&(n=t),n)u=((d=!m&&b&&void 0!==b[p])?b:n)[p],l=y&&d?s(u,r):g&&"function"==typeof u?s(Function.call,u):u,b&&a(b,p,u,e&c.U),v[p]!=u&&o(v,p,l),g&&w[p]!=u&&(w[p]=u)};r.core=i,c.F=1,c.G=2,c.S=4,c.P=8,c.B=16,c.W=32,c.U=64,c.R=128,e.exports=c},function(e,t,n){var r=n(4);e.exports=function(e){if(!r(e))throw TypeError(e+" is not an object!");return e}},function(e,t){var n=e.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)},function(e,t){e.exports=function(e){try{return!!e()}catch(e){return!0}}},function(e,t){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},function(e,t,n){var r=n(51)("wks"),i=n(36),o=n(2).Symbol,a="function"==typeof o;(e.exports=function(e){return r[e]||(r[e]=a&&o[e]||(a?o:i)("Symbol."+e))}).store=r},function(e,t,n){var r=n(22),i=Math.min;e.exports=function(e){return e>0?i(r(e),9007199254740991):0}},function(e,t,n){e.exports=!n(3)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(e,t,n){var r=n(1),i=n(103),o=n(24),a=Object.defineProperty;t.f=n(7)?Object.defineProperty:function(e,t,n){if(r(e),t=o(t,!0),r(n),i)try{return a(e,t,n)}catch(e){}if("get"in n||"set"in n)throw TypeError("Accessors not supported!");return"value"in n&&(e[t]=n.value),e}},function(e,t,n){var r=n(25);e.exports=function(e){return Object(r(e))}},function(e,t){e.exports=function(e){if("function"!=typeof e)throw TypeError(e+" is not a function!");return e}},function(e,t){var n;n=function(){return this}();try{n=n||new Function("return this")()}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){var r=n(8),i=n(35);e.exports=n(7)?function(e,t,n){return r.f(e,t,i(1,n))}:function(e,t,n){return e[t]=n,e}},function(e,t,n){var r=n(2),i=n(12),o=n(15),a=n(36)("src"),s=n(156),c=(""+s).split("toString");n(19).inspectSource=function(e){return s.call(e)},(e.exports=function(e,t,n,s){var p="function"==typeof n;p&&(o(n,"name")||i(n,"name",t)),e[t]!==n&&(p&&(o(n,a)||i(n,a,e[t]?""+e[t]:c.join(String(t)))),e===r?e[t]=n:s?e[t]?e[t]=n:i(e,t,n):(delete e[t],i(e,t,n)))})(Function.prototype,"toString",function(){return"function"==typeof this&&this[a]||s.call(this)})},function(e,t,n){var r=n(0),i=n(3),o=n(25),a=/"/g,s=function(e,t,n,r){var i=String(o(e)),s="<"+t;return""!==n&&(s+=" "+n+'="'+String(r).replace(a,""")+'"'),s+">"+i+"</"+t+">"};e.exports=function(e,t){var n={};n[e]=t(s),r(r.P+r.F*i(function(){var t=""[e]('"');return t!==t.toLowerCase()||t.split('"').length>3}),"String",n)}},function(e,t){var n={}.hasOwnProperty;e.exports=function(e,t){return n.call(e,t)}},function(e,t,n){var r=n(52),i=n(25);e.exports=function(e){return r(i(e))}},function(e,t,n){var r=n(53),i=n(35),o=n(16),a=n(24),s=n(15),c=n(103),p=Object.getOwnPropertyDescriptor;t.f=n(7)?p:function(e,t){if(e=o(e),t=a(t,!0),c)try{return p(e,t)}catch(e){}if(s(e,t))return i(!r.f.call(e,t),e[t])}},function(e,t,n){var r=n(15),i=n(9),o=n(78)("IE_PROTO"),a=Object.prototype;e.exports=Object.getPrototypeOf||function(e){return e=i(e),r(e,o)?e[o]:"function"==typeof e.constructor&&e instanceof e.constructor?e.constructor.prototype:e instanceof Object?a:null}},function(e,t){var n=e.exports={version:"2.6.9"};"number"==typeof __e&&(__e=n)},function(e,t,n){var r=n(10);e.exports=function(e,t,n){if(r(e),void 0===t)return e;switch(n){case 1:return function(n){return e.call(t,n)};case 2:return function(n,r){return e.call(t,n,r)};case 3:return function(n,r,i){return e.call(t,n,r,i)}}return function(){return e.apply(t,arguments)}}},function(e,t){var n={}.toString;e.exports=function(e){return n.call(e).slice(8,-1)}},function(e,t){var n=Math.ceil,r=Math.floor;e.exports=function(e){return isNaN(e=+e)?0:(e>0?r:n)(e)}},function(e,t,n){"use strict";var r=n(3);e.exports=function(e,t){return!!e&&r(function(){t?e.call(null,function(){},1):e.call(null)})}},function(e,t,n){var r=n(4);e.exports=function(e,t){if(!r(e))return e;var n,i;if(t&&"function"==typeof(n=e.toString)&&!r(i=n.call(e)))return i;if("function"==typeof(n=e.valueOf)&&!r(i=n.call(e)))return i;if(!t&&"function"==typeof(n=e.toString)&&!r(i=n.call(e)))return i;throw TypeError("Can't convert object to primitive value")}},function(e,t){e.exports=function(e){if(null==e)throw TypeError("Can't call method on "+e);return e}},function(e,t,n){var r=n(0),i=n(19),o=n(3);e.exports=function(e,t){var n=(i.Object||{})[e]||Object[e],a={};a[e]=t(n),r(r.S+r.F*o(function(){n(1)}),"Object",a)}},function(e,t,n){var r=n(20),i=n(52),o=n(9),a=n(6),s=n(94);e.exports=function(e,t){var n=1==e,c=2==e,p=3==e,d=4==e,u=6==e,l=5==e||u,m=t||s;return function(t,s,f){for(var h,g,y=o(t),b=i(y),v=r(s,f,3),w=a(b.length),S=0,x=n?m(t,w):c?m(t,0):void 0;w>S;S++)if((l||S in b)&&(g=v(h=b[S],S,y),e))if(n)x[S]=g;else if(g)switch(e){case 3:return!0;case 5:return h;case 6:return S;case 2:x.push(h)}else if(d)return!1;return u?-1:p||d?d:x}}},function(e,t,n){"use strict";if(n(7)){var r=n(31),i=n(2),o=n(3),a=n(0),s=n(69),c=n(102),p=n(20),d=n(42),u=n(35),l=n(12),m=n(44),f=n(22),h=n(6),g=n(131),y=n(38),b=n(24),v=n(15),w=n(47),S=n(4),x=n(9),I=n(91),T=n(39),R=n(18),k=n(40).f,C=n(93),O=n(36),$=n(5),E=n(27),D=n(59),j=n(55),P=n(96),A=n(49),M=n(64),N=n(41),_=n(95),L=n(120),q=n(8),F=n(17),U=q.f,B=F.f,W=i.RangeError,H=i.TypeError,z=i.Uint8Array,V=Array.prototype,G=c.ArrayBuffer,J=c.DataView,X=E(0),Y=E(2),K=E(3),Q=E(4),Z=E(5),ee=E(6),te=D(!0),ne=D(!1),re=P.values,ie=P.keys,oe=P.entries,ae=V.lastIndexOf,se=V.reduce,ce=V.reduceRight,pe=V.join,de=V.sort,ue=V.slice,le=V.toString,me=V.toLocaleString,fe=$("iterator"),he=$("toStringTag"),ge=O("typed_constructor"),ye=O("def_constructor"),be=s.CONSTR,ve=s.TYPED,we=s.VIEW,Se=E(1,function(e,t){return ke(j(e,e[ye]),t)}),xe=o(function(){return 1===new z(new Uint16Array([1]).buffer)[0]}),Ie=!!z&&!!z.prototype.set&&o(function(){new z(1).set({})}),Te=function(e,t){var n=f(e);if(n<0||n%t)throw W("Wrong offset!");return n},Re=function(e){if(S(e)&&ve in e)return e;throw H(e+" is not a typed array!")},ke=function(e,t){if(!(S(e)&&ge in e))throw H("It is not a typed array constructor!");return new e(t)},Ce=function(e,t){return Oe(j(e,e[ye]),t)},Oe=function(e,t){for(var n=0,r=t.length,i=ke(e,r);r>n;)i[n]=t[n++];return i},$e=function(e,t,n){U(e,t,{get:function(){return this._d[n]}})},Ee=function(e){var t,n,r,i,o,a,s=x(e),c=arguments.length,d=c>1?arguments[1]:void 0,u=void 0!==d,l=C(s);if(null!=l&&!I(l)){for(a=l.call(s),r=[],t=0;!(o=a.next()).done;t++)r.push(o.value);s=r}for(u&&c>2&&(d=p(d,arguments[2],2)),t=0,n=h(s.length),i=ke(this,n);n>t;t++)i[t]=u?d(s[t],t):s[t];return i},De=function(){for(var e=0,t=arguments.length,n=ke(this,t);t>e;)n[e]=arguments[e++];return n},je=!!z&&o(function(){me.call(new z(1))}),Pe=function(){return me.apply(je?ue.call(Re(this)):Re(this),arguments)},Ae={copyWithin:function(e,t){return L.call(Re(this),e,t,arguments.length>2?arguments[2]:void 0)},every:function(e){return Q(Re(this),e,arguments.length>1?arguments[1]:void 0)},fill:function(e){return _.apply(Re(this),arguments)},filter:function(e){return Ce(this,Y(Re(this),e,arguments.length>1?arguments[1]:void 0))},find:function(e){return Z(Re(this),e,arguments.length>1?arguments[1]:void 0)},findIndex:function(e){return ee(Re(this),e,arguments.length>1?arguments[1]:void 0)},forEach:function(e){X(Re(this),e,arguments.length>1?arguments[1]:void 0)},indexOf:function(e){return ne(Re(this),e,arguments.length>1?arguments[1]:void 0)},includes:function(e){return te(Re(this),e,arguments.length>1?arguments[1]:void 0)},join:function(e){return pe.apply(Re(this),arguments)},lastIndexOf:function(e){return ae.apply(Re(this),arguments)},map:function(e){return Se(Re(this),e,arguments.length>1?arguments[1]:void 0)},reduce:function(e){return se.apply(Re(this),arguments)},reduceRight:function(e){return ce.apply(Re(this),arguments)},reverse:function(){for(var e,t=Re(this).length,n=Math.floor(t/2),r=0;r<n;)e=this[r],this[r++]=this[--t],this[t]=e;return this},some:function(e){return K(Re(this),e,arguments.length>1?arguments[1]:void 0)},sort:function(e){return de.call(Re(this),e)},subarray:function(e,t){var n=Re(this),r=n.length,i=y(e,r);return new(j(n,n[ye]))(n.buffer,n.byteOffset+i*n.BYTES_PER_ELEMENT,h((void 0===t?r:y(t,r))-i))}},Me=function(e,t){return Ce(this,ue.call(Re(this),e,t))},Ne=function(e){Re(this);var t=Te(arguments[1],1),n=this.length,r=x(e),i=h(r.length),o=0;if(i+t>n)throw W("Wrong length!");for(;o<i;)this[t+o]=r[o++]},_e={entries:function(){return oe.call(Re(this))},keys:function(){return ie.call(Re(this))},values:function(){return re.call(Re(this))}},Le=function(e,t){return S(e)&&e[ve]&&"symbol"!=typeof t&&t in e&&String(+t)==String(t)},qe=function(e,t){return Le(e,t=b(t,!0))?u(2,e[t]):B(e,t)},Fe=function(e,t,n){return!(Le(e,t=b(t,!0))&&S(n)&&v(n,"value"))||v(n,"get")||v(n,"set")||n.configurable||v(n,"writable")&&!n.writable||v(n,"enumerable")&&!n.enumerable?U(e,t,n):(e[t]=n.value,e)};be||(F.f=qe,q.f=Fe),a(a.S+a.F*!be,"Object",{getOwnPropertyDescriptor:qe,defineProperty:Fe}),o(function(){le.call({})})&&(le=me=function(){return pe.call(this)});var Ue=m({},Ae);m(Ue,_e),l(Ue,fe,_e.values),m(Ue,{slice:Me,set:Ne,constructor:function(){},toString:le,toLocaleString:Pe}),$e(Ue,"buffer","b"),$e(Ue,"byteOffset","o"),$e(Ue,"byteLength","l"),$e(Ue,"length","e"),U(Ue,he,{get:function(){return this[ve]}}),e.exports=function(e,t,n,c){var p=e+((c=!!c)?"Clamped":"")+"Array",u="get"+e,m="set"+e,f=i[p],y=f||{},b=f&&R(f),v=!f||!s.ABV,x={},I=f&&f.prototype,C=function(e,n){U(e,n,{get:function(){return function(e,n){var r=e._d;return r.v[u](n*t+r.o,xe)}(this,n)},set:function(e){return function(e,n,r){var i=e._d;c&&(r=(r=Math.round(r))<0?0:r>255?255:255&r),i.v[m](n*t+i.o,r,xe)}(this,n,e)},enumerable:!0})};v?(f=n(function(e,n,r,i){d(e,f,p,"_d");var o,a,s,c,u=0,m=0;if(S(n)){if(!(n instanceof G||"ArrayBuffer"==(c=w(n))||"SharedArrayBuffer"==c))return ve in n?Oe(f,n):Ee.call(f,n);o=n,m=Te(r,t);var y=n.byteLength;if(void 0===i){if(y%t)throw W("Wrong length!");if((a=y-m)<0)throw W("Wrong length!")}else if((a=h(i)*t)+m>y)throw W("Wrong length!");s=a/t}else s=g(n),o=new G(a=s*t);for(l(e,"_d",{b:o,o:m,l:a,e:s,v:new J(o)});u<s;)C(e,u++)}),I=f.prototype=T(Ue),l(I,"constructor",f)):o(function(){f(1)})&&o(function(){new f(-1)})&&M(function(e){new f,new f(null),new f(1.5),new f(e)},!0)||(f=n(function(e,n,r,i){var o;return d(e,f,p),S(n)?n instanceof G||"ArrayBuffer"==(o=w(n))||"SharedArrayBuffer"==o?void 0!==i?new y(n,Te(r,t),i):void 0!==r?new y(n,Te(r,t)):new y(n):ve in n?Oe(f,n):Ee.call(f,n):new y(g(n))}),X(b!==Function.prototype?k(y).concat(k(b)):k(y),function(e){e in f||l(f,e,y[e])}),f.prototype=I,r||(I.constructor=f));var O=I[fe],$=!!O&&("values"==O.name||null==O.name),E=_e.values;l(f,ge,!0),l(I,ve,p),l(I,we,!0),l(I,ye,f),(c?new f(1)[he]==p:he in I)||U(I,he,{get:function(){return p}}),x[p]=f,a(a.G+a.W+a.F*(f!=y),x),a(a.S,p,{BYTES_PER_ELEMENT:t}),a(a.S+a.F*o(function(){y.of.call(f,1)}),p,{from:Ee,of:De}),"BYTES_PER_ELEMENT"in I||l(I,"BYTES_PER_ELEMENT",t),a(a.P,p,Ae),N(p),a(a.P+a.F*Ie,p,{set:Ne}),a(a.P+a.F*!$,p,_e),r||I.toString==le||(I.toString=le),a(a.P+a.F*o(function(){new f(1).slice()}),p,{slice:Me}),a(a.P+a.F*(o(function(){return[1,2].toLocaleString()!=new f([1,2]).toLocaleString()})||!o(function(){I.toLocaleString.call([1,2])})),p,{toLocaleString:Pe}),A[p]=$?O:E,r||$||l(I,fe,E)}}else e.exports=function(){}},function(e,t,n){var r=n(126),i=n(0),o=n(51)("metadata"),a=o.store||(o.store=new(n(129))),s=function(e,t,n){var i=a.get(e);if(!i){if(!n)return;a.set(e,i=new r)}var o=i.get(t);if(!o){if(!n)return;i.set(t,o=new r)}return o};e.exports={store:a,map:s,has:function(e,t,n){var r=s(t,n,!1);return void 0!==r&&r.has(e)},get:function(e,t,n){var r=s(t,n,!1);return void 0===r?void 0:r.get(e)},set:function(e,t,n,r){s(n,r,!0).set(e,t)},keys:function(e,t){var n=s(e,t,!1),r=[];return n&&n.forEach(function(e,t){r.push(t)}),r},key:function(e){return void 0===e||"symbol"==typeof e?e:String(e)},exp:function(e){i(i.S,"Reflect",e)}}},function(e,t){var n,r,i=e.exports={};function o(){throw new Error("setTimeout has not been defined")}function a(){throw new Error("clearTimeout has not been defined")}function s(e){if(n===setTimeout)return setTimeout(e,0);if((n===o||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:o}catch(e){n=o}try{r="function"==typeof clearTimeout?clearTimeout:a}catch(e){r=a}}();var c,p=[],d=!1,u=-1;function l(){d&&c&&(d=!1,c.length?p=c.concat(p):u=-1,p.length&&m())}function m(){if(!d){var e=s(l);d=!0;for(var t=p.length;t;){for(c=p,p=[];++u<t;)c&&c[u].run();u=-1,t=p.length}c=null,d=!1,function(e){if(r===clearTimeout)return clearTimeout(e);if((r===a||!r)&&clearTimeout)return r=clearTimeout,clearTimeout(e);try{r(e)}catch(t){try{return r.call(null,e)}catch(t){return r.call(this,e)}}}(e)}}function f(e,t){this.fun=e,this.array=t}function h(){}i.nextTick=function(e){var t=new Array(arguments.length-1);if(arguments.length>1)for(var n=1;n<arguments.length;n++)t[n-1]=arguments[n];p.push(new f(e,t)),1!==p.length||d||s(m)},f.prototype.run=function(){this.fun.apply(null,this.array)},i.title="browser",i.browser=!0,i.env={},i.argv=[],i.version="",i.versions={},i.on=h,i.addListener=h,i.once=h,i.off=h,i.removeListener=h,i.removeAllListeners=h,i.emit=h,i.prependListener=h,i.prependOnceListener=h,i.listeners=function(e){return[]},i.binding=function(e){throw new Error("process.binding is not supported")},i.cwd=function(){return"/"},i.chdir=function(e){throw new Error("process.chdir is not supported")},i.umask=function(){return 0}},function(e,t){e.exports=!1},function(e,t,n){var r=n(36)("meta"),i=n(4),o=n(15),a=n(8).f,s=0,c=Object.isExtensible||function(){return!0},p=!n(3)(function(){return c(Object.preventExtensions({}))}),d=function(e){a(e,r,{value:{i:"O"+ ++s,w:{}}})},u=e.exports={KEY:r,NEED:!1,fastKey:function(e,t){if(!i(e))return"symbol"==typeof e?e:("string"==typeof e?"S":"P")+e;if(!o(e,r)){if(!c(e))return"F";if(!t)return"E";d(e)}return e[r].i},getWeak:function(e,t){if(!o(e,r)){if(!c(e))return!0;if(!t)return!1;d(e)}return e[r].w},onFreeze:function(e){return p&&u.NEED&&c(e)&&!o(e,r)&&d(e),e}}},function(e,t,n){var r=n(5)("unscopables"),i=Array.prototype;null==i[r]&&n(12)(i,r,{}),e.exports=function(e){i[r][e]=!0}},function(e,t){"function"==typeof Object.create?e.exports=function(e,t){e.super_=t,e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}})}:e.exports=function(e,t){e.super_=t;var n=function(){};n.prototype=t.prototype,e.prototype=new n,e.prototype.constructor=e}},function(e,t){e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},function(e,t){var n=0,r=Math.random();e.exports=function(e){return"Symbol(".concat(void 0===e?"":e,")_",(++n+r).toString(36))}},function(e,t,n){var r=n(105),i=n(79);e.exports=Object.keys||function(e){return r(e,i)}},function(e,t,n){var r=n(22),i=Math.max,o=Math.min;e.exports=function(e,t){return(e=r(e))<0?i(e+t,0):o(e,t)}},function(e,t,n){var r=n(1),i=n(106),o=n(79),a=n(78)("IE_PROTO"),s=function(){},c=function(){var e,t=n(76)("iframe"),r=o.length;for(t.style.display="none",n(80).appendChild(t),t.src="javascript:",(e=t.contentWindow.document).open(),e.write("<script>document.F=Object<\/script>"),e.close(),c=e.F;r--;)delete c.prototype[o[r]];return c()};e.exports=Object.create||function(e,t){var n;return null!==e?(s.prototype=r(e),n=new s,s.prototype=null,n[a]=e):n=c(),void 0===t?n:i(n,t)}},function(e,t,n){var r=n(105),i=n(79).concat("length","prototype");t.f=Object.getOwnPropertyNames||function(e){return r(e,i)}},function(e,t,n){"use strict";var r=n(2),i=n(8),o=n(7),a=n(5)("species");e.exports=function(e){var t=r[e];o&&t&&!t[a]&&i.f(t,a,{configurable:!0,get:function(){return this}})}},function(e,t){e.exports=function(e,t,n,r){if(!(e instanceof t)||void 0!==r&&r in e)throw TypeError(n+": incorrect invocation!");return e}},function(e,t,n){var r=n(20),i=n(118),o=n(91),a=n(1),s=n(6),c=n(93),p={},d={};(t=e.exports=function(e,t,n,u,l){var m,f,h,g,y=l?function(){return e}:c(e),b=r(n,u,t?2:1),v=0;if("function"!=typeof y)throw TypeError(e+" is not iterable!");if(o(y)){for(m=s(e.length);m>v;v++)if((g=t?b(a(f=e[v])[0],f[1]):b(e[v]))===p||g===d)return g}else for(h=y.call(e);!(f=h.next()).done;)if((g=i(h,b,f.value,t))===p||g===d)return g}).BREAK=p,t.RETURN=d},function(e,t,n){var r=n(13);e.exports=function(e,t,n){for(var i in t)r(e,i,t[i],n);return e}},function(e,t,n){var r=n(4);e.exports=function(e,t){if(!r(e)||e._t!==t)throw TypeError("Incompatible receiver, "+t+" required!");return e}},function(e,t,n){var r=n(8).f,i=n(15),o=n(5)("toStringTag");e.exports=function(e,t,n){e&&!i(e=n?e:e.prototype,o)&&r(e,o,{configurable:!0,value:t})}},function(e,t,n){var r=n(21),i=n(5)("toStringTag"),o="Arguments"==r(function(){return arguments}());e.exports=function(e){var t,n,a;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(n=function(e,t){try{return e[t]}catch(e){}}(t=Object(e),i))?n:o?r(t):"Object"==(a=r(t))&&"function"==typeof t.callee?"Arguments":a}},function(e,t,n){var r=n(0),i=n(25),o=n(3),a=n(82),s="["+a+"]",c=RegExp("^"+s+s+"*"),p=RegExp(s+s+"*$"),d=function(e,t,n){var i={},s=o(function(){return!!a[e]()||"
"!="
"[e]()}),c=i[e]=s?t(u):a[e];n&&(i[n]=c),r(r.P+r.F*s,"String",i)},u=d.trim=function(e,t){return e=String(i(e)),1&t&&(e=e.replace(c,"")),2&t&&(e=e.replace(p,"")),e};e.exports=d},function(e,t){e.exports={}},function(e,t,n){"use strict";var r=n(73),i=Object.keys||function(e){var t=[];for(var n in e)t.push(n);return t};e.exports=u;var o=n(58);o.inherits=n(34);var a=n(145),s=n(148);o.inherits(u,a);for(var c=i(s.prototype),p=0;p<c.length;p++){var d=c[p];u.prototype[d]||(u.prototype[d]=s.prototype[d])}function u(e){if(!(this instanceof u))return new u(e);a.call(this,e),s.call(this,e),e&&!1===e.readable&&(this.readable=!1),e&&!1===e.writable&&(this.writable=!1),this.allowHalfOpen=!0,e&&!1===e.allowHalfOpen&&(this.allowHalfOpen=!1),this.once("end",l)}function l(){this.allowHalfOpen||this._writableState.ended||r.nextTick(m,this)}function m(e){e.end()}Object.defineProperty(u.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}}),Object.defineProperty(u.prototype,"destroyed",{get:function(){return void 0!==this._readableState&&void 0!==this._writableState&&(this._readableState.destroyed&&this._writableState.destroyed)},set:function(e){void 0!==this._readableState&&void 0!==this._writableState&&(this._readableState.destroyed=e,this._writableState.destroyed=e)}}),u.prototype._destroy=function(e,t){this.push(null),this.end(),r.nextTick(t,e)}},function(e,t,n){var r=n(19),i=n(2),o=i["__core-js_shared__"]||(i["__core-js_shared__"]={});(e.exports=function(e,t){return o[e]||(o[e]=void 0!==t?t:{})})("versions",[]).push({version:r.version,mode:n(31)?"pure":"global",copyright:"© 2019 Denis Pushkarev (zloirock.ru)"})},function(e,t,n){var r=n(21);e.exports=Object("z").propertyIsEnumerable(0)?Object:function(e){return"String"==r(e)?e.split(""):Object(e)}},function(e,t){t.f={}.propertyIsEnumerable},function(e,t,n){"use strict";var r=n(1);e.exports=function(){var e=r(this),t="";return e.global&&(t+="g"),e.ignoreCase&&(t+="i"),e.multiline&&(t+="m"),e.unicode&&(t+="u"),e.sticky&&(t+="y"),t}},function(e,t,n){var r=n(1),i=n(10),o=n(5)("species");e.exports=function(e,t){var n,a=r(e).constructor;return void 0===a||null==(n=r(a)[o])?t:i(n)}},function(e,t,n){"use strict";var r,i="object"==typeof Reflect?Reflect:null,o=i&&"function"==typeof i.apply?i.apply:function(e,t,n){return Function.prototype.apply.call(e,t,n)};r=i&&"function"==typeof i.ownKeys?i.ownKeys:Object.getOwnPropertySymbols?function(e){return Object.getOwnPropertyNames(e).concat(Object.getOwnPropertySymbols(e))}:function(e){return Object.getOwnPropertyNames(e)};var a=Number.isNaN||function(e){return e!=e};function s(){s.init.call(this)}e.exports=s,s.EventEmitter=s,s.prototype._events=void 0,s.prototype._eventsCount=0,s.prototype._maxListeners=void 0;var c=10;function p(e){return void 0===e._maxListeners?s.defaultMaxListeners:e._maxListeners}function d(e,t,n,r){var i,o,a,s;if("function"!=typeof n)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof n);if(void 0===(o=e._events)?(o=e._events=Object.create(null),e._eventsCount=0):(void 0!==o.newListener&&(e.emit("newListener",t,n.listener?n.listener:n),o=e._events),a=o[t]),void 0===a)a=o[t]=n,++e._eventsCount;else if("function"==typeof a?a=o[t]=r?[n,a]:[a,n]:r?a.unshift(n):a.push(n),(i=p(e))>0&&a.length>i&&!a.warned){a.warned=!0;var c=new Error("Possible EventEmitter memory leak detected. "+a.length+" "+String(t)+" listeners added. Use emitter.setMaxListeners() to increase limit");c.name="MaxListenersExceededWarning",c.emitter=e,c.type=t,c.count=a.length,s=c,console&&console.warn&&console.warn(s)}return e}function u(e,t,n){var r={fired:!1,wrapFn:void 0,target:e,type:t,listener:n},i=function(){for(var e=[],t=0;t<arguments.length;t++)e.push(arguments[t]);this.fired||(this.target.removeListener(this.type,this.wrapFn),this.fired=!0,o(this.listener,this.target,e))}.bind(r);return i.listener=n,r.wrapFn=i,i}function l(e,t,n){var r=e._events;if(void 0===r)return[];var i=r[t];return void 0===i?[]:"function"==typeof i?n?[i.listener||i]:[i]:n?function(e){for(var t=new Array(e.length),n=0;n<t.length;++n)t[n]=e[n].listener||e[n];return t}(i):f(i,i.length)}function m(e){var t=this._events;if(void 0!==t){var n=t[e];if("function"==typeof n)return 1;if(void 0!==n)return n.length}return 0}function f(e,t){for(var n=new Array(t),r=0;r<t;++r)n[r]=e[r];return n}Object.defineProperty(s,"defaultMaxListeners",{enumerable:!0,get:function(){return c},set:function(e){if("number"!=typeof e||e<0||a(e))throw new RangeError('The value of "defaultMaxListeners" is out of range. It must be a non-negative number. Received '+e+".");c=e}}),s.init=function(){void 0!==this._events&&this._events!==Object.getPrototypeOf(this)._events||(this._events=Object.create(null),this._eventsCount=0),this._maxListeners=this._maxListeners||void 0},s.prototype.setMaxListeners=function(e){if("number"!=typeof e||e<0||a(e))throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received '+e+".");return this._maxListeners=e,this},s.prototype.getMaxListeners=function(){return p(this)},s.prototype.emit=function(e){for(var t=[],n=1;n<arguments.length;n++)t.push(arguments[n]);var r="error"===e,i=this._events;if(void 0!==i)r=r&&void 0===i.error;else if(!r)return!1;if(r){var a;if(t.length>0&&(a=t[0]),a instanceof Error)throw a;var s=new Error("Unhandled error."+(a?" ("+a.message+")":""));throw s.context=a,s}var c=i[e];if(void 0===c)return!1;if("function"==typeof c)o(c,this,t);else{var p=c.length,d=f(c,p);for(n=0;n<p;++n)o(d[n],this,t)}return!0},s.prototype.addListener=function(e,t){return d(this,e,t,!1)},s.prototype.on=s.prototype.addListener,s.prototype.prependListener=function(e,t){return d(this,e,t,!0)},s.prototype.once=function(e,t){if("function"!=typeof t)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof t);return this.on(e,u(this,e,t)),this},s.prototype.prependOnceListener=function(e,t){if("function"!=typeof t)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof t);return this.prependListener(e,u(this,e,t)),this},s.prototype.removeListener=function(e,t){var n,r,i,o,a;if("function"!=typeof t)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof t);if(void 0===(r=this._events))return this;if(void 0===(n=r[e]))return this;if(n===t||n.listener===t)0==--this._eventsCount?this._events=Object.create(null):(delete r[e],r.removeListener&&this.emit("removeListener",e,n.listener||t));else if("function"!=typeof n){for(i=-1,o=n.length-1;o>=0;o--)if(n[o]===t||n[o].listener===t){a=n[o].listener,i=o;break}if(i<0)return this;0===i?n.shift():function(e,t){for(;t+1<e.length;t++)e[t]=e[t+1];e.pop()}(n,i),1===n.length&&(r[e]=n[0]),void 0!==r.removeListener&&this.emit("removeListener",e,a||t)}return this},s.prototype.off=s.prototype.removeListener,s.prototype.removeAllListeners=function(e){var t,n,r;if(void 0===(n=this._events))return this;if(void 0===n.removeListener)return 0===arguments.length?(this._events=Object.create(null),this._eventsCount=0):void 0!==n[e]&&(0==--this._eventsCount?this._events=Object.create(null):delete n[e]),this;if(0===arguments.length){var i,o=Object.keys(n);for(r=0;r<o.length;++r)"removeListener"!==(i=o[r])&&this.removeAllListeners(i);return this.removeAllListeners("removeListener"),this._events=Object.create(null),this._eventsCount=0,this}if("function"==typeof(t=n[e]))this.removeListener(e,t);else if(void 0!==t)for(r=t.length-1;r>=0;r--)this.removeListener(e,t[r]);return this},s.prototype.listeners=function(e){return l(this,e,!0)},s.prototype.rawListeners=function(e){return l(this,e,!1)},s.listenerCount=function(e,t){return"function"==typeof e.listenerCount?e.listenerCount(t):m.call(e,t)},s.prototype.listenerCount=m,s.prototype.eventNames=function(){return this._eventsCount>0?r(this._events):[]}},function(e,t,n){"use strict";(function(e){ +/*! + * The buffer module from node.js, for the browser. + * + * @author Feross Aboukhadijeh <feross@feross.org> <http://feross.org> + * @license MIT + */ +var r=n(357),i=n(358),o=n(141);function a(){return c.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function s(e,t){if(a()<t)throw new RangeError("Invalid typed array length");return c.TYPED_ARRAY_SUPPORT?(e=new Uint8Array(t)).__proto__=c.prototype:(null===e&&(e=new c(t)),e.length=t),e}function c(e,t,n){if(!(c.TYPED_ARRAY_SUPPORT||this instanceof c))return new c(e,t,n);if("number"==typeof e){if("string"==typeof t)throw new Error("If encoding is specified then the first argument must be a string");return u(this,e)}return p(this,e,t,n)}function p(e,t,n,r){if("number"==typeof t)throw new TypeError('"value" argument must not be a number');return"undefined"!=typeof ArrayBuffer&&t instanceof ArrayBuffer?function(e,t,n,r){if(t.byteLength,n<0||t.byteLength<n)throw new RangeError("'offset' is out of bounds");if(t.byteLength<n+(r||0))throw new RangeError("'length' is out of bounds");t=void 0===n&&void 0===r?new Uint8Array(t):void 0===r?new Uint8Array(t,n):new Uint8Array(t,n,r);c.TYPED_ARRAY_SUPPORT?(e=t).__proto__=c.prototype:e=l(e,t);return e}(e,t,n,r):"string"==typeof t?function(e,t,n){"string"==typeof n&&""!==n||(n="utf8");if(!c.isEncoding(n))throw new TypeError('"encoding" must be a valid string encoding');var r=0|f(t,n),i=(e=s(e,r)).write(t,n);i!==r&&(e=e.slice(0,i));return e}(e,t,n):function(e,t){if(c.isBuffer(t)){var n=0|m(t.length);return 0===(e=s(e,n)).length?e:(t.copy(e,0,0,n),e)}if(t){if("undefined"!=typeof ArrayBuffer&&t.buffer instanceof ArrayBuffer||"length"in t)return"number"!=typeof t.length||(r=t.length)!=r?s(e,0):l(e,t);if("Buffer"===t.type&&o(t.data))return l(e,t.data)}var r;throw new TypeError("First argument must be a string, Buffer, ArrayBuffer, Array, or array-like object.")}(e,t)}function d(e){if("number"!=typeof e)throw new TypeError('"size" argument must be a number');if(e<0)throw new RangeError('"size" argument must not be negative')}function u(e,t){if(d(t),e=s(e,t<0?0:0|m(t)),!c.TYPED_ARRAY_SUPPORT)for(var n=0;n<t;++n)e[n]=0;return e}function l(e,t){var n=t.length<0?0:0|m(t.length);e=s(e,n);for(var r=0;r<n;r+=1)e[r]=255&t[r];return e}function m(e){if(e>=a())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+a().toString(16)+" bytes");return 0|e}function f(e,t){if(c.isBuffer(e))return e.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(e)||e instanceof ArrayBuffer))return e.byteLength;"string"!=typeof e&&(e=""+e);var n=e.length;if(0===n)return 0;for(var r=!1;;)switch(t){case"ascii":case"latin1":case"binary":return n;case"utf8":case"utf-8":case void 0:return U(e).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*n;case"hex":return n>>>1;case"base64":return B(e).length;default:if(r)return U(e).length;t=(""+t).toLowerCase(),r=!0}}function h(e,t,n){var r=!1;if((void 0===t||t<0)&&(t=0),t>this.length)return"";if((void 0===n||n>this.length)&&(n=this.length),n<=0)return"";if((n>>>=0)<=(t>>>=0))return"";for(e||(e="utf8");;)switch(e){case"hex":return E(this,t,n);case"utf8":case"utf-8":return k(this,t,n);case"ascii":return O(this,t,n);case"latin1":case"binary":return $(this,t,n);case"base64":return R(this,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return D(this,t,n);default:if(r)throw new TypeError("Unknown encoding: "+e);e=(e+"").toLowerCase(),r=!0}}function g(e,t,n){var r=e[t];e[t]=e[n],e[n]=r}function y(e,t,n,r,i){if(0===e.length)return-1;if("string"==typeof n?(r=n,n=0):n>2147483647?n=2147483647:n<-2147483648&&(n=-2147483648),n=+n,isNaN(n)&&(n=i?0:e.length-1),n<0&&(n=e.length+n),n>=e.length){if(i)return-1;n=e.length-1}else if(n<0){if(!i)return-1;n=0}if("string"==typeof t&&(t=c.from(t,r)),c.isBuffer(t))return 0===t.length?-1:b(e,t,n,r,i);if("number"==typeof t)return t&=255,c.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?i?Uint8Array.prototype.indexOf.call(e,t,n):Uint8Array.prototype.lastIndexOf.call(e,t,n):b(e,[t],n,r,i);throw new TypeError("val must be string, number or Buffer")}function b(e,t,n,r,i){var o,a=1,s=e.length,c=t.length;if(void 0!==r&&("ucs2"===(r=String(r).toLowerCase())||"ucs-2"===r||"utf16le"===r||"utf-16le"===r)){if(e.length<2||t.length<2)return-1;a=2,s/=2,c/=2,n/=2}function p(e,t){return 1===a?e[t]:e.readUInt16BE(t*a)}if(i){var d=-1;for(o=n;o<s;o++)if(p(e,o)===p(t,-1===d?0:o-d)){if(-1===d&&(d=o),o-d+1===c)return d*a}else-1!==d&&(o-=o-d),d=-1}else for(n+c>s&&(n=s-c),o=n;o>=0;o--){for(var u=!0,l=0;l<c;l++)if(p(e,o+l)!==p(t,l)){u=!1;break}if(u)return o}return-1}function v(e,t,n,r){n=Number(n)||0;var i=e.length-n;r?(r=Number(r))>i&&(r=i):r=i;var o=t.length;if(o%2!=0)throw new TypeError("Invalid hex string");r>o/2&&(r=o/2);for(var a=0;a<r;++a){var s=parseInt(t.substr(2*a,2),16);if(isNaN(s))return a;e[n+a]=s}return a}function w(e,t,n,r){return W(U(t,e.length-n),e,n,r)}function S(e,t,n,r){return W(function(e){for(var t=[],n=0;n<e.length;++n)t.push(255&e.charCodeAt(n));return t}(t),e,n,r)}function x(e,t,n,r){return S(e,t,n,r)}function I(e,t,n,r){return W(B(t),e,n,r)}function T(e,t,n,r){return W(function(e,t){for(var n,r,i,o=[],a=0;a<e.length&&!((t-=2)<0);++a)n=e.charCodeAt(a),r=n>>8,i=n%256,o.push(i),o.push(r);return o}(t,e.length-n),e,n,r)}function R(e,t,n){return 0===t&&n===e.length?r.fromByteArray(e):r.fromByteArray(e.slice(t,n))}function k(e,t,n){n=Math.min(e.length,n);for(var r=[],i=t;i<n;){var o,a,s,c,p=e[i],d=null,u=p>239?4:p>223?3:p>191?2:1;if(i+u<=n)switch(u){case 1:p<128&&(d=p);break;case 2:128==(192&(o=e[i+1]))&&(c=(31&p)<<6|63&o)>127&&(d=c);break;case 3:o=e[i+1],a=e[i+2],128==(192&o)&&128==(192&a)&&(c=(15&p)<<12|(63&o)<<6|63&a)>2047&&(c<55296||c>57343)&&(d=c);break;case 4:o=e[i+1],a=e[i+2],s=e[i+3],128==(192&o)&&128==(192&a)&&128==(192&s)&&(c=(15&p)<<18|(63&o)<<12|(63&a)<<6|63&s)>65535&&c<1114112&&(d=c)}null===d?(d=65533,u=1):d>65535&&(d-=65536,r.push(d>>>10&1023|55296),d=56320|1023&d),r.push(d),i+=u}return function(e){var t=e.length;if(t<=C)return String.fromCharCode.apply(String,e);var n="",r=0;for(;r<t;)n+=String.fromCharCode.apply(String,e.slice(r,r+=C));return n}(r)}t.Buffer=c,t.SlowBuffer=function(e){+e!=e&&(e=0);return c.alloc(+e)},t.INSPECT_MAX_BYTES=50,c.TYPED_ARRAY_SUPPORT=void 0!==e.TYPED_ARRAY_SUPPORT?e.TYPED_ARRAY_SUPPORT:function(){try{var e=new Uint8Array(1);return e.__proto__={__proto__:Uint8Array.prototype,foo:function(){return 42}},42===e.foo()&&"function"==typeof e.subarray&&0===e.subarray(1,1).byteLength}catch(e){return!1}}(),t.kMaxLength=a(),c.poolSize=8192,c._augment=function(e){return e.__proto__=c.prototype,e},c.from=function(e,t,n){return p(null,e,t,n)},c.TYPED_ARRAY_SUPPORT&&(c.prototype.__proto__=Uint8Array.prototype,c.__proto__=Uint8Array,"undefined"!=typeof Symbol&&Symbol.species&&c[Symbol.species]===c&&Object.defineProperty(c,Symbol.species,{value:null,configurable:!0})),c.alloc=function(e,t,n){return function(e,t,n,r){return d(t),t<=0?s(e,t):void 0!==n?"string"==typeof r?s(e,t).fill(n,r):s(e,t).fill(n):s(e,t)}(null,e,t,n)},c.allocUnsafe=function(e){return u(null,e)},c.allocUnsafeSlow=function(e){return u(null,e)},c.isBuffer=function(e){return!(null==e||!e._isBuffer)},c.compare=function(e,t){if(!c.isBuffer(e)||!c.isBuffer(t))throw new TypeError("Arguments must be Buffers");if(e===t)return 0;for(var n=e.length,r=t.length,i=0,o=Math.min(n,r);i<o;++i)if(e[i]!==t[i]){n=e[i],r=t[i];break}return n<r?-1:r<n?1:0},c.isEncoding=function(e){switch(String(e).toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"latin1":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return!0;default:return!1}},c.concat=function(e,t){if(!o(e))throw new TypeError('"list" argument must be an Array of Buffers');if(0===e.length)return c.alloc(0);var n;if(void 0===t)for(t=0,n=0;n<e.length;++n)t+=e[n].length;var r=c.allocUnsafe(t),i=0;for(n=0;n<e.length;++n){var a=e[n];if(!c.isBuffer(a))throw new TypeError('"list" argument must be an Array of Buffers');a.copy(r,i),i+=a.length}return r},c.byteLength=f,c.prototype._isBuffer=!0,c.prototype.swap16=function(){var e=this.length;if(e%2!=0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(var t=0;t<e;t+=2)g(this,t,t+1);return this},c.prototype.swap32=function(){var e=this.length;if(e%4!=0)throw new RangeError("Buffer size must be a multiple of 32-bits");for(var t=0;t<e;t+=4)g(this,t,t+3),g(this,t+1,t+2);return this},c.prototype.swap64=function(){var e=this.length;if(e%8!=0)throw new RangeError("Buffer size must be a multiple of 64-bits");for(var t=0;t<e;t+=8)g(this,t,t+7),g(this,t+1,t+6),g(this,t+2,t+5),g(this,t+3,t+4);return this},c.prototype.toString=function(){var e=0|this.length;return 0===e?"":0===arguments.length?k(this,0,e):h.apply(this,arguments)},c.prototype.equals=function(e){if(!c.isBuffer(e))throw new TypeError("Argument must be a Buffer");return this===e||0===c.compare(this,e)},c.prototype.inspect=function(){var e="",n=t.INSPECT_MAX_BYTES;return this.length>0&&(e=this.toString("hex",0,n).match(/.{2}/g).join(" "),this.length>n&&(e+=" ... ")),"<Buffer "+e+">"},c.prototype.compare=function(e,t,n,r,i){if(!c.isBuffer(e))throw new TypeError("Argument must be a Buffer");if(void 0===t&&(t=0),void 0===n&&(n=e?e.length:0),void 0===r&&(r=0),void 0===i&&(i=this.length),t<0||n>e.length||r<0||i>this.length)throw new RangeError("out of range index");if(r>=i&&t>=n)return 0;if(r>=i)return-1;if(t>=n)return 1;if(this===e)return 0;for(var o=(i>>>=0)-(r>>>=0),a=(n>>>=0)-(t>>>=0),s=Math.min(o,a),p=this.slice(r,i),d=e.slice(t,n),u=0;u<s;++u)if(p[u]!==d[u]){o=p[u],a=d[u];break}return o<a?-1:a<o?1:0},c.prototype.includes=function(e,t,n){return-1!==this.indexOf(e,t,n)},c.prototype.indexOf=function(e,t,n){return y(this,e,t,n,!0)},c.prototype.lastIndexOf=function(e,t,n){return y(this,e,t,n,!1)},c.prototype.write=function(e,t,n,r){if(void 0===t)r="utf8",n=this.length,t=0;else if(void 0===n&&"string"==typeof t)r=t,n=this.length,t=0;else{if(!isFinite(t))throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported");t|=0,isFinite(n)?(n|=0,void 0===r&&(r="utf8")):(r=n,n=void 0)}var i=this.length-t;if((void 0===n||n>i)&&(n=i),e.length>0&&(n<0||t<0)||t>this.length)throw new RangeError("Attempt to write outside buffer bounds");r||(r="utf8");for(var o=!1;;)switch(r){case"hex":return v(this,e,t,n);case"utf8":case"utf-8":return w(this,e,t,n);case"ascii":return S(this,e,t,n);case"latin1":case"binary":return x(this,e,t,n);case"base64":return I(this,e,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return T(this,e,t,n);default:if(o)throw new TypeError("Unknown encoding: "+r);r=(""+r).toLowerCase(),o=!0}},c.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var C=4096;function O(e,t,n){var r="";n=Math.min(e.length,n);for(var i=t;i<n;++i)r+=String.fromCharCode(127&e[i]);return r}function $(e,t,n){var r="";n=Math.min(e.length,n);for(var i=t;i<n;++i)r+=String.fromCharCode(e[i]);return r}function E(e,t,n){var r=e.length;(!t||t<0)&&(t=0),(!n||n<0||n>r)&&(n=r);for(var i="",o=t;o<n;++o)i+=F(e[o]);return i}function D(e,t,n){for(var r=e.slice(t,n),i="",o=0;o<r.length;o+=2)i+=String.fromCharCode(r[o]+256*r[o+1]);return i}function j(e,t,n){if(e%1!=0||e<0)throw new RangeError("offset is not uint");if(e+t>n)throw new RangeError("Trying to access beyond buffer length")}function P(e,t,n,r,i,o){if(!c.isBuffer(e))throw new TypeError('"buffer" argument must be a Buffer instance');if(t>i||t<o)throw new RangeError('"value" argument is out of bounds');if(n+r>e.length)throw new RangeError("Index out of range")}function A(e,t,n,r){t<0&&(t=65535+t+1);for(var i=0,o=Math.min(e.length-n,2);i<o;++i)e[n+i]=(t&255<<8*(r?i:1-i))>>>8*(r?i:1-i)}function M(e,t,n,r){t<0&&(t=4294967295+t+1);for(var i=0,o=Math.min(e.length-n,4);i<o;++i)e[n+i]=t>>>8*(r?i:3-i)&255}function N(e,t,n,r,i,o){if(n+r>e.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("Index out of range")}function _(e,t,n,r,o){return o||N(e,0,n,4),i.write(e,t,n,r,23,4),n+4}function L(e,t,n,r,o){return o||N(e,0,n,8),i.write(e,t,n,r,52,8),n+8}c.prototype.slice=function(e,t){var n,r=this.length;if((e=~~e)<0?(e+=r)<0&&(e=0):e>r&&(e=r),(t=void 0===t?r:~~t)<0?(t+=r)<0&&(t=0):t>r&&(t=r),t<e&&(t=e),c.TYPED_ARRAY_SUPPORT)(n=this.subarray(e,t)).__proto__=c.prototype;else{var i=t-e;n=new c(i,void 0);for(var o=0;o<i;++o)n[o]=this[o+e]}return n},c.prototype.readUIntLE=function(e,t,n){e|=0,t|=0,n||j(e,t,this.length);for(var r=this[e],i=1,o=0;++o<t&&(i*=256);)r+=this[e+o]*i;return r},c.prototype.readUIntBE=function(e,t,n){e|=0,t|=0,n||j(e,t,this.length);for(var r=this[e+--t],i=1;t>0&&(i*=256);)r+=this[e+--t]*i;return r},c.prototype.readUInt8=function(e,t){return t||j(e,1,this.length),this[e]},c.prototype.readUInt16LE=function(e,t){return t||j(e,2,this.length),this[e]|this[e+1]<<8},c.prototype.readUInt16BE=function(e,t){return t||j(e,2,this.length),this[e]<<8|this[e+1]},c.prototype.readUInt32LE=function(e,t){return t||j(e,4,this.length),(this[e]|this[e+1]<<8|this[e+2]<<16)+16777216*this[e+3]},c.prototype.readUInt32BE=function(e,t){return t||j(e,4,this.length),16777216*this[e]+(this[e+1]<<16|this[e+2]<<8|this[e+3])},c.prototype.readIntLE=function(e,t,n){e|=0,t|=0,n||j(e,t,this.length);for(var r=this[e],i=1,o=0;++o<t&&(i*=256);)r+=this[e+o]*i;return r>=(i*=128)&&(r-=Math.pow(2,8*t)),r},c.prototype.readIntBE=function(e,t,n){e|=0,t|=0,n||j(e,t,this.length);for(var r=t,i=1,o=this[e+--r];r>0&&(i*=256);)o+=this[e+--r]*i;return o>=(i*=128)&&(o-=Math.pow(2,8*t)),o},c.prototype.readInt8=function(e,t){return t||j(e,1,this.length),128&this[e]?-1*(255-this[e]+1):this[e]},c.prototype.readInt16LE=function(e,t){t||j(e,2,this.length);var n=this[e]|this[e+1]<<8;return 32768&n?4294901760|n:n},c.prototype.readInt16BE=function(e,t){t||j(e,2,this.length);var n=this[e+1]|this[e]<<8;return 32768&n?4294901760|n:n},c.prototype.readInt32LE=function(e,t){return t||j(e,4,this.length),this[e]|this[e+1]<<8|this[e+2]<<16|this[e+3]<<24},c.prototype.readInt32BE=function(e,t){return t||j(e,4,this.length),this[e]<<24|this[e+1]<<16|this[e+2]<<8|this[e+3]},c.prototype.readFloatLE=function(e,t){return t||j(e,4,this.length),i.read(this,e,!0,23,4)},c.prototype.readFloatBE=function(e,t){return t||j(e,4,this.length),i.read(this,e,!1,23,4)},c.prototype.readDoubleLE=function(e,t){return t||j(e,8,this.length),i.read(this,e,!0,52,8)},c.prototype.readDoubleBE=function(e,t){return t||j(e,8,this.length),i.read(this,e,!1,52,8)},c.prototype.writeUIntLE=function(e,t,n,r){(e=+e,t|=0,n|=0,r)||P(this,e,t,n,Math.pow(2,8*n)-1,0);var i=1,o=0;for(this[t]=255&e;++o<n&&(i*=256);)this[t+o]=e/i&255;return t+n},c.prototype.writeUIntBE=function(e,t,n,r){(e=+e,t|=0,n|=0,r)||P(this,e,t,n,Math.pow(2,8*n)-1,0);var i=n-1,o=1;for(this[t+i]=255&e;--i>=0&&(o*=256);)this[t+i]=e/o&255;return t+n},c.prototype.writeUInt8=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,1,255,0),c.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),this[t]=255&e,t+1},c.prototype.writeUInt16LE=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,2,65535,0),c.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):A(this,e,t,!0),t+2},c.prototype.writeUInt16BE=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,2,65535,0),c.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):A(this,e,t,!1),t+2},c.prototype.writeUInt32LE=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,4,4294967295,0),c.TYPED_ARRAY_SUPPORT?(this[t+3]=e>>>24,this[t+2]=e>>>16,this[t+1]=e>>>8,this[t]=255&e):M(this,e,t,!0),t+4},c.prototype.writeUInt32BE=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,4,4294967295,0),c.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):M(this,e,t,!1),t+4},c.prototype.writeIntLE=function(e,t,n,r){if(e=+e,t|=0,!r){var i=Math.pow(2,8*n-1);P(this,e,t,n,i-1,-i)}var o=0,a=1,s=0;for(this[t]=255&e;++o<n&&(a*=256);)e<0&&0===s&&0!==this[t+o-1]&&(s=1),this[t+o]=(e/a>>0)-s&255;return t+n},c.prototype.writeIntBE=function(e,t,n,r){if(e=+e,t|=0,!r){var i=Math.pow(2,8*n-1);P(this,e,t,n,i-1,-i)}var o=n-1,a=1,s=0;for(this[t+o]=255&e;--o>=0&&(a*=256);)e<0&&0===s&&0!==this[t+o+1]&&(s=1),this[t+o]=(e/a>>0)-s&255;return t+n},c.prototype.writeInt8=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,1,127,-128),c.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),e<0&&(e=255+e+1),this[t]=255&e,t+1},c.prototype.writeInt16LE=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,2,32767,-32768),c.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):A(this,e,t,!0),t+2},c.prototype.writeInt16BE=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,2,32767,-32768),c.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):A(this,e,t,!1),t+2},c.prototype.writeInt32LE=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,4,2147483647,-2147483648),c.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8,this[t+2]=e>>>16,this[t+3]=e>>>24):M(this,e,t,!0),t+4},c.prototype.writeInt32BE=function(e,t,n){return e=+e,t|=0,n||P(this,e,t,4,2147483647,-2147483648),e<0&&(e=4294967295+e+1),c.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):M(this,e,t,!1),t+4},c.prototype.writeFloatLE=function(e,t,n){return _(this,e,t,!0,n)},c.prototype.writeFloatBE=function(e,t,n){return _(this,e,t,!1,n)},c.prototype.writeDoubleLE=function(e,t,n){return L(this,e,t,!0,n)},c.prototype.writeDoubleBE=function(e,t,n){return L(this,e,t,!1,n)},c.prototype.copy=function(e,t,n,r){if(n||(n=0),r||0===r||(r=this.length),t>=e.length&&(t=e.length),t||(t=0),r>0&&r<n&&(r=n),r===n)return 0;if(0===e.length||0===this.length)return 0;if(t<0)throw new RangeError("targetStart out of bounds");if(n<0||n>=this.length)throw new RangeError("sourceStart out of bounds");if(r<0)throw new RangeError("sourceEnd out of bounds");r>this.length&&(r=this.length),e.length-t<r-n&&(r=e.length-t+n);var i,o=r-n;if(this===e&&n<t&&t<r)for(i=o-1;i>=0;--i)e[i+t]=this[i+n];else if(o<1e3||!c.TYPED_ARRAY_SUPPORT)for(i=0;i<o;++i)e[i+t]=this[i+n];else Uint8Array.prototype.set.call(e,this.subarray(n,n+o),t);return o},c.prototype.fill=function(e,t,n,r){if("string"==typeof e){if("string"==typeof t?(r=t,t=0,n=this.length):"string"==typeof n&&(r=n,n=this.length),1===e.length){var i=e.charCodeAt(0);i<256&&(e=i)}if(void 0!==r&&"string"!=typeof r)throw new TypeError("encoding must be a string");if("string"==typeof r&&!c.isEncoding(r))throw new TypeError("Unknown encoding: "+r)}else"number"==typeof e&&(e&=255);if(t<0||this.length<t||this.length<n)throw new RangeError("Out of range index");if(n<=t)return this;var o;if(t>>>=0,n=void 0===n?this.length:n>>>0,e||(e=0),"number"==typeof e)for(o=t;o<n;++o)this[o]=e;else{var a=c.isBuffer(e)?e:U(new c(e,r).toString()),s=a.length;for(o=0;o<n-t;++o)this[o+t]=a[o%s]}return this};var q=/[^+\/0-9A-Za-z-_]/g;function F(e){return e<16?"0"+e.toString(16):e.toString(16)}function U(e,t){var n;t=t||1/0;for(var r=e.length,i=null,o=[],a=0;a<r;++a){if((n=e.charCodeAt(a))>55295&&n<57344){if(!i){if(n>56319){(t-=3)>-1&&o.push(239,191,189);continue}if(a+1===r){(t-=3)>-1&&o.push(239,191,189);continue}i=n;continue}if(n<56320){(t-=3)>-1&&o.push(239,191,189),i=n;continue}n=65536+(i-55296<<10|n-56320)}else i&&(t-=3)>-1&&o.push(239,191,189);if(i=null,n<128){if((t-=1)<0)break;o.push(n)}else if(n<2048){if((t-=2)<0)break;o.push(n>>6|192,63&n|128)}else if(n<65536){if((t-=3)<0)break;o.push(n>>12|224,n>>6&63|128,63&n|128)}else{if(!(n<1114112))throw new Error("Invalid code point");if((t-=4)<0)break;o.push(n>>18|240,n>>12&63|128,n>>6&63|128,63&n|128)}}return o}function B(e){return r.toByteArray(function(e){if((e=function(e){return e.trim?e.trim():e.replace(/^\s+|\s+$/g,"")}(e).replace(q,"")).length<2)return"";for(;e.length%4!=0;)e+="=";return e}(e))}function W(e,t,n,r){for(var i=0;i<r&&!(i+n>=t.length||i>=e.length);++i)t[i+n]=e[i];return i}}).call(this,n(11))},function(e,t,n){(function(e){function n(e){return Object.prototype.toString.call(e)}t.isArray=function(e){return Array.isArray?Array.isArray(e):"[object Array]"===n(e)},t.isBoolean=function(e){return"boolean"==typeof e},t.isNull=function(e){return null===e},t.isNullOrUndefined=function(e){return null==e},t.isNumber=function(e){return"number"==typeof e},t.isString=function(e){return"string"==typeof e},t.isSymbol=function(e){return"symbol"==typeof e},t.isUndefined=function(e){return void 0===e},t.isRegExp=function(e){return"[object RegExp]"===n(e)},t.isObject=function(e){return"object"==typeof e&&null!==e},t.isDate=function(e){return"[object Date]"===n(e)},t.isError=function(e){return"[object Error]"===n(e)||e instanceof Error},t.isFunction=function(e){return"function"==typeof e},t.isPrimitive=function(e){return null===e||"boolean"==typeof e||"number"==typeof e||"string"==typeof e||"symbol"==typeof e||void 0===e},t.isBuffer=e.isBuffer}).call(this,n(57).Buffer)},function(e,t,n){var r=n(16),i=n(6),o=n(38);e.exports=function(e){return function(t,n,a){var s,c=r(t),p=i(c.length),d=o(a,p);if(e&&n!=n){for(;p>d;)if((s=c[d++])!=s)return!0}else for(;p>d;d++)if((e||d in c)&&c[d]===n)return e||d||0;return!e&&-1}}},function(e,t){t.f=Object.getOwnPropertySymbols},function(e,t,n){var r=n(21);e.exports=Array.isArray||function(e){return"Array"==r(e)}},function(e,t,n){var r=n(22),i=n(25);e.exports=function(e){return function(t,n){var o,a,s=String(i(t)),c=r(n),p=s.length;return c<0||c>=p?e?"":void 0:(o=s.charCodeAt(c))<55296||o>56319||c+1===p||(a=s.charCodeAt(c+1))<56320||a>57343?e?s.charAt(c):o:e?s.slice(c,c+2):a-56320+(o-55296<<10)+65536}}},function(e,t,n){var r=n(4),i=n(21),o=n(5)("match");e.exports=function(e){var t;return r(e)&&(void 0!==(t=e[o])?!!t:"RegExp"==i(e))}},function(e,t,n){var r=n(5)("iterator"),i=!1;try{var o=[7][r]();o.return=function(){i=!0},Array.from(o,function(){throw 2})}catch(e){}e.exports=function(e,t){if(!t&&!i)return!1;var n=!1;try{var o=[7],a=o[r]();a.next=function(){return{done:n=!0}},o[r]=function(){return a},e(o)}catch(e){}return n}},function(e,t,n){"use strict";var r=n(47),i=RegExp.prototype.exec;e.exports=function(e,t){var n=e.exec;if("function"==typeof n){var o=n.call(e,t);if("object"!=typeof o)throw new TypeError("RegExp exec method returned something other than an Object or null");return o}if("RegExp"!==r(e))throw new TypeError("RegExp#exec called on incompatible receiver");return i.call(e,t)}},function(e,t,n){"use strict";n(122);var r=n(13),i=n(12),o=n(3),a=n(25),s=n(5),c=n(97),p=s("species"),d=!o(function(){var e=/./;return e.exec=function(){var e=[];return e.groups={a:"7"},e},"7"!=="".replace(e,"$<a>")}),u=function(){var e=/(?:)/,t=e.exec;e.exec=function(){return t.apply(this,arguments)};var n="ab".split(e);return 2===n.length&&"a"===n[0]&&"b"===n[1]}();e.exports=function(e,t,n){var l=s(e),m=!o(function(){var t={};return t[l]=function(){return 7},7!=""[e](t)}),f=m?!o(function(){var t=!1,n=/a/;return n.exec=function(){return t=!0,null},"split"===e&&(n.constructor={},n.constructor[p]=function(){return n}),n[l](""),!t}):void 0;if(!m||!f||"replace"===e&&!d||"split"===e&&!u){var h=/./[l],g=n(a,l,""[e],function(e,t,n,r,i){return t.exec===c?m&&!i?{done:!0,value:h.call(t,n,r)}:{done:!0,value:e.call(n,t,r)}:{done:!1}}),y=g[0],b=g[1];r(String.prototype,e,y),i(RegExp.prototype,l,2==t?function(e,t){return b.call(e,this,t)}:function(e){return b.call(e,this)})}}},function(e,t,n){var r=n(2).navigator;e.exports=r&&r.userAgent||""},function(e,t,n){"use strict";var r=n(2),i=n(0),o=n(13),a=n(44),s=n(32),c=n(43),p=n(42),d=n(4),u=n(3),l=n(64),m=n(46),f=n(83);e.exports=function(e,t,n,h,g,y){var b=r[e],v=b,w=g?"set":"add",S=v&&v.prototype,x={},I=function(e){var t=S[e];o(S,e,"delete"==e?function(e){return!(y&&!d(e))&&t.call(this,0===e?0:e)}:"has"==e?function(e){return!(y&&!d(e))&&t.call(this,0===e?0:e)}:"get"==e?function(e){return y&&!d(e)?void 0:t.call(this,0===e?0:e)}:"add"==e?function(e){return t.call(this,0===e?0:e),this}:function(e,n){return t.call(this,0===e?0:e,n),this})};if("function"==typeof v&&(y||S.forEach&&!u(function(){(new v).entries().next()}))){var T=new v,R=T[w](y?{}:-0,1)!=T,k=u(function(){T.has(1)}),C=l(function(e){new v(e)}),O=!y&&u(function(){for(var e=new v,t=5;t--;)e[w](t,t);return!e.has(-0)});C||((v=t(function(t,n){p(t,v,e);var r=f(new b,t,v);return null!=n&&c(n,g,r[w],r),r})).prototype=S,S.constructor=v),(k||O)&&(I("delete"),I("has"),g&&I("get")),(O||R)&&I(w),y&&S.clear&&delete S.clear}else v=h.getConstructor(t,e,g,w),a(v.prototype,n),s.NEED=!0;return m(v,e),x[e]=v,i(i.G+i.W+i.F*(v!=b),x),y||h.setStrong(v,e,g),v}},function(e,t,n){for(var r,i=n(2),o=n(12),a=n(36),s=a("typed_array"),c=a("view"),p=!(!i.ArrayBuffer||!i.DataView),d=p,u=0,l="Int8Array,Uint8Array,Uint8ClampedArray,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array".split(",");u<9;)(r=i[l[u++]])?(o(r.prototype,s,!0),o(r.prototype,c,!0)):d=!1;e.exports={ABV:p,CONSTR:d,TYPED:s,VIEW:c}},function(e,t,n){"use strict";e.exports=n(31)||!n(3)(function(){var e=Math.random();__defineSetter__.call(null,e,function(){}),delete n(2)[e]})},function(e,t,n){"use strict";var r=n(0);e.exports=function(e){r(r.S,e,{of:function(){for(var e=arguments.length,t=new Array(e);e--;)t[e]=arguments[e];return new this(t)}})}},function(e,t,n){"use strict";var r=n(0),i=n(10),o=n(20),a=n(43);e.exports=function(e){r(r.S,e,{from:function(e){var t,n,r,s,c=arguments[1];return i(this),(t=void 0!==c)&&i(c),null==e?new this:(n=[],t?(r=0,s=o(c,arguments[2],2),a(e,!1,function(e){n.push(s(e,r++))})):a(e,!1,n.push,n),new this(n))}})}},function(e,t,n){"use strict";(function(t){void 0===t||!t.version||0===t.version.indexOf("v0.")||0===t.version.indexOf("v1.")&&0!==t.version.indexOf("v1.8.")?e.exports={nextTick:function(e,n,r,i){if("function"!=typeof e)throw new TypeError('"callback" argument must be a function');var o,a,s=arguments.length;switch(s){case 0:case 1:return t.nextTick(e);case 2:return t.nextTick(function(){e.call(null,n)});case 3:return t.nextTick(function(){e.call(null,n,r)});case 4:return t.nextTick(function(){e.call(null,n,r,i)});default:for(o=new Array(s-1),a=0;a<o.length;)o[a++]=arguments[a];return t.nextTick(function(){e.apply(null,o)})}}}:e.exports=t}).call(this,n(30))},function(e,t,n){var r=n(57),i=r.Buffer;function o(e,t){for(var n in e)t[n]=e[n]}function a(e,t,n){return i(e,t,n)}i.from&&i.alloc&&i.allocUnsafe&&i.allocUnsafeSlow?e.exports=r:(o(r,t),t.Buffer=a),o(i,a),a.from=function(e,t,n){if("number"==typeof e)throw new TypeError("Argument must not be a number");return i(e,t,n)},a.alloc=function(e,t,n){if("number"!=typeof e)throw new TypeError("Argument must be a number");var r=i(e);return void 0!==t?"string"==typeof n?r.fill(t,n):r.fill(t):r.fill(0),r},a.allocUnsafe=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return i(e)},a.allocUnsafeSlow=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return r.SlowBuffer(e)}},function(e,t,n){"use strict";var r=n(369),i=n(371);function o(){this.protocol=null,this.slashes=null,this.auth=null,this.host=null,this.port=null,this.hostname=null,this.hash=null,this.search=null,this.query=null,this.pathname=null,this.path=null,this.href=null}t.parse=v,t.resolve=function(e,t){return v(e,!1,!0).resolve(t)},t.resolveObject=function(e,t){return e?v(e,!1,!0).resolveObject(t):t},t.format=function(e){i.isString(e)&&(e=v(e));return e instanceof o?e.format():o.prototype.format.call(e)},t.Url=o;var a=/^([a-z0-9.+-]+:)/i,s=/:[0-9]*$/,c=/^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/,p=["{","}","|","\\","^","`"].concat(["<",">",'"',"`"," ","\r","\n","\t"]),d=["'"].concat(p),u=["%","/","?",";","#"].concat(d),l=["/","?","#"],m=/^[+a-z0-9A-Z_-]{0,63}$/,f=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,h={javascript:!0,"javascript:":!0},g={javascript:!0,"javascript:":!0},y={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0},b=n(372);function v(e,t,n){if(e&&i.isObject(e)&&e instanceof o)return e;var r=new o;return r.parse(e,t,n),r}o.prototype.parse=function(e,t,n){if(!i.isString(e))throw new TypeError("Parameter 'url' must be a string, not "+typeof e);var o=e.indexOf("?"),s=-1!==o&&o<e.indexOf("#")?"?":"#",p=e.split(s);p[0]=p[0].replace(/\\/g,"/");var v=e=p.join(s);if(v=v.trim(),!n&&1===e.split("#").length){var w=c.exec(v);if(w)return this.path=v,this.href=v,this.pathname=w[1],w[2]?(this.search=w[2],this.query=t?b.parse(this.search.substr(1)):this.search.substr(1)):t&&(this.search="",this.query={}),this}var S=a.exec(v);if(S){var x=(S=S[0]).toLowerCase();this.protocol=x,v=v.substr(S.length)}if(n||S||v.match(/^\/\/[^@\/]+@[^@\/]+/)){var I="//"===v.substr(0,2);!I||S&&g[S]||(v=v.substr(2),this.slashes=!0)}if(!g[S]&&(I||S&&!y[S])){for(var T,R,k=-1,C=0;C<l.length;C++){-1!==(O=v.indexOf(l[C]))&&(-1===k||O<k)&&(k=O)}-1!==(R=-1===k?v.lastIndexOf("@"):v.lastIndexOf("@",k))&&(T=v.slice(0,R),v=v.slice(R+1),this.auth=decodeURIComponent(T)),k=-1;for(C=0;C<u.length;C++){var O;-1!==(O=v.indexOf(u[C]))&&(-1===k||O<k)&&(k=O)}-1===k&&(k=v.length),this.host=v.slice(0,k),v=v.slice(k),this.parseHost(),this.hostname=this.hostname||"";var $="["===this.hostname[0]&&"]"===this.hostname[this.hostname.length-1];if(!$)for(var E=this.hostname.split(/\./),D=(C=0,E.length);C<D;C++){var j=E[C];if(j&&!j.match(m)){for(var P="",A=0,M=j.length;A<M;A++)j.charCodeAt(A)>127?P+="x":P+=j[A];if(!P.match(m)){var N=E.slice(0,C),_=E.slice(C+1),L=j.match(f);L&&(N.push(L[1]),_.unshift(L[2])),_.length&&(v="/"+_.join(".")+v),this.hostname=N.join(".");break}}}this.hostname.length>255?this.hostname="":this.hostname=this.hostname.toLowerCase(),$||(this.hostname=r.toASCII(this.hostname));var q=this.port?":"+this.port:"",F=this.hostname||"";this.host=F+q,this.href+=this.host,$&&(this.hostname=this.hostname.substr(1,this.hostname.length-2),"/"!==v[0]&&(v="/"+v))}if(!h[x])for(C=0,D=d.length;C<D;C++){var U=d[C];if(-1!==v.indexOf(U)){var B=encodeURIComponent(U);B===U&&(B=escape(U)),v=v.split(U).join(B)}}var W=v.indexOf("#");-1!==W&&(this.hash=v.substr(W),v=v.slice(0,W));var H=v.indexOf("?");if(-1!==H?(this.search=v.substr(H),this.query=v.substr(H+1),t&&(this.query=b.parse(this.query)),v=v.slice(0,H)):t&&(this.search="",this.query={}),v&&(this.pathname=v),y[x]&&this.hostname&&!this.pathname&&(this.pathname="/"),this.pathname||this.search){q=this.pathname||"";var z=this.search||"";this.path=q+z}return this.href=this.format(),this},o.prototype.format=function(){var e=this.auth||"";e&&(e=(e=encodeURIComponent(e)).replace(/%3A/i,":"),e+="@");var t=this.protocol||"",n=this.pathname||"",r=this.hash||"",o=!1,a="";this.host?o=e+this.host:this.hostname&&(o=e+(-1===this.hostname.indexOf(":")?this.hostname:"["+this.hostname+"]"),this.port&&(o+=":"+this.port)),this.query&&i.isObject(this.query)&&Object.keys(this.query).length&&(a=b.stringify(this.query));var s=this.search||a&&"?"+a||"";return t&&":"!==t.substr(-1)&&(t+=":"),this.slashes||(!t||y[t])&&!1!==o?(o="//"+(o||""),n&&"/"!==n.charAt(0)&&(n="/"+n)):o||(o=""),r&&"#"!==r.charAt(0)&&(r="#"+r),s&&"?"!==s.charAt(0)&&(s="?"+s),t+o+(n=n.replace(/[?#]/g,function(e){return encodeURIComponent(e)}))+(s=s.replace("#","%23"))+r},o.prototype.resolve=function(e){return this.resolveObject(v(e,!1,!0)).format()},o.prototype.resolveObject=function(e){if(i.isString(e)){var t=new o;t.parse(e,!1,!0),e=t}for(var n=new o,r=Object.keys(this),a=0;a<r.length;a++){var s=r[a];n[s]=this[s]}if(n.hash=e.hash,""===e.href)return n.href=n.format(),n;if(e.slashes&&!e.protocol){for(var c=Object.keys(e),p=0;p<c.length;p++){var d=c[p];"protocol"!==d&&(n[d]=e[d])}return y[n.protocol]&&n.hostname&&!n.pathname&&(n.path=n.pathname="/"),n.href=n.format(),n}if(e.protocol&&e.protocol!==n.protocol){if(!y[e.protocol]){for(var u=Object.keys(e),l=0;l<u.length;l++){var m=u[l];n[m]=e[m]}return n.href=n.format(),n}if(n.protocol=e.protocol,e.host||g[e.protocol])n.pathname=e.pathname;else{for(var f=(e.pathname||"").split("/");f.length&&!(e.host=f.shift()););e.host||(e.host=""),e.hostname||(e.hostname=""),""!==f[0]&&f.unshift(""),f.length<2&&f.unshift(""),n.pathname=f.join("/")}if(n.search=e.search,n.query=e.query,n.host=e.host||"",n.auth=e.auth,n.hostname=e.hostname||e.host,n.port=e.port,n.pathname||n.search){var h=n.pathname||"",b=n.search||"";n.path=h+b}return n.slashes=n.slashes||e.slashes,n.href=n.format(),n}var v=n.pathname&&"/"===n.pathname.charAt(0),w=e.host||e.pathname&&"/"===e.pathname.charAt(0),S=w||v||n.host&&e.pathname,x=S,I=n.pathname&&n.pathname.split("/")||[],T=(f=e.pathname&&e.pathname.split("/")||[],n.protocol&&!y[n.protocol]);if(T&&(n.hostname="",n.port=null,n.host&&(""===I[0]?I[0]=n.host:I.unshift(n.host)),n.host="",e.protocol&&(e.hostname=null,e.port=null,e.host&&(""===f[0]?f[0]=e.host:f.unshift(e.host)),e.host=null),S=S&&(""===f[0]||""===I[0])),w)n.host=e.host||""===e.host?e.host:n.host,n.hostname=e.hostname||""===e.hostname?e.hostname:n.hostname,n.search=e.search,n.query=e.query,I=f;else if(f.length)I||(I=[]),I.pop(),I=I.concat(f),n.search=e.search,n.query=e.query;else if(!i.isNullOrUndefined(e.search)){if(T)n.hostname=n.host=I.shift(),($=!!(n.host&&n.host.indexOf("@")>0)&&n.host.split("@"))&&(n.auth=$.shift(),n.host=n.hostname=$.shift());return n.search=e.search,n.query=e.query,i.isNull(n.pathname)&&i.isNull(n.search)||(n.path=(n.pathname?n.pathname:"")+(n.search?n.search:"")),n.href=n.format(),n}if(!I.length)return n.pathname=null,n.search?n.path="/"+n.search:n.path=null,n.href=n.format(),n;for(var R=I.slice(-1)[0],k=(n.host||e.host||I.length>1)&&("."===R||".."===R)||""===R,C=0,O=I.length;O>=0;O--)"."===(R=I[O])?I.splice(O,1):".."===R?(I.splice(O,1),C++):C&&(I.splice(O,1),C--);if(!S&&!x)for(;C--;C)I.unshift("..");!S||""===I[0]||I[0]&&"/"===I[0].charAt(0)||I.unshift(""),k&&"/"!==I.join("/").substr(-1)&&I.push("");var $,E=""===I[0]||I[0]&&"/"===I[0].charAt(0);T&&(n.hostname=n.host=E?"":I.length?I.shift():"",($=!!(n.host&&n.host.indexOf("@")>0)&&n.host.split("@"))&&(n.auth=$.shift(),n.host=n.hostname=$.shift()));return(S=S||n.host&&I.length)&&!E&&I.unshift(""),I.length?n.pathname=I.join("/"):(n.pathname=null,n.path=null),i.isNull(n.pathname)&&i.isNull(n.search)||(n.path=(n.pathname?n.pathname:"")+(n.search?n.search:"")),n.auth=e.auth||n.auth,n.slashes=n.slashes||e.slashes,n.href=n.format(),n},o.prototype.parseHost=function(){var e=this.host,t=s.exec(e);t&&(":"!==(t=t[0])&&(this.port=t.substr(1)),e=e.substr(0,e.length-t.length)),e&&(this.hostname=e)}},function(e,t,n){var r=n(4),i=n(2).document,o=r(i)&&r(i.createElement);e.exports=function(e){return o?i.createElement(e):{}}},function(e,t,n){var r=n(2),i=n(19),o=n(31),a=n(104),s=n(8).f;e.exports=function(e){var t=i.Symbol||(i.Symbol=o?{}:r.Symbol||{});"_"==e.charAt(0)||e in t||s(t,e,{value:a.f(e)})}},function(e,t,n){var r=n(51)("keys"),i=n(36);e.exports=function(e){return r[e]||(r[e]=i(e))}},function(e,t){e.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},function(e,t,n){var r=n(2).document;e.exports=r&&r.documentElement},function(e,t,n){var r=n(4),i=n(1),o=function(e,t){if(i(e),!r(t)&&null!==t)throw TypeError(t+": can't set as prototype!")};e.exports={set:Object.setPrototypeOf||("__proto__"in{}?function(e,t,r){try{(r=n(20)(Function.call,n(17).f(Object.prototype,"__proto__").set,2))(e,[]),t=!(e instanceof Array)}catch(e){t=!0}return function(e,n){return o(e,n),t?e.__proto__=n:r(e,n),e}}({},!1):void 0),check:o}},function(e,t){e.exports="\t\n\v\f\r \u2028\u2029\ufeff"},function(e,t,n){var r=n(4),i=n(81).set;e.exports=function(e,t,n){var o,a=t.constructor;return a!==n&&"function"==typeof a&&(o=a.prototype)!==n.prototype&&r(o)&&i&&i(e,o),e}},function(e,t,n){"use strict";var r=n(22),i=n(25);e.exports=function(e){var t=String(i(this)),n="",o=r(e);if(o<0||o==1/0)throw RangeError("Count can't be negative");for(;o>0;(o>>>=1)&&(t+=t))1&o&&(n+=t);return n}},function(e,t){e.exports=Math.sign||function(e){return 0==(e=+e)||e!=e?e:e<0?-1:1}},function(e,t){var n=Math.expm1;e.exports=!n||n(10)>22025.465794806718||n(10)<22025.465794806718||-2e-17!=n(-2e-17)?function(e){return 0==(e=+e)?e:e>-1e-6&&e<1e-6?e+e*e/2:Math.exp(e)-1}:n},function(e,t,n){"use strict";var r=n(31),i=n(0),o=n(13),a=n(12),s=n(49),c=n(88),p=n(46),d=n(18),u=n(5)("iterator"),l=!([].keys&&"next"in[].keys()),m=function(){return this};e.exports=function(e,t,n,f,h,g,y){c(n,t,f);var b,v,w,S=function(e){if(!l&&e in R)return R[e];switch(e){case"keys":case"values":return function(){return new n(this,e)}}return function(){return new n(this,e)}},x=t+" Iterator",I="values"==h,T=!1,R=e.prototype,k=R[u]||R["@@iterator"]||h&&R[h],C=k||S(h),O=h?I?S("entries"):C:void 0,$="Array"==t&&R.entries||k;if($&&(w=d($.call(new e)))!==Object.prototype&&w.next&&(p(w,x,!0),r||"function"==typeof w[u]||a(w,u,m)),I&&k&&"values"!==k.name&&(T=!0,C=function(){return k.call(this)}),r&&!y||!l&&!T&&R[u]||a(R,u,C),s[t]=C,s[x]=m,h)if(b={values:I?C:S("values"),keys:g?C:S("keys"),entries:O},y)for(v in b)v in R||o(R,v,b[v]);else i(i.P+i.F*(l||T),t,b);return b}},function(e,t,n){"use strict";var r=n(39),i=n(35),o=n(46),a={};n(12)(a,n(5)("iterator"),function(){return this}),e.exports=function(e,t,n){e.prototype=r(a,{next:i(1,n)}),o(e,t+" Iterator")}},function(e,t,n){var r=n(63),i=n(25);e.exports=function(e,t,n){if(r(t))throw TypeError("String#"+n+" doesn't accept regex!");return String(i(e))}},function(e,t,n){var r=n(5)("match");e.exports=function(e){var t=/./;try{"/./"[e](t)}catch(n){try{return t[r]=!1,!"/./"[e](t)}catch(e){}}return!0}},function(e,t,n){var r=n(49),i=n(5)("iterator"),o=Array.prototype;e.exports=function(e){return void 0!==e&&(r.Array===e||o[i]===e)}},function(e,t,n){"use strict";var r=n(8),i=n(35);e.exports=function(e,t,n){t in e?r.f(e,t,i(0,n)):e[t]=n}},function(e,t,n){var r=n(47),i=n(5)("iterator"),o=n(49);e.exports=n(19).getIteratorMethod=function(e){if(null!=e)return e[i]||e["@@iterator"]||o[r(e)]}},function(e,t,n){var r=n(245);e.exports=function(e,t){return new(r(e))(t)}},function(e,t,n){"use strict";var r=n(9),i=n(38),o=n(6);e.exports=function(e){for(var t=r(this),n=o(t.length),a=arguments.length,s=i(a>1?arguments[1]:void 0,n),c=a>2?arguments[2]:void 0,p=void 0===c?n:i(c,n);p>s;)t[s++]=e;return t}},function(e,t,n){"use strict";var r=n(33),i=n(121),o=n(49),a=n(16);e.exports=n(87)(Array,"Array",function(e,t){this._t=a(e),this._i=0,this._k=t},function(){var e=this._t,t=this._k,n=this._i++;return!e||n>=e.length?(this._t=void 0,i(1)):i(0,"keys"==t?n:"values"==t?e[n]:[n,e[n]])},"values"),o.Arguments=o.Array,r("keys"),r("values"),r("entries")},function(e,t,n){"use strict";var r,i,o=n(54),a=RegExp.prototype.exec,s=String.prototype.replace,c=a,p=(r=/a/,i=/b*/g,a.call(r,"a"),a.call(i,"a"),0!==r.lastIndex||0!==i.lastIndex),d=void 0!==/()??/.exec("")[1];(p||d)&&(c=function(e){var t,n,r,i,c=this;return d&&(n=new RegExp("^"+c.source+"$(?!\\s)",o.call(c))),p&&(t=c.lastIndex),r=a.call(c,e),p&&r&&(c.lastIndex=c.global?r.index+r[0].length:t),d&&r&&r.length>1&&s.call(r[0],n,function(){for(i=1;i<arguments.length-2;i++)void 0===arguments[i]&&(r[i]=void 0)}),r}),e.exports=c},function(e,t,n){"use strict";var r=n(62)(!0);e.exports=function(e,t,n){return t+(n?r(e,t).length:1)}},function(e,t,n){var r,i,o,a=n(20),s=n(111),c=n(80),p=n(76),d=n(2),u=d.process,l=d.setImmediate,m=d.clearImmediate,f=d.MessageChannel,h=d.Dispatch,g=0,y={},b=function(){var e=+this;if(y.hasOwnProperty(e)){var t=y[e];delete y[e],t()}},v=function(e){b.call(e.data)};l&&m||(l=function(e){for(var t=[],n=1;arguments.length>n;)t.push(arguments[n++]);return y[++g]=function(){s("function"==typeof e?e:Function(e),t)},r(g),g},m=function(e){delete y[e]},"process"==n(21)(u)?r=function(e){u.nextTick(a(b,e,1))}:h&&h.now?r=function(e){h.now(a(b,e,1))}:f?(o=(i=new f).port2,i.port1.onmessage=v,r=a(o.postMessage,o,1)):d.addEventListener&&"function"==typeof postMessage&&!d.importScripts?(r=function(e){d.postMessage(e+"","*")},d.addEventListener("message",v,!1)):r="onreadystatechange"in p("script")?function(e){c.appendChild(p("script")).onreadystatechange=function(){c.removeChild(this),b.call(e)}}:function(e){setTimeout(a(b,e,1),0)}),e.exports={set:l,clear:m}},function(e,t,n){var r=n(2),i=n(99).set,o=r.MutationObserver||r.WebKitMutationObserver,a=r.process,s=r.Promise,c="process"==n(21)(a);e.exports=function(){var e,t,n,p=function(){var r,i;for(c&&(r=a.domain)&&r.exit();e;){i=e.fn,e=e.next;try{i()}catch(r){throw e?n():t=void 0,r}}t=void 0,r&&r.enter()};if(c)n=function(){a.nextTick(p)};else if(!o||r.navigator&&r.navigator.standalone)if(s&&s.resolve){var d=s.resolve(void 0);n=function(){d.then(p)}}else n=function(){i.call(r,p)};else{var u=!0,l=document.createTextNode("");new o(p).observe(l,{characterData:!0}),n=function(){l.data=u=!u}}return function(r){var i={fn:r,next:void 0};t&&(t.next=i),e||(e=i,n()),t=i}}},function(e,t,n){"use strict";var r=n(10);function i(e){var t,n;this.promise=new e(function(e,r){if(void 0!==t||void 0!==n)throw TypeError("Bad Promise constructor");t=e,n=r}),this.resolve=r(t),this.reject=r(n)}e.exports.f=function(e){return new i(e)}},function(e,t,n){"use strict";var r=n(2),i=n(7),o=n(31),a=n(69),s=n(12),c=n(44),p=n(3),d=n(42),u=n(22),l=n(6),m=n(131),f=n(40).f,h=n(8).f,g=n(95),y=n(46),b="prototype",v="Wrong index!",w=r.ArrayBuffer,S=r.DataView,x=r.Math,I=r.RangeError,T=r.Infinity,R=w,k=x.abs,C=x.pow,O=x.floor,$=x.log,E=x.LN2,D=i?"_b":"buffer",j=i?"_l":"byteLength",P=i?"_o":"byteOffset";function A(e,t,n){var r,i,o,a=new Array(n),s=8*n-t-1,c=(1<<s)-1,p=c>>1,d=23===t?C(2,-24)-C(2,-77):0,u=0,l=e<0||0===e&&1/e<0?1:0;for((e=k(e))!=e||e===T?(i=e!=e?1:0,r=c):(r=O($(e)/E),e*(o=C(2,-r))<1&&(r--,o*=2),(e+=r+p>=1?d/o:d*C(2,1-p))*o>=2&&(r++,o/=2),r+p>=c?(i=0,r=c):r+p>=1?(i=(e*o-1)*C(2,t),r+=p):(i=e*C(2,p-1)*C(2,t),r=0));t>=8;a[u++]=255&i,i/=256,t-=8);for(r=r<<t|i,s+=t;s>0;a[u++]=255&r,r/=256,s-=8);return a[--u]|=128*l,a}function M(e,t,n){var r,i=8*n-t-1,o=(1<<i)-1,a=o>>1,s=i-7,c=n-1,p=e[c--],d=127&p;for(p>>=7;s>0;d=256*d+e[c],c--,s-=8);for(r=d&(1<<-s)-1,d>>=-s,s+=t;s>0;r=256*r+e[c],c--,s-=8);if(0===d)d=1-a;else{if(d===o)return r?NaN:p?-T:T;r+=C(2,t),d-=a}return(p?-1:1)*r*C(2,d-t)}function N(e){return e[3]<<24|e[2]<<16|e[1]<<8|e[0]}function _(e){return[255&e]}function L(e){return[255&e,e>>8&255]}function q(e){return[255&e,e>>8&255,e>>16&255,e>>24&255]}function F(e){return A(e,52,8)}function U(e){return A(e,23,4)}function B(e,t,n){h(e[b],t,{get:function(){return this[n]}})}function W(e,t,n,r){var i=m(+n);if(i+t>e[j])throw I(v);var o=e[D]._b,a=i+e[P],s=o.slice(a,a+t);return r?s:s.reverse()}function H(e,t,n,r,i,o){var a=m(+n);if(a+t>e[j])throw I(v);for(var s=e[D]._b,c=a+e[P],p=r(+i),d=0;d<t;d++)s[c+d]=p[o?d:t-d-1]}if(a.ABV){if(!p(function(){w(1)})||!p(function(){new w(-1)})||p(function(){return new w,new w(1.5),new w(NaN),"ArrayBuffer"!=w.name})){for(var z,V=(w=function(e){return d(this,w),new R(m(e))})[b]=R[b],G=f(R),J=0;G.length>J;)(z=G[J++])in w||s(w,z,R[z]);o||(V.constructor=w)}var X=new S(new w(2)),Y=S[b].setInt8;X.setInt8(0,2147483648),X.setInt8(1,2147483649),!X.getInt8(0)&&X.getInt8(1)||c(S[b],{setInt8:function(e,t){Y.call(this,e,t<<24>>24)},setUint8:function(e,t){Y.call(this,e,t<<24>>24)}},!0)}else w=function(e){d(this,w,"ArrayBuffer");var t=m(e);this._b=g.call(new Array(t),0),this[j]=t},S=function(e,t,n){d(this,S,"DataView"),d(e,w,"DataView");var r=e[j],i=u(t);if(i<0||i>r)throw I("Wrong offset!");if(i+(n=void 0===n?r-i:l(n))>r)throw I("Wrong length!");this[D]=e,this[P]=i,this[j]=n},i&&(B(w,"byteLength","_l"),B(S,"buffer","_b"),B(S,"byteLength","_l"),B(S,"byteOffset","_o")),c(S[b],{getInt8:function(e){return W(this,1,e)[0]<<24>>24},getUint8:function(e){return W(this,1,e)[0]},getInt16:function(e){var t=W(this,2,e,arguments[1]);return(t[1]<<8|t[0])<<16>>16},getUint16:function(e){var t=W(this,2,e,arguments[1]);return t[1]<<8|t[0]},getInt32:function(e){return N(W(this,4,e,arguments[1]))},getUint32:function(e){return N(W(this,4,e,arguments[1]))>>>0},getFloat32:function(e){return M(W(this,4,e,arguments[1]),23,4)},getFloat64:function(e){return M(W(this,8,e,arguments[1]),52,8)},setInt8:function(e,t){H(this,1,e,_,t)},setUint8:function(e,t){H(this,1,e,_,t)},setInt16:function(e,t){H(this,2,e,L,t,arguments[2])},setUint16:function(e,t){H(this,2,e,L,t,arguments[2])},setInt32:function(e,t){H(this,4,e,q,t,arguments[2])},setUint32:function(e,t){H(this,4,e,q,t,arguments[2])},setFloat32:function(e,t){H(this,4,e,U,t,arguments[2])},setFloat64:function(e,t){H(this,8,e,F,t,arguments[2])}});y(w,"ArrayBuffer"),y(S,"DataView"),s(S[b],a.VIEW,!0),t.ArrayBuffer=w,t.DataView=S},function(e,t,n){e.exports=!n(7)&&!n(3)(function(){return 7!=Object.defineProperty(n(76)("div"),"a",{get:function(){return 7}}).a})},function(e,t,n){t.f=n(5)},function(e,t,n){var r=n(15),i=n(16),o=n(59)(!1),a=n(78)("IE_PROTO");e.exports=function(e,t){var n,s=i(e),c=0,p=[];for(n in s)n!=a&&r(s,n)&&p.push(n);for(;t.length>c;)r(s,n=t[c++])&&(~o(p,n)||p.push(n));return p}},function(e,t,n){var r=n(8),i=n(1),o=n(37);e.exports=n(7)?Object.defineProperties:function(e,t){i(e);for(var n,a=o(t),s=a.length,c=0;s>c;)r.f(e,n=a[c++],t[n]);return e}},function(e,t,n){var r=n(16),i=n(40).f,o={}.toString,a="object"==typeof window&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[];e.exports.f=function(e){return a&&"[object Window]"==o.call(e)?function(e){try{return i(e)}catch(e){return a.slice()}}(e):i(r(e))}},function(e,t,n){"use strict";var r=n(7),i=n(37),o=n(60),a=n(53),s=n(9),c=n(52),p=Object.assign;e.exports=!p||n(3)(function(){var e={},t={},n=Symbol(),r="abcdefghijklmnopqrst";return e[n]=7,r.split("").forEach(function(e){t[e]=e}),7!=p({},e)[n]||Object.keys(p({},t)).join("")!=r})?function(e,t){for(var n=s(e),p=arguments.length,d=1,u=o.f,l=a.f;p>d;)for(var m,f=c(arguments[d++]),h=u?i(f).concat(u(f)):i(f),g=h.length,y=0;g>y;)m=h[y++],r&&!l.call(f,m)||(n[m]=f[m]);return n}:p},function(e,t){e.exports=Object.is||function(e,t){return e===t?0!==e||1/e==1/t:e!=e&&t!=t}},function(e,t,n){"use strict";var r=n(10),i=n(4),o=n(111),a=[].slice,s={},c=function(e,t,n){if(!(t in s)){for(var r=[],i=0;i<t;i++)r[i]="a["+i+"]";s[t]=Function("F,a","return new F("+r.join(",")+")")}return s[t](e,n)};e.exports=Function.bind||function(e){var t=r(this),n=a.call(arguments,1),s=function(){var r=n.concat(a.call(arguments));return this instanceof s?c(t,r.length,r):o(t,r,e)};return i(t.prototype)&&(s.prototype=t.prototype),s}},function(e,t){e.exports=function(e,t,n){var r=void 0===n;switch(t.length){case 0:return r?e():e.call(n);case 1:return r?e(t[0]):e.call(n,t[0]);case 2:return r?e(t[0],t[1]):e.call(n,t[0],t[1]);case 3:return r?e(t[0],t[1],t[2]):e.call(n,t[0],t[1],t[2]);case 4:return r?e(t[0],t[1],t[2],t[3]):e.call(n,t[0],t[1],t[2],t[3])}return e.apply(n,t)}},function(e,t,n){var r=n(2).parseInt,i=n(48).trim,o=n(82),a=/^[-+]?0[xX]/;e.exports=8!==r(o+"08")||22!==r(o+"0x16")?function(e,t){var n=i(String(e),3);return r(n,t>>>0||(a.test(n)?16:10))}:r},function(e,t,n){var r=n(2).parseFloat,i=n(48).trim;e.exports=1/r(n(82)+"-0")!=-1/0?function(e){var t=i(String(e),3),n=r(t);return 0===n&&"-"==t.charAt(0)?-0:n}:r},function(e,t,n){var r=n(21);e.exports=function(e,t){if("number"!=typeof e&&"Number"!=r(e))throw TypeError(t);return+e}},function(e,t,n){var r=n(4),i=Math.floor;e.exports=function(e){return!r(e)&&isFinite(e)&&i(e)===e}},function(e,t){e.exports=Math.log1p||function(e){return(e=+e)>-1e-8&&e<1e-8?e-e*e/2:Math.log(1+e)}},function(e,t,n){var r=n(85),i=Math.pow,o=i(2,-52),a=i(2,-23),s=i(2,127)*(2-a),c=i(2,-126);e.exports=Math.fround||function(e){var t,n,i=Math.abs(e),p=r(e);return i<c?p*(i/c/a+1/o-1/o)*c*a:(n=(t=(1+a/o)*i)-(t-i))>s||n!=n?p*(1/0):p*n}},function(e,t,n){var r=n(1);e.exports=function(e,t,n,i){try{return i?t(r(n)[0],n[1]):t(n)}catch(t){var o=e.return;throw void 0!==o&&r(o.call(e)),t}}},function(e,t,n){var r=n(10),i=n(9),o=n(52),a=n(6);e.exports=function(e,t,n,s,c){r(t);var p=i(e),d=o(p),u=a(p.length),l=c?u-1:0,m=c?-1:1;if(n<2)for(;;){if(l in d){s=d[l],l+=m;break}if(l+=m,c?l<0:u<=l)throw TypeError("Reduce of empty array with no initial value")}for(;c?l>=0:u>l;l+=m)l in d&&(s=t(s,d[l],l,p));return s}},function(e,t,n){"use strict";var r=n(9),i=n(38),o=n(6);e.exports=[].copyWithin||function(e,t){var n=r(this),a=o(n.length),s=i(e,a),c=i(t,a),p=arguments.length>2?arguments[2]:void 0,d=Math.min((void 0===p?a:i(p,a))-c,a-s),u=1;for(c<s&&s<c+d&&(u=-1,c+=d-1,s+=d-1);d-- >0;)c in n?n[s]=n[c]:delete n[s],s+=u,c+=u;return n}},function(e,t){e.exports=function(e,t){return{value:t,done:!!e}}},function(e,t,n){"use strict";var r=n(97);n(0)({target:"RegExp",proto:!0,forced:r!==/./.exec},{exec:r})},function(e,t,n){n(7)&&"g"!=/./g.flags&&n(8).f(RegExp.prototype,"flags",{configurable:!0,get:n(54)})},function(e,t){e.exports=function(e){try{return{e:!1,v:e()}}catch(e){return{e:!0,v:e}}}},function(e,t,n){var r=n(1),i=n(4),o=n(101);e.exports=function(e,t){if(r(e),i(t)&&t.constructor===e)return t;var n=o.f(e);return(0,n.resolve)(t),n.promise}},function(e,t,n){"use strict";var r=n(127),i=n(45);e.exports=n(68)("Map",function(e){return function(){return e(this,arguments.length>0?arguments[0]:void 0)}},{get:function(e){var t=r.getEntry(i(this,"Map"),e);return t&&t.v},set:function(e,t){return r.def(i(this,"Map"),0===e?0:e,t)}},r,!0)},function(e,t,n){"use strict";var r=n(8).f,i=n(39),o=n(44),a=n(20),s=n(42),c=n(43),p=n(87),d=n(121),u=n(41),l=n(7),m=n(32).fastKey,f=n(45),h=l?"_s":"size",g=function(e,t){var n,r=m(t);if("F"!==r)return e._i[r];for(n=e._f;n;n=n.n)if(n.k==t)return n};e.exports={getConstructor:function(e,t,n,p){var d=e(function(e,r){s(e,d,t,"_i"),e._t=t,e._i=i(null),e._f=void 0,e._l=void 0,e[h]=0,null!=r&&c(r,n,e[p],e)});return o(d.prototype,{clear:function(){for(var e=f(this,t),n=e._i,r=e._f;r;r=r.n)r.r=!0,r.p&&(r.p=r.p.n=void 0),delete n[r.i];e._f=e._l=void 0,e[h]=0},delete:function(e){var n=f(this,t),r=g(n,e);if(r){var i=r.n,o=r.p;delete n._i[r.i],r.r=!0,o&&(o.n=i),i&&(i.p=o),n._f==r&&(n._f=i),n._l==r&&(n._l=o),n[h]--}return!!r},forEach:function(e){f(this,t);for(var n,r=a(e,arguments.length>1?arguments[1]:void 0,3);n=n?n.n:this._f;)for(r(n.v,n.k,this);n&&n.r;)n=n.p},has:function(e){return!!g(f(this,t),e)}}),l&&r(d.prototype,"size",{get:function(){return f(this,t)[h]}}),d},def:function(e,t,n){var r,i,o=g(e,t);return o?o.v=n:(e._l=o={i:i=m(t,!0),k:t,v:n,p:r=e._l,n:void 0,r:!1},e._f||(e._f=o),r&&(r.n=o),e[h]++,"F"!==i&&(e._i[i]=o)),e},getEntry:g,setStrong:function(e,t,n){p(e,t,function(e,n){this._t=f(e,t),this._k=n,this._l=void 0},function(){for(var e=this._k,t=this._l;t&&t.r;)t=t.p;return this._t&&(this._l=t=t?t.n:this._t._f)?d(0,"keys"==e?t.k:"values"==e?t.v:[t.k,t.v]):(this._t=void 0,d(1))},n?"entries":"values",!n,!0),u(t)}}},function(e,t,n){"use strict";var r=n(127),i=n(45);e.exports=n(68)("Set",function(e){return function(){return e(this,arguments.length>0?arguments[0]:void 0)}},{add:function(e){return r.def(i(this,"Set"),e=0===e?0:e,e)}},r)},function(e,t,n){"use strict";var r,i=n(2),o=n(27)(0),a=n(13),s=n(32),c=n(108),p=n(130),d=n(4),u=n(45),l=n(45),m=!i.ActiveXObject&&"ActiveXObject"in i,f=s.getWeak,h=Object.isExtensible,g=p.ufstore,y=function(e){return function(){return e(this,arguments.length>0?arguments[0]:void 0)}},b={get:function(e){if(d(e)){var t=f(e);return!0===t?g(u(this,"WeakMap")).get(e):t?t[this._i]:void 0}},set:function(e,t){return p.def(u(this,"WeakMap"),e,t)}},v=e.exports=n(68)("WeakMap",y,b,p,!0,!0);l&&m&&(c((r=p.getConstructor(y,"WeakMap")).prototype,b),s.NEED=!0,o(["delete","has","get","set"],function(e){var t=v.prototype,n=t[e];a(t,e,function(t,i){if(d(t)&&!h(t)){this._f||(this._f=new r);var o=this._f[e](t,i);return"set"==e?this:o}return n.call(this,t,i)})}))},function(e,t,n){"use strict";var r=n(44),i=n(32).getWeak,o=n(1),a=n(4),s=n(42),c=n(43),p=n(27),d=n(15),u=n(45),l=p(5),m=p(6),f=0,h=function(e){return e._l||(e._l=new g)},g=function(){this.a=[]},y=function(e,t){return l(e.a,function(e){return e[0]===t})};g.prototype={get:function(e){var t=y(this,e);if(t)return t[1]},has:function(e){return!!y(this,e)},set:function(e,t){var n=y(this,e);n?n[1]=t:this.a.push([e,t])},delete:function(e){var t=m(this.a,function(t){return t[0]===e});return~t&&this.a.splice(t,1),!!~t}},e.exports={getConstructor:function(e,t,n,o){var p=e(function(e,r){s(e,p,t,"_i"),e._t=t,e._i=f++,e._l=void 0,null!=r&&c(r,n,e[o],e)});return r(p.prototype,{delete:function(e){if(!a(e))return!1;var n=i(e);return!0===n?h(u(this,t)).delete(e):n&&d(n,this._i)&&delete n[this._i]},has:function(e){if(!a(e))return!1;var n=i(e);return!0===n?h(u(this,t)).has(e):n&&d(n,this._i)}}),p},def:function(e,t,n){var r=i(o(t),!0);return!0===r?h(e).set(t,n):r[e._i]=n,e},ufstore:h}},function(e,t,n){var r=n(22),i=n(6);e.exports=function(e){if(void 0===e)return 0;var t=r(e),n=i(t);if(t!==n)throw RangeError("Wrong length!");return n}},function(e,t,n){var r=n(40),i=n(60),o=n(1),a=n(2).Reflect;e.exports=a&&a.ownKeys||function(e){var t=r.f(o(e)),n=i.f;return n?t.concat(n(e)):t}},function(e,t,n){"use strict";var r=n(61),i=n(4),o=n(6),a=n(20),s=n(5)("isConcatSpreadable");e.exports=function e(t,n,c,p,d,u,l,m){for(var f,h,g=d,y=0,b=!!l&&a(l,m,3);y<p;){if(y in c){if(f=b?b(c[y],y,n):c[y],h=!1,i(f)&&(h=void 0!==(h=f[s])?!!h:r(f)),h&&u>0)g=e(t,n,f,o(f.length),g,u-1)-1;else{if(g>=9007199254740991)throw TypeError();t[g]=f}g++}y++}return g}},function(e,t,n){var r=n(6),i=n(84),o=n(25);e.exports=function(e,t,n,a){var s=String(o(e)),c=s.length,p=void 0===n?" ":String(n),d=r(t);if(d<=c||""==p)return s;var u=d-c,l=i.call(p,Math.ceil(u/p.length));return l.length>u&&(l=l.slice(0,u)),a?l+s:s+l}},function(e,t,n){var r=n(7),i=n(37),o=n(16),a=n(53).f;e.exports=function(e){return function(t){for(var n,s=o(t),c=i(s),p=c.length,d=0,u=[];p>d;)n=c[d++],r&&!a.call(s,n)||u.push(e?[n,s[n]]:s[n]);return u}}},function(e,t,n){var r=n(47),i=n(137);e.exports=function(e){return function(){if(r(this)!=e)throw TypeError(e+"#toJSON isn't generic");return i(this)}}},function(e,t,n){var r=n(43);e.exports=function(e,t){var n=[];return r(e,!1,n.push,n,t),n}},function(e,t){e.exports=Math.scale||function(e,t,n,r,i){return 0===arguments.length||e!=e||t!=t||n!=n||r!=r||i!=i?NaN:e===1/0||e===-1/0?e:(e-t)*(i-r)/(n-t)+r}},function(e,t,n){"use strict";const r=n(140),i=n(375),o=n(151),a=n(376);function s(e,t){e.host=e.host||o.HOST,e.port=e.port||o.PORT,e.secure=!!e.secure,e.useHostName=!!e.useHostName,e.alterPath=e.alterPath||(e=>e);const n={...e};n.path=e.alterPath(e.path),a(e.secure?i:r,n,t)}function c(e){return(t,n)=>("function"==typeof t&&(n=t,t=void 0),t=t||{},"function"==typeof n?void e(t,n):new Promise((n,r)=>{e(t,(e,t)=>{e?r(e):n(t)})}))}e.exports.Protocol=c(function(e,t){if(e.local){const e=n(377);t(null,e)}else e.path="/json/protocol",s(e,(e,n)=>{e?t(e):t(null,JSON.parse(n))})}),e.exports.List=c(function(e,t){e.path="/json/list",s(e,(e,n)=>{e?t(e):t(null,JSON.parse(n))})}),e.exports.New=c(function(e,t){e.path="/json/new",Object.prototype.hasOwnProperty.call(e,"url")&&(e.path+=`?${e.url}`),s(e,(e,n)=>{e?t(e):t(null,JSON.parse(n))})}),e.exports.Activate=c(function(e,t){e.path="/json/activate/"+e.id,s(e,e=>{t(e||null)})}),e.exports.Close=c(function(e,t){e.path="/json/close/"+e.id,s(e,e=>{t(e||null)})}),e.exports.Version=c(function(e,t){e.path="/json/version",s(e,(e,n)=>{e?t(e):t(null,JSON.parse(n))})})},function(e,t,n){(function(e){var r=n(356),i=n(143),o=n(367),a=n(368),s=n(75),c=t;c.request=function(t,n){t="string"==typeof t?s.parse(t):o(t);var i=-1===e.location.protocol.search(/^https?:$/)?"http:":"",a=t.protocol||i,c=t.hostname||t.host,p=t.port,d=t.path||"/";c&&-1!==c.indexOf(":")&&(c="["+c+"]"),t.url=(c?a+"//"+c:"")+(p?":"+p:"")+d,t.method=(t.method||"GET").toUpperCase(),t.headers=t.headers||{};var u=new r(t);return n&&u.on("response",n),u},c.get=function(e,t){var n=c.request(e,t);return n.end(),n},c.ClientRequest=r,c.IncomingMessage=i.IncomingMessage,c.Agent=function(){},c.Agent.defaultMaxSockets=4,c.globalAgent=new c.Agent,c.STATUS_CODES=a,c.METHODS=["CHECKOUT","CONNECT","COPY","DELETE","GET","HEAD","LOCK","M-SEARCH","MERGE","MKACTIVITY","MKCOL","MOVE","NOTIFY","OPTIONS","PATCH","POST","PROPFIND","PROPPATCH","PURGE","PUT","REPORT","SEARCH","SUBSCRIBE","TRACE","UNLOCK","UNSUBSCRIBE"]}).call(this,n(11))},function(e,t){var n={}.toString;e.exports=Array.isArray||function(e){return"[object Array]"==n.call(e)}},function(e,t,n){(function(e){t.fetch=s(e.fetch)&&s(e.ReadableStream),t.writableStream=s(e.WritableStream),t.abortController=s(e.AbortController),t.blobConstructor=!1;try{new Blob([new ArrayBuffer(1)]),t.blobConstructor=!0}catch(e){}var n;function r(){if(void 0!==n)return n;if(e.XMLHttpRequest){n=new e.XMLHttpRequest;try{n.open("GET",e.XDomainRequest?"/":"https://example.com")}catch(e){n=null}}else n=null;return n}function i(e){var t=r();if(!t)return!1;try{return t.responseType=e,t.responseType===e}catch(e){}return!1}var o=void 0!==e.ArrayBuffer,a=o&&s(e.ArrayBuffer.prototype.slice);function s(e){return"function"==typeof e}t.arraybuffer=t.fetch||o&&i("arraybuffer"),t.msstream=!t.fetch&&a&&i("ms-stream"),t.mozchunkedarraybuffer=!t.fetch&&o&&i("moz-chunked-arraybuffer"),t.overrideMimeType=t.fetch||!!r()&&s(r().overrideMimeType),t.vbArray=s(e.VBArray),n=null}).call(this,n(11))},function(e,t,n){(function(e,r,i){var o=n(142),a=n(34),s=n(144),c=t.readyStates={UNSENT:0,OPENED:1,HEADERS_RECEIVED:2,LOADING:3,DONE:4},p=t.IncomingMessage=function(t,n,a,c){var p=this;if(s.Readable.call(p),p._mode=a,p.headers={},p.rawHeaders=[],p.trailers={},p.rawTrailers=[],p.on("end",function(){e.nextTick(function(){p.emit("close")})}),"fetch"===a){if(p._fetchResponse=n,p.url=n.url,p.statusCode=n.status,p.statusMessage=n.statusText,n.headers.forEach(function(e,t){p.headers[t.toLowerCase()]=e,p.rawHeaders.push(t,e)}),o.writableStream){var d=new WritableStream({write:function(e){return new Promise(function(t,n){p._destroyed?n():p.push(new r(e))?t():p._resumeFetch=t})},close:function(){i.clearTimeout(c),p._destroyed||p.push(null)},abort:function(e){p._destroyed||p.emit("error",e)}});try{return void n.body.pipeTo(d).catch(function(e){i.clearTimeout(c),p._destroyed||p.emit("error",e)})}catch(e){}}var u=n.body.getReader();!function e(){u.read().then(function(t){if(!p._destroyed){if(t.done)return i.clearTimeout(c),void p.push(null);p.push(new r(t.value)),e()}}).catch(function(e){i.clearTimeout(c),p._destroyed||p.emit("error",e)})}()}else{if(p._xhr=t,p._pos=0,p.url=t.responseURL,p.statusCode=t.status,p.statusMessage=t.statusText,t.getAllResponseHeaders().split(/\r?\n/).forEach(function(e){var t=e.match(/^([^:]+):\s*(.*)/);if(t){var n=t[1].toLowerCase();"set-cookie"===n?(void 0===p.headers[n]&&(p.headers[n]=[]),p.headers[n].push(t[2])):void 0!==p.headers[n]?p.headers[n]+=", "+t[2]:p.headers[n]=t[2],p.rawHeaders.push(t[1],t[2])}}),p._charset="x-user-defined",!o.overrideMimeType){var l=p.rawHeaders["mime-type"];if(l){var m=l.match(/;\s*charset=([^;])(;|$)/);m&&(p._charset=m[1].toLowerCase())}p._charset||(p._charset="utf-8")}}};a(p,s.Readable),p.prototype._read=function(){var e=this._resumeFetch;e&&(this._resumeFetch=null,e())},p.prototype._onXHRProgress=function(){var e=this,t=e._xhr,n=null;switch(e._mode){case"text:vbarray":if(t.readyState!==c.DONE)break;try{n=new i.VBArray(t.responseBody).toArray()}catch(e){}if(null!==n){e.push(new r(n));break}case"text":try{n=t.responseText}catch(t){e._mode="text:vbarray";break}if(n.length>e._pos){var o=n.substr(e._pos);if("x-user-defined"===e._charset){for(var a=new r(o.length),s=0;s<o.length;s++)a[s]=255&o.charCodeAt(s);e.push(a)}else e.push(o,e._charset);e._pos=n.length}break;case"arraybuffer":if(t.readyState!==c.DONE||!t.response)break;n=t.response,e.push(new r(new Uint8Array(n)));break;case"moz-chunked-arraybuffer":if(n=t.response,t.readyState!==c.LOADING||!n)break;e.push(new r(new Uint8Array(n)));break;case"ms-stream":if(n=t.response,t.readyState!==c.LOADING)break;var p=new i.MSStreamReader;p.onprogress=function(){p.result.byteLength>e._pos&&(e.push(new r(new Uint8Array(p.result.slice(e._pos)))),e._pos=p.result.byteLength)},p.onload=function(){e.push(null)},p.readAsArrayBuffer(n)}e._xhr.readyState===c.DONE&&"ms-stream"!==e._mode&&e.push(null)}}).call(this,n(30),n(57).Buffer,n(11))},function(e,t,n){(t=e.exports=n(145)).Stream=t,t.Readable=t,t.Writable=n(148),t.Duplex=n(50),t.Transform=n(150),t.PassThrough=n(365)},function(e,t,n){"use strict";(function(t,r){var i=n(73);e.exports=v;var o,a=n(141);v.ReadableState=b;n(56).EventEmitter;var s=function(e,t){return e.listeners(t).length},c=n(146),p=n(74).Buffer,d=t.Uint8Array||function(){};var u=n(58);u.inherits=n(34);var l=n(359),m=void 0;m=l&&l.debuglog?l.debuglog("stream"):function(){};var f,h=n(360),g=n(147);u.inherits(v,c);var y=["error","close","destroy","pause","resume"];function b(e,t){e=e||{};var r=t instanceof(o=o||n(50));this.objectMode=!!e.objectMode,r&&(this.objectMode=this.objectMode||!!e.readableObjectMode);var i=e.highWaterMark,a=e.readableHighWaterMark,s=this.objectMode?16:16384;this.highWaterMark=i||0===i?i:r&&(a||0===a)?a:s,this.highWaterMark=Math.floor(this.highWaterMark),this.buffer=new h,this.length=0,this.pipes=null,this.pipesCount=0,this.flowing=null,this.ended=!1,this.endEmitted=!1,this.reading=!1,this.sync=!0,this.needReadable=!1,this.emittedReadable=!1,this.readableListening=!1,this.resumeScheduled=!1,this.destroyed=!1,this.defaultEncoding=e.defaultEncoding||"utf8",this.awaitDrain=0,this.readingMore=!1,this.decoder=null,this.encoding=null,e.encoding&&(f||(f=n(149).StringDecoder),this.decoder=new f(e.encoding),this.encoding=e.encoding)}function v(e){if(o=o||n(50),!(this instanceof v))return new v(e);this._readableState=new b(e,this),this.readable=!0,e&&("function"==typeof e.read&&(this._read=e.read),"function"==typeof e.destroy&&(this._destroy=e.destroy)),c.call(this)}function w(e,t,n,r,i){var o,a=e._readableState;null===t?(a.reading=!1,function(e,t){if(t.ended)return;if(t.decoder){var n=t.decoder.end();n&&n.length&&(t.buffer.push(n),t.length+=t.objectMode?1:n.length)}t.ended=!0,T(e)}(e,a)):(i||(o=function(e,t){var n;r=t,p.isBuffer(r)||r instanceof d||"string"==typeof t||void 0===t||e.objectMode||(n=new TypeError("Invalid non-string/buffer chunk"));var r;return n}(a,t)),o?e.emit("error",o):a.objectMode||t&&t.length>0?("string"==typeof t||a.objectMode||Object.getPrototypeOf(t)===p.prototype||(t=function(e){return p.from(e)}(t)),r?a.endEmitted?e.emit("error",new Error("stream.unshift() after end event")):S(e,a,t,!0):a.ended?e.emit("error",new Error("stream.push() after EOF")):(a.reading=!1,a.decoder&&!n?(t=a.decoder.write(t),a.objectMode||0!==t.length?S(e,a,t,!1):k(e,a)):S(e,a,t,!1))):r||(a.reading=!1));return function(e){return!e.ended&&(e.needReadable||e.length<e.highWaterMark||0===e.length)}(a)}function S(e,t,n,r){t.flowing&&0===t.length&&!t.sync?(e.emit("data",n),e.read(0)):(t.length+=t.objectMode?1:n.length,r?t.buffer.unshift(n):t.buffer.push(n),t.needReadable&&T(e)),k(e,t)}Object.defineProperty(v.prototype,"destroyed",{get:function(){return void 0!==this._readableState&&this._readableState.destroyed},set:function(e){this._readableState&&(this._readableState.destroyed=e)}}),v.prototype.destroy=g.destroy,v.prototype._undestroy=g.undestroy,v.prototype._destroy=function(e,t){this.push(null),t(e)},v.prototype.push=function(e,t){var n,r=this._readableState;return r.objectMode?n=!0:"string"==typeof e&&((t=t||r.defaultEncoding)!==r.encoding&&(e=p.from(e,t),t=""),n=!0),w(this,e,t,!1,n)},v.prototype.unshift=function(e){return w(this,e,null,!0,!1)},v.prototype.isPaused=function(){return!1===this._readableState.flowing},v.prototype.setEncoding=function(e){return f||(f=n(149).StringDecoder),this._readableState.decoder=new f(e),this._readableState.encoding=e,this};var x=8388608;function I(e,t){return e<=0||0===t.length&&t.ended?0:t.objectMode?1:e!=e?t.flowing&&t.length?t.buffer.head.data.length:t.length:(e>t.highWaterMark&&(t.highWaterMark=function(e){return e>=x?e=x:(e--,e|=e>>>1,e|=e>>>2,e|=e>>>4,e|=e>>>8,e|=e>>>16,e++),e}(e)),e<=t.length?e:t.ended?t.length:(t.needReadable=!0,0))}function T(e){var t=e._readableState;t.needReadable=!1,t.emittedReadable||(m("emitReadable",t.flowing),t.emittedReadable=!0,t.sync?i.nextTick(R,e):R(e))}function R(e){m("emit readable"),e.emit("readable"),E(e)}function k(e,t){t.readingMore||(t.readingMore=!0,i.nextTick(C,e,t))}function C(e,t){for(var n=t.length;!t.reading&&!t.flowing&&!t.ended&&t.length<t.highWaterMark&&(m("maybeReadMore read 0"),e.read(0),n!==t.length);)n=t.length;t.readingMore=!1}function O(e){m("readable nexttick read 0"),e.read(0)}function $(e,t){t.reading||(m("resume read 0"),e.read(0)),t.resumeScheduled=!1,t.awaitDrain=0,e.emit("resume"),E(e),t.flowing&&!t.reading&&e.read(0)}function E(e){var t=e._readableState;for(m("flow",t.flowing);t.flowing&&null!==e.read(););}function D(e,t){return 0===t.length?null:(t.objectMode?n=t.buffer.shift():!e||e>=t.length?(n=t.decoder?t.buffer.join(""):1===t.buffer.length?t.buffer.head.data:t.buffer.concat(t.length),t.buffer.clear()):n=function(e,t,n){var r;e<t.head.data.length?(r=t.head.data.slice(0,e),t.head.data=t.head.data.slice(e)):r=e===t.head.data.length?t.shift():n?function(e,t){var n=t.head,r=1,i=n.data;e-=i.length;for(;n=n.next;){var o=n.data,a=e>o.length?o.length:e;if(a===o.length?i+=o:i+=o.slice(0,e),0===(e-=a)){a===o.length?(++r,n.next?t.head=n.next:t.head=t.tail=null):(t.head=n,n.data=o.slice(a));break}++r}return t.length-=r,i}(e,t):function(e,t){var n=p.allocUnsafe(e),r=t.head,i=1;r.data.copy(n),e-=r.data.length;for(;r=r.next;){var o=r.data,a=e>o.length?o.length:e;if(o.copy(n,n.length-e,0,a),0===(e-=a)){a===o.length?(++i,r.next?t.head=r.next:t.head=t.tail=null):(t.head=r,r.data=o.slice(a));break}++i}return t.length-=i,n}(e,t);return r}(e,t.buffer,t.decoder),n);var n}function j(e){var t=e._readableState;if(t.length>0)throw new Error('"endReadable()" called on non-empty stream');t.endEmitted||(t.ended=!0,i.nextTick(P,t,e))}function P(e,t){e.endEmitted||0!==e.length||(e.endEmitted=!0,t.readable=!1,t.emit("end"))}function A(e,t){for(var n=0,r=e.length;n<r;n++)if(e[n]===t)return n;return-1}v.prototype.read=function(e){m("read",e),e=parseInt(e,10);var t=this._readableState,n=e;if(0!==e&&(t.emittedReadable=!1),0===e&&t.needReadable&&(t.length>=t.highWaterMark||t.ended))return m("read: emitReadable",t.length,t.ended),0===t.length&&t.ended?j(this):T(this),null;if(0===(e=I(e,t))&&t.ended)return 0===t.length&&j(this),null;var r,i=t.needReadable;return m("need readable",i),(0===t.length||t.length-e<t.highWaterMark)&&m("length less than watermark",i=!0),t.ended||t.reading?m("reading or ended",i=!1):i&&(m("do read"),t.reading=!0,t.sync=!0,0===t.length&&(t.needReadable=!0),this._read(t.highWaterMark),t.sync=!1,t.reading||(e=I(n,t))),null===(r=e>0?D(e,t):null)?(t.needReadable=!0,e=0):t.length-=e,0===t.length&&(t.ended||(t.needReadable=!0),n!==e&&t.ended&&j(this)),null!==r&&this.emit("data",r),r},v.prototype._read=function(e){this.emit("error",new Error("_read() is not implemented"))},v.prototype.pipe=function(e,t){var n=this,o=this._readableState;switch(o.pipesCount){case 0:o.pipes=e;break;case 1:o.pipes=[o.pipes,e];break;default:o.pipes.push(e)}o.pipesCount+=1,m("pipe count=%d opts=%j",o.pipesCount,t);var c=(!t||!1!==t.end)&&e!==r.stdout&&e!==r.stderr?d:v;function p(t,r){m("onunpipe"),t===n&&r&&!1===r.hasUnpiped&&(r.hasUnpiped=!0,m("cleanup"),e.removeListener("close",y),e.removeListener("finish",b),e.removeListener("drain",u),e.removeListener("error",g),e.removeListener("unpipe",p),n.removeListener("end",d),n.removeListener("end",v),n.removeListener("data",h),l=!0,!o.awaitDrain||e._writableState&&!e._writableState.needDrain||u())}function d(){m("onend"),e.end()}o.endEmitted?i.nextTick(c):n.once("end",c),e.on("unpipe",p);var u=function(e){return function(){var t=e._readableState;m("pipeOnDrain",t.awaitDrain),t.awaitDrain&&t.awaitDrain--,0===t.awaitDrain&&s(e,"data")&&(t.flowing=!0,E(e))}}(n);e.on("drain",u);var l=!1;var f=!1;function h(t){m("ondata"),f=!1,!1!==e.write(t)||f||((1===o.pipesCount&&o.pipes===e||o.pipesCount>1&&-1!==A(o.pipes,e))&&!l&&(m("false write response, pause",n._readableState.awaitDrain),n._readableState.awaitDrain++,f=!0),n.pause())}function g(t){m("onerror",t),v(),e.removeListener("error",g),0===s(e,"error")&&e.emit("error",t)}function y(){e.removeListener("finish",b),v()}function b(){m("onfinish"),e.removeListener("close",y),v()}function v(){m("unpipe"),n.unpipe(e)}return n.on("data",h),function(e,t,n){if("function"==typeof e.prependListener)return e.prependListener(t,n);e._events&&e._events[t]?a(e._events[t])?e._events[t].unshift(n):e._events[t]=[n,e._events[t]]:e.on(t,n)}(e,"error",g),e.once("close",y),e.once("finish",b),e.emit("pipe",n),o.flowing||(m("pipe resume"),n.resume()),e},v.prototype.unpipe=function(e){var t=this._readableState,n={hasUnpiped:!1};if(0===t.pipesCount)return this;if(1===t.pipesCount)return e&&e!==t.pipes?this:(e||(e=t.pipes),t.pipes=null,t.pipesCount=0,t.flowing=!1,e&&e.emit("unpipe",this,n),this);if(!e){var r=t.pipes,i=t.pipesCount;t.pipes=null,t.pipesCount=0,t.flowing=!1;for(var o=0;o<i;o++)r[o].emit("unpipe",this,n);return this}var a=A(t.pipes,e);return-1===a?this:(t.pipes.splice(a,1),t.pipesCount-=1,1===t.pipesCount&&(t.pipes=t.pipes[0]),e.emit("unpipe",this,n),this)},v.prototype.on=function(e,t){var n=c.prototype.on.call(this,e,t);if("data"===e)!1!==this._readableState.flowing&&this.resume();else if("readable"===e){var r=this._readableState;r.endEmitted||r.readableListening||(r.readableListening=r.needReadable=!0,r.emittedReadable=!1,r.reading?r.length&&T(this):i.nextTick(O,this))}return n},v.prototype.addListener=v.prototype.on,v.prototype.resume=function(){var e=this._readableState;return e.flowing||(m("resume"),e.flowing=!0,function(e,t){t.resumeScheduled||(t.resumeScheduled=!0,i.nextTick($,e,t))}(this,e)),this},v.prototype.pause=function(){return m("call pause flowing=%j",this._readableState.flowing),!1!==this._readableState.flowing&&(m("pause"),this._readableState.flowing=!1,this.emit("pause")),this},v.prototype.wrap=function(e){var t=this,n=this._readableState,r=!1;for(var i in e.on("end",function(){if(m("wrapped end"),n.decoder&&!n.ended){var e=n.decoder.end();e&&e.length&&t.push(e)}t.push(null)}),e.on("data",function(i){(m("wrapped data"),n.decoder&&(i=n.decoder.write(i)),n.objectMode&&null==i)||(n.objectMode||i&&i.length)&&(t.push(i)||(r=!0,e.pause()))}),e)void 0===this[i]&&"function"==typeof e[i]&&(this[i]=function(t){return function(){return e[t].apply(e,arguments)}}(i));for(var o=0;o<y.length;o++)e.on(y[o],this.emit.bind(this,y[o]));return this._read=function(t){m("wrapped _read",t),r&&(r=!1,e.resume())},this},Object.defineProperty(v.prototype,"readableHighWaterMark",{enumerable:!1,get:function(){return this._readableState.highWaterMark}}),v._fromList=D}).call(this,n(11),n(30))},function(e,t,n){e.exports=n(56).EventEmitter},function(e,t,n){"use strict";var r=n(73);function i(e,t){e.emit("error",t)}e.exports={destroy:function(e,t){var n=this,o=this._readableState&&this._readableState.destroyed,a=this._writableState&&this._writableState.destroyed;return o||a?(t?t(e):!e||this._writableState&&this._writableState.errorEmitted||r.nextTick(i,this,e),this):(this._readableState&&(this._readableState.destroyed=!0),this._writableState&&(this._writableState.destroyed=!0),this._destroy(e||null,function(e){!t&&e?(r.nextTick(i,n,e),n._writableState&&(n._writableState.errorEmitted=!0)):t&&t(e)}),this)},undestroy:function(){this._readableState&&(this._readableState.destroyed=!1,this._readableState.reading=!1,this._readableState.ended=!1,this._readableState.endEmitted=!1),this._writableState&&(this._writableState.destroyed=!1,this._writableState.ended=!1,this._writableState.ending=!1,this._writableState.finished=!1,this._writableState.errorEmitted=!1)}}},function(e,t,n){"use strict";(function(t,r,i){var o=n(73);function a(e){var t=this;this.next=null,this.entry=null,this.finish=function(){!function(e,t,n){var r=e.entry;e.entry=null;for(;r;){var i=r.callback;t.pendingcb--,i(n),r=r.next}t.corkedRequestsFree?t.corkedRequestsFree.next=e:t.corkedRequestsFree=e}(t,e)}}e.exports=b;var s,c=!t.browser&&["v0.10","v0.9."].indexOf(t.version.slice(0,5))>-1?r:o.nextTick;b.WritableState=y;var p=n(58);p.inherits=n(34);var d={deprecate:n(364)},u=n(146),l=n(74).Buffer,m=i.Uint8Array||function(){};var f,h=n(147);function g(){}function y(e,t){s=s||n(50),e=e||{};var r=t instanceof s;this.objectMode=!!e.objectMode,r&&(this.objectMode=this.objectMode||!!e.writableObjectMode);var i=e.highWaterMark,p=e.writableHighWaterMark,d=this.objectMode?16:16384;this.highWaterMark=i||0===i?i:r&&(p||0===p)?p:d,this.highWaterMark=Math.floor(this.highWaterMark),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var u=!1===e.decodeStrings;this.decodeStrings=!u,this.defaultEncoding=e.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(e){!function(e,t){var n=e._writableState,r=n.sync,i=n.writecb;if(function(e){e.writing=!1,e.writecb=null,e.length-=e.writelen,e.writelen=0}(n),t)!function(e,t,n,r,i){--t.pendingcb,n?(o.nextTick(i,r),o.nextTick(T,e,t),e._writableState.errorEmitted=!0,e.emit("error",r)):(i(r),e._writableState.errorEmitted=!0,e.emit("error",r),T(e,t))}(e,n,r,t,i);else{var a=x(n);a||n.corked||n.bufferProcessing||!n.bufferedRequest||S(e,n),r?c(w,e,n,a,i):w(e,n,a,i)}}(t,e)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.bufferedRequestCount=0,this.corkedRequestsFree=new a(this)}function b(e){if(s=s||n(50),!(f.call(b,this)||this instanceof s))return new b(e);this._writableState=new y(e,this),this.writable=!0,e&&("function"==typeof e.write&&(this._write=e.write),"function"==typeof e.writev&&(this._writev=e.writev),"function"==typeof e.destroy&&(this._destroy=e.destroy),"function"==typeof e.final&&(this._final=e.final)),u.call(this)}function v(e,t,n,r,i,o,a){t.writelen=r,t.writecb=a,t.writing=!0,t.sync=!0,n?e._writev(i,t.onwrite):e._write(i,o,t.onwrite),t.sync=!1}function w(e,t,n,r){n||function(e,t){0===t.length&&t.needDrain&&(t.needDrain=!1,e.emit("drain"))}(e,t),t.pendingcb--,r(),T(e,t)}function S(e,t){t.bufferProcessing=!0;var n=t.bufferedRequest;if(e._writev&&n&&n.next){var r=t.bufferedRequestCount,i=new Array(r),o=t.corkedRequestsFree;o.entry=n;for(var s=0,c=!0;n;)i[s]=n,n.isBuf||(c=!1),n=n.next,s+=1;i.allBuffers=c,v(e,t,!0,t.length,i,"",o.finish),t.pendingcb++,t.lastBufferedRequest=null,o.next?(t.corkedRequestsFree=o.next,o.next=null):t.corkedRequestsFree=new a(t),t.bufferedRequestCount=0}else{for(;n;){var p=n.chunk,d=n.encoding,u=n.callback;if(v(e,t,!1,t.objectMode?1:p.length,p,d,u),n=n.next,t.bufferedRequestCount--,t.writing)break}null===n&&(t.lastBufferedRequest=null)}t.bufferedRequest=n,t.bufferProcessing=!1}function x(e){return e.ending&&0===e.length&&null===e.bufferedRequest&&!e.finished&&!e.writing}function I(e,t){e._final(function(n){t.pendingcb--,n&&e.emit("error",n),t.prefinished=!0,e.emit("prefinish"),T(e,t)})}function T(e,t){var n=x(t);return n&&(!function(e,t){t.prefinished||t.finalCalled||("function"==typeof e._final?(t.pendingcb++,t.finalCalled=!0,o.nextTick(I,e,t)):(t.prefinished=!0,e.emit("prefinish")))}(e,t),0===t.pendingcb&&(t.finished=!0,e.emit("finish"))),n}p.inherits(b,u),y.prototype.getBuffer=function(){for(var e=this.bufferedRequest,t=[];e;)t.push(e),e=e.next;return t},function(){try{Object.defineProperty(y.prototype,"buffer",{get:d.deprecate(function(){return this.getBuffer()},"_writableState.buffer is deprecated. Use _writableState.getBuffer instead.","DEP0003")})}catch(e){}}(),"function"==typeof Symbol&&Symbol.hasInstance&&"function"==typeof Function.prototype[Symbol.hasInstance]?(f=Function.prototype[Symbol.hasInstance],Object.defineProperty(b,Symbol.hasInstance,{value:function(e){return!!f.call(this,e)||this===b&&(e&&e._writableState instanceof y)}})):f=function(e){return e instanceof this},b.prototype.pipe=function(){this.emit("error",new Error("Cannot pipe, not readable"))},b.prototype.write=function(e,t,n){var r,i=this._writableState,a=!1,s=!i.objectMode&&(r=e,l.isBuffer(r)||r instanceof m);return s&&!l.isBuffer(e)&&(e=function(e){return l.from(e)}(e)),"function"==typeof t&&(n=t,t=null),s?t="buffer":t||(t=i.defaultEncoding),"function"!=typeof n&&(n=g),i.ended?function(e,t){var n=new Error("write after end");e.emit("error",n),o.nextTick(t,n)}(this,n):(s||function(e,t,n,r){var i=!0,a=!1;return null===n?a=new TypeError("May not write null values to stream"):"string"==typeof n||void 0===n||t.objectMode||(a=new TypeError("Invalid non-string/buffer chunk")),a&&(e.emit("error",a),o.nextTick(r,a),i=!1),i}(this,i,e,n))&&(i.pendingcb++,a=function(e,t,n,r,i,o){if(!n){var a=function(e,t,n){e.objectMode||!1===e.decodeStrings||"string"!=typeof t||(t=l.from(t,n));return t}(t,r,i);r!==a&&(n=!0,i="buffer",r=a)}var s=t.objectMode?1:r.length;t.length+=s;var c=t.length<t.highWaterMark;c||(t.needDrain=!0);if(t.writing||t.corked){var p=t.lastBufferedRequest;t.lastBufferedRequest={chunk:r,encoding:i,isBuf:n,callback:o,next:null},p?p.next=t.lastBufferedRequest:t.bufferedRequest=t.lastBufferedRequest,t.bufferedRequestCount+=1}else v(e,t,!1,s,r,i,o);return c}(this,i,s,e,t,n)),a},b.prototype.cork=function(){this._writableState.corked++},b.prototype.uncork=function(){var e=this._writableState;e.corked&&(e.corked--,e.writing||e.corked||e.finished||e.bufferProcessing||!e.bufferedRequest||S(this,e))},b.prototype.setDefaultEncoding=function(e){if("string"==typeof e&&(e=e.toLowerCase()),!(["hex","utf8","utf-8","ascii","binary","base64","ucs2","ucs-2","utf16le","utf-16le","raw"].indexOf((e+"").toLowerCase())>-1))throw new TypeError("Unknown encoding: "+e);return this._writableState.defaultEncoding=e,this},Object.defineProperty(b.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}}),b.prototype._write=function(e,t,n){n(new Error("_write() is not implemented"))},b.prototype._writev=null,b.prototype.end=function(e,t,n){var r=this._writableState;"function"==typeof e?(n=e,e=null,t=null):"function"==typeof t&&(n=t,t=null),null!=e&&this.write(e,t),r.corked&&(r.corked=1,this.uncork()),r.ending||r.finished||function(e,t,n){t.ending=!0,T(e,t),n&&(t.finished?o.nextTick(n):e.once("finish",n));t.ended=!0,e.writable=!1}(this,r,n)},Object.defineProperty(b.prototype,"destroyed",{get:function(){return void 0!==this._writableState&&this._writableState.destroyed},set:function(e){this._writableState&&(this._writableState.destroyed=e)}}),b.prototype.destroy=h.destroy,b.prototype._undestroy=h.undestroy,b.prototype._destroy=function(e,t){this.end(),t(e)}}).call(this,n(30),n(362).setImmediate,n(11))},function(e,t,n){"use strict";var r=n(74).Buffer,i=r.isEncoding||function(e){switch((e=""+e)&&e.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function o(e){var t;switch(this.encoding=function(e){var t=function(e){if(!e)return"utf8";for(var t;;)switch(e){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return e;default:if(t)return;e=(""+e).toLowerCase(),t=!0}}(e);if("string"!=typeof t&&(r.isEncoding===i||!i(e)))throw new Error("Unknown encoding: "+e);return t||e}(e),this.encoding){case"utf16le":this.text=c,this.end=p,t=4;break;case"utf8":this.fillLast=s,t=4;break;case"base64":this.text=d,this.end=u,t=3;break;default:return this.write=l,void(this.end=m)}this.lastNeed=0,this.lastTotal=0,this.lastChar=r.allocUnsafe(t)}function a(e){return e<=127?0:e>>5==6?2:e>>4==14?3:e>>3==30?4:e>>6==2?-1:-2}function s(e){var t=this.lastTotal-this.lastNeed,n=function(e,t,n){if(128!=(192&t[0]))return e.lastNeed=0,"�";if(e.lastNeed>1&&t.length>1){if(128!=(192&t[1]))return e.lastNeed=1,"�";if(e.lastNeed>2&&t.length>2&&128!=(192&t[2]))return e.lastNeed=2,"�"}}(this,e);return void 0!==n?n:this.lastNeed<=e.length?(e.copy(this.lastChar,t,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(e.copy(this.lastChar,t,0,e.length),void(this.lastNeed-=e.length))}function c(e,t){if((e.length-t)%2==0){var n=e.toString("utf16le",t);if(n){var r=n.charCodeAt(n.length-1);if(r>=55296&&r<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1],n.slice(0,-1)}return n}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=e[e.length-1],e.toString("utf16le",t,e.length-1)}function p(e){var t=e&&e.length?this.write(e):"";if(this.lastNeed){var n=this.lastTotal-this.lastNeed;return t+this.lastChar.toString("utf16le",0,n)}return t}function d(e,t){var n=(e.length-t)%3;return 0===n?e.toString("base64",t):(this.lastNeed=3-n,this.lastTotal=3,1===n?this.lastChar[0]=e[e.length-1]:(this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1]),e.toString("base64",t,e.length-n))}function u(e){var t=e&&e.length?this.write(e):"";return this.lastNeed?t+this.lastChar.toString("base64",0,3-this.lastNeed):t}function l(e){return e.toString(this.encoding)}function m(e){return e&&e.length?this.write(e):""}t.StringDecoder=o,o.prototype.write=function(e){if(0===e.length)return"";var t,n;if(this.lastNeed){if(void 0===(t=this.fillLast(e)))return"";n=this.lastNeed,this.lastNeed=0}else n=0;return n<e.length?t?t+this.text(e,n):this.text(e,n):t||""},o.prototype.end=function(e){var t=e&&e.length?this.write(e):"";return this.lastNeed?t+"�":t},o.prototype.text=function(e,t){var n=function(e,t,n){var r=t.length-1;if(r<n)return 0;var i=a(t[r]);if(i>=0)return i>0&&(e.lastNeed=i-1),i;if(--r<n||-2===i)return 0;if((i=a(t[r]))>=0)return i>0&&(e.lastNeed=i-2),i;if(--r<n||-2===i)return 0;if((i=a(t[r]))>=0)return i>0&&(2===i?i=0:e.lastNeed=i-3),i;return 0}(this,e,t);if(!this.lastNeed)return e.toString("utf8",t);this.lastTotal=n;var r=e.length-(n-this.lastNeed);return e.copy(this.lastChar,0,r),e.toString("utf8",t,r)},o.prototype.fillLast=function(e){if(this.lastNeed<=e.length)return e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,e.length),this.lastNeed-=e.length}},function(e,t,n){"use strict";e.exports=a;var r=n(50),i=n(58);function o(e,t){var n=this._transformState;n.transforming=!1;var r=n.writecb;if(!r)return this.emit("error",new Error("write callback called multiple times"));n.writechunk=null,n.writecb=null,null!=t&&this.push(t),r(e);var i=this._readableState;i.reading=!1,(i.needReadable||i.length<i.highWaterMark)&&this._read(i.highWaterMark)}function a(e){if(!(this instanceof a))return new a(e);r.call(this,e),this._transformState={afterTransform:o.bind(this),needTransform:!1,transforming:!1,writecb:null,writechunk:null,writeencoding:null},this._readableState.needReadable=!0,this._readableState.sync=!1,e&&("function"==typeof e.transform&&(this._transform=e.transform),"function"==typeof e.flush&&(this._flush=e.flush)),this.on("prefinish",s)}function s(){var e=this;"function"==typeof this._flush?this._flush(function(t,n){c(e,t,n)}):c(this,null,null)}function c(e,t,n){if(t)return e.emit("error",t);if(null!=n&&e.push(n),e._writableState.length)throw new Error("Calling transform done when ws.length != 0");if(e._transformState.transforming)throw new Error("Calling transform done when still transforming");return e.push(null)}i.inherits=n(34),i.inherits(a,r),a.prototype.push=function(e,t){return this._transformState.needTransform=!1,r.prototype.push.call(this,e,t)},a.prototype._transform=function(e,t,n){throw new Error("_transform() is not implemented")},a.prototype._write=function(e,t,n){var r=this._transformState;if(r.writecb=n,r.writechunk=e,r.writeencoding=t,!r.transforming){var i=this._readableState;(r.needTransform||i.needReadable||i.length<i.highWaterMark)&&this._read(i.highWaterMark)}},a.prototype._read=function(e){var t=this._transformState;null!==t.writechunk&&t.writecb&&!t.transforming?(t.transforming=!0,this._transform(t.writechunk,t.writeencoding,t.afterTransform)):t.needTransform=!0},a.prototype._destroy=function(e,t){var n=this;r.prototype._destroy.call(this,e,function(e){t(e),n.emit("close")})}},function(e,t,n){"use strict";e.exports.HOST="localhost",e.exports.PORT=9222},function(e,t,n){n(153),e.exports=n(355)},function(e,t,n){"use strict";(function(e){if(n(154),n(351),n(352),e._babelPolyfill)throw new Error("only one instance of babel-polyfill is allowed");e._babelPolyfill=!0;var t="defineProperty";function r(e,n,r){e[n]||Object[t](e,n,{writable:!0,configurable:!0,value:r})}r(String.prototype,"padLeft","".padStart),r(String.prototype,"padRight","".padEnd),"pop,reverse,shift,keys,values,entries,indexOf,every,some,forEach,map,filter,find,findIndex,includes,join,slice,concat,push,splice,unshift,sort,lastIndexOf,reduce,reduceRight,copyWithin,fill".split(",").forEach(function(e){[][e]&&r(Array,e,Function.call.bind([][e]))})}).call(this,n(11))},function(e,t,n){n(155),n(158),n(159),n(160),n(161),n(162),n(163),n(164),n(165),n(166),n(167),n(168),n(169),n(170),n(171),n(172),n(173),n(174),n(175),n(176),n(177),n(178),n(179),n(180),n(181),n(182),n(183),n(184),n(185),n(186),n(187),n(188),n(189),n(190),n(191),n(192),n(193),n(194),n(195),n(196),n(197),n(198),n(199),n(200),n(201),n(202),n(203),n(204),n(205),n(206),n(207),n(208),n(209),n(210),n(211),n(212),n(213),n(214),n(215),n(216),n(217),n(218),n(219),n(220),n(221),n(222),n(223),n(224),n(225),n(226),n(227),n(228),n(229),n(230),n(231),n(232),n(233),n(235),n(236),n(238),n(239),n(240),n(241),n(242),n(243),n(244),n(246),n(247),n(248),n(249),n(250),n(251),n(252),n(253),n(254),n(255),n(256),n(257),n(258),n(96),n(259),n(122),n(260),n(123),n(261),n(262),n(263),n(264),n(265),n(126),n(128),n(129),n(266),n(267),n(268),n(269),n(270),n(271),n(272),n(273),n(274),n(275),n(276),n(277),n(278),n(279),n(280),n(281),n(282),n(283),n(284),n(285),n(286),n(287),n(288),n(289),n(290),n(291),n(292),n(293),n(294),n(295),n(296),n(297),n(298),n(299),n(300),n(301),n(302),n(303),n(304),n(305),n(306),n(307),n(308),n(309),n(310),n(311),n(312),n(313),n(314),n(315),n(316),n(317),n(318),n(319),n(320),n(321),n(322),n(323),n(324),n(325),n(326),n(327),n(328),n(329),n(330),n(331),n(332),n(333),n(334),n(335),n(336),n(337),n(338),n(339),n(340),n(341),n(342),n(343),n(344),n(345),n(346),n(347),n(348),n(349),n(350),e.exports=n(19)},function(e,t,n){"use strict";var r=n(2),i=n(15),o=n(7),a=n(0),s=n(13),c=n(32).KEY,p=n(3),d=n(51),u=n(46),l=n(36),m=n(5),f=n(104),h=n(77),g=n(157),y=n(61),b=n(1),v=n(4),w=n(9),S=n(16),x=n(24),I=n(35),T=n(39),R=n(107),k=n(17),C=n(60),O=n(8),$=n(37),E=k.f,D=O.f,j=R.f,P=r.Symbol,A=r.JSON,M=A&&A.stringify,N=m("_hidden"),_=m("toPrimitive"),L={}.propertyIsEnumerable,q=d("symbol-registry"),F=d("symbols"),U=d("op-symbols"),B=Object.prototype,W="function"==typeof P&&!!C.f,H=r.QObject,z=!H||!H.prototype||!H.prototype.findChild,V=o&&p(function(){return 7!=T(D({},"a",{get:function(){return D(this,"a",{value:7}).a}})).a})?function(e,t,n){var r=E(B,t);r&&delete B[t],D(e,t,n),r&&e!==B&&D(B,t,r)}:D,G=function(e){var t=F[e]=T(P.prototype);return t._k=e,t},J=W&&"symbol"==typeof P.iterator?function(e){return"symbol"==typeof e}:function(e){return e instanceof P},X=function(e,t,n){return e===B&&X(U,t,n),b(e),t=x(t,!0),b(n),i(F,t)?(n.enumerable?(i(e,N)&&e[N][t]&&(e[N][t]=!1),n=T(n,{enumerable:I(0,!1)})):(i(e,N)||D(e,N,I(1,{})),e[N][t]=!0),V(e,t,n)):D(e,t,n)},Y=function(e,t){b(e);for(var n,r=g(t=S(t)),i=0,o=r.length;o>i;)X(e,n=r[i++],t[n]);return e},K=function(e){var t=L.call(this,e=x(e,!0));return!(this===B&&i(F,e)&&!i(U,e))&&(!(t||!i(this,e)||!i(F,e)||i(this,N)&&this[N][e])||t)},Q=function(e,t){if(e=S(e),t=x(t,!0),e!==B||!i(F,t)||i(U,t)){var n=E(e,t);return!n||!i(F,t)||i(e,N)&&e[N][t]||(n.enumerable=!0),n}},Z=function(e){for(var t,n=j(S(e)),r=[],o=0;n.length>o;)i(F,t=n[o++])||t==N||t==c||r.push(t);return r},ee=function(e){for(var t,n=e===B,r=j(n?U:S(e)),o=[],a=0;r.length>a;)!i(F,t=r[a++])||n&&!i(B,t)||o.push(F[t]);return o};W||(s((P=function(){if(this instanceof P)throw TypeError("Symbol is not a constructor!");var e=l(arguments.length>0?arguments[0]:void 0),t=function(n){this===B&&t.call(U,n),i(this,N)&&i(this[N],e)&&(this[N][e]=!1),V(this,e,I(1,n))};return o&&z&&V(B,e,{configurable:!0,set:t}),G(e)}).prototype,"toString",function(){return this._k}),k.f=Q,O.f=X,n(40).f=R.f=Z,n(53).f=K,C.f=ee,o&&!n(31)&&s(B,"propertyIsEnumerable",K,!0),f.f=function(e){return G(m(e))}),a(a.G+a.W+a.F*!W,{Symbol:P});for(var te="hasInstance,isConcatSpreadable,iterator,match,replace,search,species,split,toPrimitive,toStringTag,unscopables".split(","),ne=0;te.length>ne;)m(te[ne++]);for(var re=$(m.store),ie=0;re.length>ie;)h(re[ie++]);a(a.S+a.F*!W,"Symbol",{for:function(e){return i(q,e+="")?q[e]:q[e]=P(e)},keyFor:function(e){if(!J(e))throw TypeError(e+" is not a symbol!");for(var t in q)if(q[t]===e)return t},useSetter:function(){z=!0},useSimple:function(){z=!1}}),a(a.S+a.F*!W,"Object",{create:function(e,t){return void 0===t?T(e):Y(T(e),t)},defineProperty:X,defineProperties:Y,getOwnPropertyDescriptor:Q,getOwnPropertyNames:Z,getOwnPropertySymbols:ee});var oe=p(function(){C.f(1)});a(a.S+a.F*oe,"Object",{getOwnPropertySymbols:function(e){return C.f(w(e))}}),A&&a(a.S+a.F*(!W||p(function(){var e=P();return"[null]"!=M([e])||"{}"!=M({a:e})||"{}"!=M(Object(e))})),"JSON",{stringify:function(e){for(var t,n,r=[e],i=1;arguments.length>i;)r.push(arguments[i++]);if(n=t=r[1],(v(t)||void 0!==e)&&!J(e))return y(t)||(t=function(e,t){if("function"==typeof n&&(t=n.call(this,e,t)),!J(t))return t}),r[1]=t,M.apply(A,r)}}),P.prototype[_]||n(12)(P.prototype,_,P.prototype.valueOf),u(P,"Symbol"),u(Math,"Math",!0),u(r.JSON,"JSON",!0)},function(e,t,n){e.exports=n(51)("native-function-to-string",Function.toString)},function(e,t,n){var r=n(37),i=n(60),o=n(53);e.exports=function(e){var t=r(e),n=i.f;if(n)for(var a,s=n(e),c=o.f,p=0;s.length>p;)c.call(e,a=s[p++])&&t.push(a);return t}},function(e,t,n){var r=n(0);r(r.S,"Object",{create:n(39)})},function(e,t,n){var r=n(0);r(r.S+r.F*!n(7),"Object",{defineProperty:n(8).f})},function(e,t,n){var r=n(0);r(r.S+r.F*!n(7),"Object",{defineProperties:n(106)})},function(e,t,n){var r=n(16),i=n(17).f;n(26)("getOwnPropertyDescriptor",function(){return function(e,t){return i(r(e),t)}})},function(e,t,n){var r=n(9),i=n(18);n(26)("getPrototypeOf",function(){return function(e){return i(r(e))}})},function(e,t,n){var r=n(9),i=n(37);n(26)("keys",function(){return function(e){return i(r(e))}})},function(e,t,n){n(26)("getOwnPropertyNames",function(){return n(107).f})},function(e,t,n){var r=n(4),i=n(32).onFreeze;n(26)("freeze",function(e){return function(t){return e&&r(t)?e(i(t)):t}})},function(e,t,n){var r=n(4),i=n(32).onFreeze;n(26)("seal",function(e){return function(t){return e&&r(t)?e(i(t)):t}})},function(e,t,n){var r=n(4),i=n(32).onFreeze;n(26)("preventExtensions",function(e){return function(t){return e&&r(t)?e(i(t)):t}})},function(e,t,n){var r=n(4);n(26)("isFrozen",function(e){return function(t){return!r(t)||!!e&&e(t)}})},function(e,t,n){var r=n(4);n(26)("isSealed",function(e){return function(t){return!r(t)||!!e&&e(t)}})},function(e,t,n){var r=n(4);n(26)("isExtensible",function(e){return function(t){return!!r(t)&&(!e||e(t))}})},function(e,t,n){var r=n(0);r(r.S+r.F,"Object",{assign:n(108)})},function(e,t,n){var r=n(0);r(r.S,"Object",{is:n(109)})},function(e,t,n){var r=n(0);r(r.S,"Object",{setPrototypeOf:n(81).set})},function(e,t,n){"use strict";var r=n(47),i={};i[n(5)("toStringTag")]="z",i+""!="[object z]"&&n(13)(Object.prototype,"toString",function(){return"[object "+r(this)+"]"},!0)},function(e,t,n){var r=n(0);r(r.P,"Function",{bind:n(110)})},function(e,t,n){var r=n(8).f,i=Function.prototype,o=/^\s*function ([^ (]*)/;"name"in i||n(7)&&r(i,"name",{configurable:!0,get:function(){try{return(""+this).match(o)[1]}catch(e){return""}}})},function(e,t,n){"use strict";var r=n(4),i=n(18),o=n(5)("hasInstance"),a=Function.prototype;o in a||n(8).f(a,o,{value:function(e){if("function"!=typeof this||!r(e))return!1;if(!r(this.prototype))return e instanceof this;for(;e=i(e);)if(this.prototype===e)return!0;return!1}})},function(e,t,n){var r=n(0),i=n(112);r(r.G+r.F*(parseInt!=i),{parseInt:i})},function(e,t,n){var r=n(0),i=n(113);r(r.G+r.F*(parseFloat!=i),{parseFloat:i})},function(e,t,n){"use strict";var r=n(2),i=n(15),o=n(21),a=n(83),s=n(24),c=n(3),p=n(40).f,d=n(17).f,u=n(8).f,l=n(48).trim,m=r.Number,f=m,h=m.prototype,g="Number"==o(n(39)(h)),y="trim"in String.prototype,b=function(e){var t=s(e,!1);if("string"==typeof t&&t.length>2){var n,r,i,o=(t=y?t.trim():l(t,3)).charCodeAt(0);if(43===o||45===o){if(88===(n=t.charCodeAt(2))||120===n)return NaN}else if(48===o){switch(t.charCodeAt(1)){case 66:case 98:r=2,i=49;break;case 79:case 111:r=8,i=55;break;default:return+t}for(var a,c=t.slice(2),p=0,d=c.length;p<d;p++)if((a=c.charCodeAt(p))<48||a>i)return NaN;return parseInt(c,r)}}return+t};if(!m(" 0o1")||!m("0b1")||m("+0x1")){m=function(e){var t=arguments.length<1?0:e,n=this;return n instanceof m&&(g?c(function(){h.valueOf.call(n)}):"Number"!=o(n))?a(new f(b(t)),n,m):b(t)};for(var v,w=n(7)?p(f):"MAX_VALUE,MIN_VALUE,NaN,NEGATIVE_INFINITY,POSITIVE_INFINITY,EPSILON,isFinite,isInteger,isNaN,isSafeInteger,MAX_SAFE_INTEGER,MIN_SAFE_INTEGER,parseFloat,parseInt,isInteger".split(","),S=0;w.length>S;S++)i(f,v=w[S])&&!i(m,v)&&u(m,v,d(f,v));m.prototype=h,h.constructor=m,n(13)(r,"Number",m)}},function(e,t,n){"use strict";var r=n(0),i=n(22),o=n(114),a=n(84),s=1..toFixed,c=Math.floor,p=[0,0,0,0,0,0],d="Number.toFixed: incorrect invocation!",u=function(e,t){for(var n=-1,r=t;++n<6;)r+=e*p[n],p[n]=r%1e7,r=c(r/1e7)},l=function(e){for(var t=6,n=0;--t>=0;)n+=p[t],p[t]=c(n/e),n=n%e*1e7},m=function(){for(var e=6,t="";--e>=0;)if(""!==t||0===e||0!==p[e]){var n=String(p[e]);t=""===t?n:t+a.call("0",7-n.length)+n}return t},f=function(e,t,n){return 0===t?n:t%2==1?f(e,t-1,n*e):f(e*e,t/2,n)};r(r.P+r.F*(!!s&&("0.000"!==8e-5.toFixed(3)||"1"!==.9.toFixed(0)||"1.25"!==1.255.toFixed(2)||"1000000000000000128"!==(0xde0b6b3a7640080).toFixed(0))||!n(3)(function(){s.call({})})),"Number",{toFixed:function(e){var t,n,r,s,c=o(this,d),p=i(e),h="",g="0";if(p<0||p>20)throw RangeError(d);if(c!=c)return"NaN";if(c<=-1e21||c>=1e21)return String(c);if(c<0&&(h="-",c=-c),c>1e-21)if(n=(t=function(e){for(var t=0,n=e;n>=4096;)t+=12,n/=4096;for(;n>=2;)t+=1,n/=2;return t}(c*f(2,69,1))-69)<0?c*f(2,-t,1):c/f(2,t,1),n*=4503599627370496,(t=52-t)>0){for(u(0,n),r=p;r>=7;)u(1e7,0),r-=7;for(u(f(10,r,1),0),r=t-1;r>=23;)l(1<<23),r-=23;l(1<<r),u(1,1),l(2),g=m()}else u(0,n),u(1<<-t,0),g=m()+a.call("0",p);return g=p>0?h+((s=g.length)<=p?"0."+a.call("0",p-s)+g:g.slice(0,s-p)+"."+g.slice(s-p)):h+g}})},function(e,t,n){"use strict";var r=n(0),i=n(3),o=n(114),a=1..toPrecision;r(r.P+r.F*(i(function(){return"1"!==a.call(1,void 0)})||!i(function(){a.call({})})),"Number",{toPrecision:function(e){var t=o(this,"Number#toPrecision: incorrect invocation!");return void 0===e?a.call(t):a.call(t,e)}})},function(e,t,n){var r=n(0);r(r.S,"Number",{EPSILON:Math.pow(2,-52)})},function(e,t,n){var r=n(0),i=n(2).isFinite;r(r.S,"Number",{isFinite:function(e){return"number"==typeof e&&i(e)}})},function(e,t,n){var r=n(0);r(r.S,"Number",{isInteger:n(115)})},function(e,t,n){var r=n(0);r(r.S,"Number",{isNaN:function(e){return e!=e}})},function(e,t,n){var r=n(0),i=n(115),o=Math.abs;r(r.S,"Number",{isSafeInteger:function(e){return i(e)&&o(e)<=9007199254740991}})},function(e,t,n){var r=n(0);r(r.S,"Number",{MAX_SAFE_INTEGER:9007199254740991})},function(e,t,n){var r=n(0);r(r.S,"Number",{MIN_SAFE_INTEGER:-9007199254740991})},function(e,t,n){var r=n(0),i=n(113);r(r.S+r.F*(Number.parseFloat!=i),"Number",{parseFloat:i})},function(e,t,n){var r=n(0),i=n(112);r(r.S+r.F*(Number.parseInt!=i),"Number",{parseInt:i})},function(e,t,n){var r=n(0),i=n(116),o=Math.sqrt,a=Math.acosh;r(r.S+r.F*!(a&&710==Math.floor(a(Number.MAX_VALUE))&&a(1/0)==1/0),"Math",{acosh:function(e){return(e=+e)<1?NaN:e>94906265.62425156?Math.log(e)+Math.LN2:i(e-1+o(e-1)*o(e+1))}})},function(e,t,n){var r=n(0),i=Math.asinh;r(r.S+r.F*!(i&&1/i(0)>0),"Math",{asinh:function e(t){return isFinite(t=+t)&&0!=t?t<0?-e(-t):Math.log(t+Math.sqrt(t*t+1)):t}})},function(e,t,n){var r=n(0),i=Math.atanh;r(r.S+r.F*!(i&&1/i(-0)<0),"Math",{atanh:function(e){return 0==(e=+e)?e:Math.log((1+e)/(1-e))/2}})},function(e,t,n){var r=n(0),i=n(85);r(r.S,"Math",{cbrt:function(e){return i(e=+e)*Math.pow(Math.abs(e),1/3)}})},function(e,t,n){var r=n(0);r(r.S,"Math",{clz32:function(e){return(e>>>=0)?31-Math.floor(Math.log(e+.5)*Math.LOG2E):32}})},function(e,t,n){var r=n(0),i=Math.exp;r(r.S,"Math",{cosh:function(e){return(i(e=+e)+i(-e))/2}})},function(e,t,n){var r=n(0),i=n(86);r(r.S+r.F*(i!=Math.expm1),"Math",{expm1:i})},function(e,t,n){var r=n(0);r(r.S,"Math",{fround:n(117)})},function(e,t,n){var r=n(0),i=Math.abs;r(r.S,"Math",{hypot:function(e,t){for(var n,r,o=0,a=0,s=arguments.length,c=0;a<s;)c<(n=i(arguments[a++]))?(o=o*(r=c/n)*r+1,c=n):o+=n>0?(r=n/c)*r:n;return c===1/0?1/0:c*Math.sqrt(o)}})},function(e,t,n){var r=n(0),i=Math.imul;r(r.S+r.F*n(3)(function(){return-5!=i(4294967295,5)||2!=i.length}),"Math",{imul:function(e,t){var n=+e,r=+t,i=65535&n,o=65535&r;return 0|i*o+((65535&n>>>16)*o+i*(65535&r>>>16)<<16>>>0)}})},function(e,t,n){var r=n(0);r(r.S,"Math",{log10:function(e){return Math.log(e)*Math.LOG10E}})},function(e,t,n){var r=n(0);r(r.S,"Math",{log1p:n(116)})},function(e,t,n){var r=n(0);r(r.S,"Math",{log2:function(e){return Math.log(e)/Math.LN2}})},function(e,t,n){var r=n(0);r(r.S,"Math",{sign:n(85)})},function(e,t,n){var r=n(0),i=n(86),o=Math.exp;r(r.S+r.F*n(3)(function(){return-2e-17!=!Math.sinh(-2e-17)}),"Math",{sinh:function(e){return Math.abs(e=+e)<1?(i(e)-i(-e))/2:(o(e-1)-o(-e-1))*(Math.E/2)}})},function(e,t,n){var r=n(0),i=n(86),o=Math.exp;r(r.S,"Math",{tanh:function(e){var t=i(e=+e),n=i(-e);return t==1/0?1:n==1/0?-1:(t-n)/(o(e)+o(-e))}})},function(e,t,n){var r=n(0);r(r.S,"Math",{trunc:function(e){return(e>0?Math.floor:Math.ceil)(e)}})},function(e,t,n){var r=n(0),i=n(38),o=String.fromCharCode,a=String.fromCodePoint;r(r.S+r.F*(!!a&&1!=a.length),"String",{fromCodePoint:function(e){for(var t,n=[],r=arguments.length,a=0;r>a;){if(t=+arguments[a++],i(t,1114111)!==t)throw RangeError(t+" is not a valid code point");n.push(t<65536?o(t):o(55296+((t-=65536)>>10),t%1024+56320))}return n.join("")}})},function(e,t,n){var r=n(0),i=n(16),o=n(6);r(r.S,"String",{raw:function(e){for(var t=i(e.raw),n=o(t.length),r=arguments.length,a=[],s=0;n>s;)a.push(String(t[s++])),s<r&&a.push(String(arguments[s]));return a.join("")}})},function(e,t,n){"use strict";n(48)("trim",function(e){return function(){return e(this,3)}})},function(e,t,n){"use strict";var r=n(62)(!0);n(87)(String,"String",function(e){this._t=String(e),this._i=0},function(){var e,t=this._t,n=this._i;return n>=t.length?{value:void 0,done:!0}:(e=r(t,n),this._i+=e.length,{value:e,done:!1})})},function(e,t,n){"use strict";var r=n(0),i=n(62)(!1);r(r.P,"String",{codePointAt:function(e){return i(this,e)}})},function(e,t,n){"use strict";var r=n(0),i=n(6),o=n(89),a="".endsWith;r(r.P+r.F*n(90)("endsWith"),"String",{endsWith:function(e){var t=o(this,e,"endsWith"),n=arguments.length>1?arguments[1]:void 0,r=i(t.length),s=void 0===n?r:Math.min(i(n),r),c=String(e);return a?a.call(t,c,s):t.slice(s-c.length,s)===c}})},function(e,t,n){"use strict";var r=n(0),i=n(89);r(r.P+r.F*n(90)("includes"),"String",{includes:function(e){return!!~i(this,e,"includes").indexOf(e,arguments.length>1?arguments[1]:void 0)}})},function(e,t,n){var r=n(0);r(r.P,"String",{repeat:n(84)})},function(e,t,n){"use strict";var r=n(0),i=n(6),o=n(89),a="".startsWith;r(r.P+r.F*n(90)("startsWith"),"String",{startsWith:function(e){var t=o(this,e,"startsWith"),n=i(Math.min(arguments.length>1?arguments[1]:void 0,t.length)),r=String(e);return a?a.call(t,r,n):t.slice(n,n+r.length)===r}})},function(e,t,n){"use strict";n(14)("anchor",function(e){return function(t){return e(this,"a","name",t)}})},function(e,t,n){"use strict";n(14)("big",function(e){return function(){return e(this,"big","","")}})},function(e,t,n){"use strict";n(14)("blink",function(e){return function(){return e(this,"blink","","")}})},function(e,t,n){"use strict";n(14)("bold",function(e){return function(){return e(this,"b","","")}})},function(e,t,n){"use strict";n(14)("fixed",function(e){return function(){return e(this,"tt","","")}})},function(e,t,n){"use strict";n(14)("fontcolor",function(e){return function(t){return e(this,"font","color",t)}})},function(e,t,n){"use strict";n(14)("fontsize",function(e){return function(t){return e(this,"font","size",t)}})},function(e,t,n){"use strict";n(14)("italics",function(e){return function(){return e(this,"i","","")}})},function(e,t,n){"use strict";n(14)("link",function(e){return function(t){return e(this,"a","href",t)}})},function(e,t,n){"use strict";n(14)("small",function(e){return function(){return e(this,"small","","")}})},function(e,t,n){"use strict";n(14)("strike",function(e){return function(){return e(this,"strike","","")}})},function(e,t,n){"use strict";n(14)("sub",function(e){return function(){return e(this,"sub","","")}})},function(e,t,n){"use strict";n(14)("sup",function(e){return function(){return e(this,"sup","","")}})},function(e,t,n){var r=n(0);r(r.S,"Date",{now:function(){return(new Date).getTime()}})},function(e,t,n){"use strict";var r=n(0),i=n(9),o=n(24);r(r.P+r.F*n(3)(function(){return null!==new Date(NaN).toJSON()||1!==Date.prototype.toJSON.call({toISOString:function(){return 1}})}),"Date",{toJSON:function(e){var t=i(this),n=o(t);return"number"!=typeof n||isFinite(n)?t.toISOString():null}})},function(e,t,n){var r=n(0),i=n(234);r(r.P+r.F*(Date.prototype.toISOString!==i),"Date",{toISOString:i})},function(e,t,n){"use strict";var r=n(3),i=Date.prototype.getTime,o=Date.prototype.toISOString,a=function(e){return e>9?e:"0"+e};e.exports=r(function(){return"0385-07-25T07:06:39.999Z"!=o.call(new Date(-5e13-1))})||!r(function(){o.call(new Date(NaN))})?function(){if(!isFinite(i.call(this)))throw RangeError("Invalid time value");var e=this,t=e.getUTCFullYear(),n=e.getUTCMilliseconds(),r=t<0?"-":t>9999?"+":"";return r+("00000"+Math.abs(t)).slice(r?-6:-4)+"-"+a(e.getUTCMonth()+1)+"-"+a(e.getUTCDate())+"T"+a(e.getUTCHours())+":"+a(e.getUTCMinutes())+":"+a(e.getUTCSeconds())+"."+(n>99?n:"0"+a(n))+"Z"}:o},function(e,t,n){var r=Date.prototype,i=r.toString,o=r.getTime;new Date(NaN)+""!="Invalid Date"&&n(13)(r,"toString",function(){var e=o.call(this);return e==e?i.call(this):"Invalid Date"})},function(e,t,n){var r=n(5)("toPrimitive"),i=Date.prototype;r in i||n(12)(i,r,n(237))},function(e,t,n){"use strict";var r=n(1),i=n(24);e.exports=function(e){if("string"!==e&&"number"!==e&&"default"!==e)throw TypeError("Incorrect hint");return i(r(this),"number"!=e)}},function(e,t,n){var r=n(0);r(r.S,"Array",{isArray:n(61)})},function(e,t,n){"use strict";var r=n(20),i=n(0),o=n(9),a=n(118),s=n(91),c=n(6),p=n(92),d=n(93);i(i.S+i.F*!n(64)(function(e){Array.from(e)}),"Array",{from:function(e){var t,n,i,u,l=o(e),m="function"==typeof this?this:Array,f=arguments.length,h=f>1?arguments[1]:void 0,g=void 0!==h,y=0,b=d(l);if(g&&(h=r(h,f>2?arguments[2]:void 0,2)),null==b||m==Array&&s(b))for(n=new m(t=c(l.length));t>y;y++)p(n,y,g?h(l[y],y):l[y]);else for(u=b.call(l),n=new m;!(i=u.next()).done;y++)p(n,y,g?a(u,h,[i.value,y],!0):i.value);return n.length=y,n}})},function(e,t,n){"use strict";var r=n(0),i=n(92);r(r.S+r.F*n(3)(function(){function e(){}return!(Array.of.call(e)instanceof e)}),"Array",{of:function(){for(var e=0,t=arguments.length,n=new("function"==typeof this?this:Array)(t);t>e;)i(n,e,arguments[e++]);return n.length=t,n}})},function(e,t,n){"use strict";var r=n(0),i=n(16),o=[].join;r(r.P+r.F*(n(52)!=Object||!n(23)(o)),"Array",{join:function(e){return o.call(i(this),void 0===e?",":e)}})},function(e,t,n){"use strict";var r=n(0),i=n(80),o=n(21),a=n(38),s=n(6),c=[].slice;r(r.P+r.F*n(3)(function(){i&&c.call(i)}),"Array",{slice:function(e,t){var n=s(this.length),r=o(this);if(t=void 0===t?n:t,"Array"==r)return c.call(this,e,t);for(var i=a(e,n),p=a(t,n),d=s(p-i),u=new Array(d),l=0;l<d;l++)u[l]="String"==r?this.charAt(i+l):this[i+l];return u}})},function(e,t,n){"use strict";var r=n(0),i=n(10),o=n(9),a=n(3),s=[].sort,c=[1,2,3];r(r.P+r.F*(a(function(){c.sort(void 0)})||!a(function(){c.sort(null)})||!n(23)(s)),"Array",{sort:function(e){return void 0===e?s.call(o(this)):s.call(o(this),i(e))}})},function(e,t,n){"use strict";var r=n(0),i=n(27)(0),o=n(23)([].forEach,!0);r(r.P+r.F*!o,"Array",{forEach:function(e){return i(this,e,arguments[1])}})},function(e,t,n){var r=n(4),i=n(61),o=n(5)("species");e.exports=function(e){var t;return i(e)&&("function"!=typeof(t=e.constructor)||t!==Array&&!i(t.prototype)||(t=void 0),r(t)&&null===(t=t[o])&&(t=void 0)),void 0===t?Array:t}},function(e,t,n){"use strict";var r=n(0),i=n(27)(1);r(r.P+r.F*!n(23)([].map,!0),"Array",{map:function(e){return i(this,e,arguments[1])}})},function(e,t,n){"use strict";var r=n(0),i=n(27)(2);r(r.P+r.F*!n(23)([].filter,!0),"Array",{filter:function(e){return i(this,e,arguments[1])}})},function(e,t,n){"use strict";var r=n(0),i=n(27)(3);r(r.P+r.F*!n(23)([].some,!0),"Array",{some:function(e){return i(this,e,arguments[1])}})},function(e,t,n){"use strict";var r=n(0),i=n(27)(4);r(r.P+r.F*!n(23)([].every,!0),"Array",{every:function(e){return i(this,e,arguments[1])}})},function(e,t,n){"use strict";var r=n(0),i=n(119);r(r.P+r.F*!n(23)([].reduce,!0),"Array",{reduce:function(e){return i(this,e,arguments.length,arguments[1],!1)}})},function(e,t,n){"use strict";var r=n(0),i=n(119);r(r.P+r.F*!n(23)([].reduceRight,!0),"Array",{reduceRight:function(e){return i(this,e,arguments.length,arguments[1],!0)}})},function(e,t,n){"use strict";var r=n(0),i=n(59)(!1),o=[].indexOf,a=!!o&&1/[1].indexOf(1,-0)<0;r(r.P+r.F*(a||!n(23)(o)),"Array",{indexOf:function(e){return a?o.apply(this,arguments)||0:i(this,e,arguments[1])}})},function(e,t,n){"use strict";var r=n(0),i=n(16),o=n(22),a=n(6),s=[].lastIndexOf,c=!!s&&1/[1].lastIndexOf(1,-0)<0;r(r.P+r.F*(c||!n(23)(s)),"Array",{lastIndexOf:function(e){if(c)return s.apply(this,arguments)||0;var t=i(this),n=a(t.length),r=n-1;for(arguments.length>1&&(r=Math.min(r,o(arguments[1]))),r<0&&(r=n+r);r>=0;r--)if(r in t&&t[r]===e)return r||0;return-1}})},function(e,t,n){var r=n(0);r(r.P,"Array",{copyWithin:n(120)}),n(33)("copyWithin")},function(e,t,n){var r=n(0);r(r.P,"Array",{fill:n(95)}),n(33)("fill")},function(e,t,n){"use strict";var r=n(0),i=n(27)(5),o=!0;"find"in[]&&Array(1).find(function(){o=!1}),r(r.P+r.F*o,"Array",{find:function(e){return i(this,e,arguments.length>1?arguments[1]:void 0)}}),n(33)("find")},function(e,t,n){"use strict";var r=n(0),i=n(27)(6),o="findIndex",a=!0;o in[]&&Array(1)[o](function(){a=!1}),r(r.P+r.F*a,"Array",{findIndex:function(e){return i(this,e,arguments.length>1?arguments[1]:void 0)}}),n(33)(o)},function(e,t,n){n(41)("Array")},function(e,t,n){var r=n(2),i=n(83),o=n(8).f,a=n(40).f,s=n(63),c=n(54),p=r.RegExp,d=p,u=p.prototype,l=/a/g,m=/a/g,f=new p(l)!==l;if(n(7)&&(!f||n(3)(function(){return m[n(5)("match")]=!1,p(l)!=l||p(m)==m||"/a/i"!=p(l,"i")}))){p=function(e,t){var n=this instanceof p,r=s(e),o=void 0===t;return!n&&r&&e.constructor===p&&o?e:i(f?new d(r&&!o?e.source:e,t):d((r=e instanceof p)?e.source:e,r&&o?c.call(e):t),n?this:u,p)};for(var h=function(e){e in p||o(p,e,{configurable:!0,get:function(){return d[e]},set:function(t){d[e]=t}})},g=a(d),y=0;g.length>y;)h(g[y++]);u.constructor=p,p.prototype=u,n(13)(r,"RegExp",p)}n(41)("RegExp")},function(e,t,n){"use strict";n(123);var r=n(1),i=n(54),o=n(7),a=/./.toString,s=function(e){n(13)(RegExp.prototype,"toString",e,!0)};n(3)(function(){return"/a/b"!=a.call({source:"a",flags:"b"})})?s(function(){var e=r(this);return"/".concat(e.source,"/","flags"in e?e.flags:!o&&e instanceof RegExp?i.call(e):void 0)}):"toString"!=a.name&&s(function(){return a.call(this)})},function(e,t,n){"use strict";var r=n(1),i=n(6),o=n(98),a=n(65);n(66)("match",1,function(e,t,n,s){return[function(n){var r=e(this),i=null==n?void 0:n[t];return void 0!==i?i.call(n,r):new RegExp(n)[t](String(r))},function(e){var t=s(n,e,this);if(t.done)return t.value;var c=r(e),p=String(this);if(!c.global)return a(c,p);var d=c.unicode;c.lastIndex=0;for(var u,l=[],m=0;null!==(u=a(c,p));){var f=String(u[0]);l[m]=f,""===f&&(c.lastIndex=o(p,i(c.lastIndex),d)),m++}return 0===m?null:l}]})},function(e,t,n){"use strict";var r=n(1),i=n(9),o=n(6),a=n(22),s=n(98),c=n(65),p=Math.max,d=Math.min,u=Math.floor,l=/\$([$&`']|\d\d?|<[^>]*>)/g,m=/\$([$&`']|\d\d?)/g;n(66)("replace",2,function(e,t,n,f){return[function(r,i){var o=e(this),a=null==r?void 0:r[t];return void 0!==a?a.call(r,o,i):n.call(String(o),r,i)},function(e,t){var i=f(n,e,this,t);if(i.done)return i.value;var u=r(e),l=String(this),m="function"==typeof t;m||(t=String(t));var g=u.global;if(g){var y=u.unicode;u.lastIndex=0}for(var b=[];;){var v=c(u,l);if(null===v)break;if(b.push(v),!g)break;""===String(v[0])&&(u.lastIndex=s(l,o(u.lastIndex),y))}for(var w,S="",x=0,I=0;I<b.length;I++){v=b[I];for(var T=String(v[0]),R=p(d(a(v.index),l.length),0),k=[],C=1;C<v.length;C++)k.push(void 0===(w=v[C])?w:String(w));var O=v.groups;if(m){var $=[T].concat(k,R,l);void 0!==O&&$.push(O);var E=String(t.apply(void 0,$))}else E=h(T,l,R,k,O,t);R>=x&&(S+=l.slice(x,R)+E,x=R+T.length)}return S+l.slice(x)}];function h(e,t,r,o,a,s){var c=r+e.length,p=o.length,d=m;return void 0!==a&&(a=i(a),d=l),n.call(s,d,function(n,i){var s;switch(i.charAt(0)){case"$":return"$";case"&":return e;case"`":return t.slice(0,r);case"'":return t.slice(c);case"<":s=a[i.slice(1,-1)];break;default:var d=+i;if(0===d)return n;if(d>p){var l=u(d/10);return 0===l?n:l<=p?void 0===o[l-1]?i.charAt(1):o[l-1]+i.charAt(1):n}s=o[d-1]}return void 0===s?"":s})}})},function(e,t,n){"use strict";var r=n(1),i=n(109),o=n(65);n(66)("search",1,function(e,t,n,a){return[function(n){var r=e(this),i=null==n?void 0:n[t];return void 0!==i?i.call(n,r):new RegExp(n)[t](String(r))},function(e){var t=a(n,e,this);if(t.done)return t.value;var s=r(e),c=String(this),p=s.lastIndex;i(p,0)||(s.lastIndex=0);var d=o(s,c);return i(s.lastIndex,p)||(s.lastIndex=p),null===d?-1:d.index}]})},function(e,t,n){"use strict";var r=n(63),i=n(1),o=n(55),a=n(98),s=n(6),c=n(65),p=n(97),d=n(3),u=Math.min,l=[].push,m=!d(function(){RegExp(4294967295,"y")});n(66)("split",2,function(e,t,n,d){var f;return f="c"=="abbc".split(/(b)*/)[1]||4!="test".split(/(?:)/,-1).length||2!="ab".split(/(?:ab)*/).length||4!=".".split(/(.?)(.?)/).length||".".split(/()()/).length>1||"".split(/.?/).length?function(e,t){var i=String(this);if(void 0===e&&0===t)return[];if(!r(e))return n.call(i,e,t);for(var o,a,s,c=[],d=(e.ignoreCase?"i":"")+(e.multiline?"m":"")+(e.unicode?"u":"")+(e.sticky?"y":""),u=0,m=void 0===t?4294967295:t>>>0,f=new RegExp(e.source,d+"g");(o=p.call(f,i))&&!((a=f.lastIndex)>u&&(c.push(i.slice(u,o.index)),o.length>1&&o.index<i.length&&l.apply(c,o.slice(1)),s=o[0].length,u=a,c.length>=m));)f.lastIndex===o.index&&f.lastIndex++;return u===i.length?!s&&f.test("")||c.push(""):c.push(i.slice(u)),c.length>m?c.slice(0,m):c}:"0".split(void 0,0).length?function(e,t){return void 0===e&&0===t?[]:n.call(this,e,t)}:n,[function(n,r){var i=e(this),o=null==n?void 0:n[t];return void 0!==o?o.call(n,i,r):f.call(String(i),n,r)},function(e,t){var r=d(f,e,this,t,f!==n);if(r.done)return r.value;var p=i(e),l=String(this),h=o(p,RegExp),g=p.unicode,y=(p.ignoreCase?"i":"")+(p.multiline?"m":"")+(p.unicode?"u":"")+(m?"y":"g"),b=new h(m?p:"^(?:"+p.source+")",y),v=void 0===t?4294967295:t>>>0;if(0===v)return[];if(0===l.length)return null===c(b,l)?[l]:[];for(var w=0,S=0,x=[];S<l.length;){b.lastIndex=m?S:0;var I,T=c(b,m?l:l.slice(S));if(null===T||(I=u(s(b.lastIndex+(m?0:S)),l.length))===w)S=a(l,S,g);else{if(x.push(l.slice(w,S)),x.length===v)return x;for(var R=1;R<=T.length-1;R++)if(x.push(T[R]),x.length===v)return x;S=w=I}}return x.push(l.slice(w)),x}]})},function(e,t,n){"use strict";var r,i,o,a,s=n(31),c=n(2),p=n(20),d=n(47),u=n(0),l=n(4),m=n(10),f=n(42),h=n(43),g=n(55),y=n(99).set,b=n(100)(),v=n(101),w=n(124),S=n(67),x=n(125),I=c.TypeError,T=c.process,R=T&&T.versions,k=R&&R.v8||"",C=c.Promise,O="process"==d(T),$=function(){},E=i=v.f,D=!!function(){try{var e=C.resolve(1),t=(e.constructor={})[n(5)("species")]=function(e){e($,$)};return(O||"function"==typeof PromiseRejectionEvent)&&e.then($)instanceof t&&0!==k.indexOf("6.6")&&-1===S.indexOf("Chrome/66")}catch(e){}}(),j=function(e){var t;return!(!l(e)||"function"!=typeof(t=e.then))&&t},P=function(e,t){if(!e._n){e._n=!0;var n=e._c;b(function(){for(var r=e._v,i=1==e._s,o=0,a=function(t){var n,o,a,s=i?t.ok:t.fail,c=t.resolve,p=t.reject,d=t.domain;try{s?(i||(2==e._h&&N(e),e._h=1),!0===s?n=r:(d&&d.enter(),n=s(r),d&&(d.exit(),a=!0)),n===t.promise?p(I("Promise-chain cycle")):(o=j(n))?o.call(n,c,p):c(n)):p(r)}catch(e){d&&!a&&d.exit(),p(e)}};n.length>o;)a(n[o++]);e._c=[],e._n=!1,t&&!e._h&&A(e)})}},A=function(e){y.call(c,function(){var t,n,r,i=e._v,o=M(e);if(o&&(t=w(function(){O?T.emit("unhandledRejection",i,e):(n=c.onunhandledrejection)?n({promise:e,reason:i}):(r=c.console)&&r.error&&r.error("Unhandled promise rejection",i)}),e._h=O||M(e)?2:1),e._a=void 0,o&&t.e)throw t.v})},M=function(e){return 1!==e._h&&0===(e._a||e._c).length},N=function(e){y.call(c,function(){var t;O?T.emit("rejectionHandled",e):(t=c.onrejectionhandled)&&t({promise:e,reason:e._v})})},_=function(e){var t=this;t._d||(t._d=!0,(t=t._w||t)._v=e,t._s=2,t._a||(t._a=t._c.slice()),P(t,!0))},L=function(e){var t,n=this;if(!n._d){n._d=!0,n=n._w||n;try{if(n===e)throw I("Promise can't be resolved itself");(t=j(e))?b(function(){var r={_w:n,_d:!1};try{t.call(e,p(L,r,1),p(_,r,1))}catch(e){_.call(r,e)}}):(n._v=e,n._s=1,P(n,!1))}catch(e){_.call({_w:n,_d:!1},e)}}};D||(C=function(e){f(this,C,"Promise","_h"),m(e),r.call(this);try{e(p(L,this,1),p(_,this,1))}catch(e){_.call(this,e)}},(r=function(e){this._c=[],this._a=void 0,this._s=0,this._d=!1,this._v=void 0,this._h=0,this._n=!1}).prototype=n(44)(C.prototype,{then:function(e,t){var n=E(g(this,C));return n.ok="function"!=typeof e||e,n.fail="function"==typeof t&&t,n.domain=O?T.domain:void 0,this._c.push(n),this._a&&this._a.push(n),this._s&&P(this,!1),n.promise},catch:function(e){return this.then(void 0,e)}}),o=function(){var e=new r;this.promise=e,this.resolve=p(L,e,1),this.reject=p(_,e,1)},v.f=E=function(e){return e===C||e===a?new o(e):i(e)}),u(u.G+u.W+u.F*!D,{Promise:C}),n(46)(C,"Promise"),n(41)("Promise"),a=n(19).Promise,u(u.S+u.F*!D,"Promise",{reject:function(e){var t=E(this);return(0,t.reject)(e),t.promise}}),u(u.S+u.F*(s||!D),"Promise",{resolve:function(e){return x(s&&this===a?C:this,e)}}),u(u.S+u.F*!(D&&n(64)(function(e){C.all(e).catch($)})),"Promise",{all:function(e){var t=this,n=E(t),r=n.resolve,i=n.reject,o=w(function(){var n=[],o=0,a=1;h(e,!1,function(e){var s=o++,c=!1;n.push(void 0),a++,t.resolve(e).then(function(e){c||(c=!0,n[s]=e,--a||r(n))},i)}),--a||r(n)});return o.e&&i(o.v),n.promise},race:function(e){var t=this,n=E(t),r=n.reject,i=w(function(){h(e,!1,function(e){t.resolve(e).then(n.resolve,r)})});return i.e&&r(i.v),n.promise}})},function(e,t,n){"use strict";var r=n(130),i=n(45);n(68)("WeakSet",function(e){return function(){return e(this,arguments.length>0?arguments[0]:void 0)}},{add:function(e){return r.def(i(this,"WeakSet"),e,!0)}},r,!1,!0)},function(e,t,n){"use strict";var r=n(0),i=n(69),o=n(102),a=n(1),s=n(38),c=n(6),p=n(4),d=n(2).ArrayBuffer,u=n(55),l=o.ArrayBuffer,m=o.DataView,f=i.ABV&&d.isView,h=l.prototype.slice,g=i.VIEW;r(r.G+r.W+r.F*(d!==l),{ArrayBuffer:l}),r(r.S+r.F*!i.CONSTR,"ArrayBuffer",{isView:function(e){return f&&f(e)||p(e)&&g in e}}),r(r.P+r.U+r.F*n(3)(function(){return!new l(2).slice(1,void 0).byteLength}),"ArrayBuffer",{slice:function(e,t){if(void 0!==h&&void 0===t)return h.call(a(this),e);for(var n=a(this).byteLength,r=s(e,n),i=s(void 0===t?n:t,n),o=new(u(this,l))(c(i-r)),p=new m(this),d=new m(o),f=0;r<i;)d.setUint8(f++,p.getUint8(r++));return o}}),n(41)("ArrayBuffer")},function(e,t,n){var r=n(0);r(r.G+r.W+r.F*!n(69).ABV,{DataView:n(102).DataView})},function(e,t,n){n(28)("Int8",1,function(e){return function(t,n,r){return e(this,t,n,r)}})},function(e,t,n){n(28)("Uint8",1,function(e){return function(t,n,r){return e(this,t,n,r)}})},function(e,t,n){n(28)("Uint8",1,function(e){return function(t,n,r){return e(this,t,n,r)}},!0)},function(e,t,n){n(28)("Int16",2,function(e){return function(t,n,r){return e(this,t,n,r)}})},function(e,t,n){n(28)("Uint16",2,function(e){return function(t,n,r){return e(this,t,n,r)}})},function(e,t,n){n(28)("Int32",4,function(e){return function(t,n,r){return e(this,t,n,r)}})},function(e,t,n){n(28)("Uint32",4,function(e){return function(t,n,r){return e(this,t,n,r)}})},function(e,t,n){n(28)("Float32",4,function(e){return function(t,n,r){return e(this,t,n,r)}})},function(e,t,n){n(28)("Float64",8,function(e){return function(t,n,r){return e(this,t,n,r)}})},function(e,t,n){var r=n(0),i=n(10),o=n(1),a=(n(2).Reflect||{}).apply,s=Function.apply;r(r.S+r.F*!n(3)(function(){a(function(){})}),"Reflect",{apply:function(e,t,n){var r=i(e),c=o(n);return a?a(r,t,c):s.call(r,t,c)}})},function(e,t,n){var r=n(0),i=n(39),o=n(10),a=n(1),s=n(4),c=n(3),p=n(110),d=(n(2).Reflect||{}).construct,u=c(function(){function e(){}return!(d(function(){},[],e)instanceof e)}),l=!c(function(){d(function(){})});r(r.S+r.F*(u||l),"Reflect",{construct:function(e,t){o(e),a(t);var n=arguments.length<3?e:o(arguments[2]);if(l&&!u)return d(e,t,n);if(e==n){switch(t.length){case 0:return new e;case 1:return new e(t[0]);case 2:return new e(t[0],t[1]);case 3:return new e(t[0],t[1],t[2]);case 4:return new e(t[0],t[1],t[2],t[3])}var r=[null];return r.push.apply(r,t),new(p.apply(e,r))}var c=n.prototype,m=i(s(c)?c:Object.prototype),f=Function.apply.call(e,m,t);return s(f)?f:m}})},function(e,t,n){var r=n(8),i=n(0),o=n(1),a=n(24);i(i.S+i.F*n(3)(function(){Reflect.defineProperty(r.f({},1,{value:1}),1,{value:2})}),"Reflect",{defineProperty:function(e,t,n){o(e),t=a(t,!0),o(n);try{return r.f(e,t,n),!0}catch(e){return!1}}})},function(e,t,n){var r=n(0),i=n(17).f,o=n(1);r(r.S,"Reflect",{deleteProperty:function(e,t){var n=i(o(e),t);return!(n&&!n.configurable)&&delete e[t]}})},function(e,t,n){"use strict";var r=n(0),i=n(1),o=function(e){this._t=i(e),this._i=0;var t,n=this._k=[];for(t in e)n.push(t)};n(88)(o,"Object",function(){var e,t=this._k;do{if(this._i>=t.length)return{value:void 0,done:!0}}while(!((e=t[this._i++])in this._t));return{value:e,done:!1}}),r(r.S,"Reflect",{enumerate:function(e){return new o(e)}})},function(e,t,n){var r=n(17),i=n(18),o=n(15),a=n(0),s=n(4),c=n(1);a(a.S,"Reflect",{get:function e(t,n){var a,p,d=arguments.length<3?t:arguments[2];return c(t)===d?t[n]:(a=r.f(t,n))?o(a,"value")?a.value:void 0!==a.get?a.get.call(d):void 0:s(p=i(t))?e(p,n,d):void 0}})},function(e,t,n){var r=n(17),i=n(0),o=n(1);i(i.S,"Reflect",{getOwnPropertyDescriptor:function(e,t){return r.f(o(e),t)}})},function(e,t,n){var r=n(0),i=n(18),o=n(1);r(r.S,"Reflect",{getPrototypeOf:function(e){return i(o(e))}})},function(e,t,n){var r=n(0);r(r.S,"Reflect",{has:function(e,t){return t in e}})},function(e,t,n){var r=n(0),i=n(1),o=Object.isExtensible;r(r.S,"Reflect",{isExtensible:function(e){return i(e),!o||o(e)}})},function(e,t,n){var r=n(0);r(r.S,"Reflect",{ownKeys:n(132)})},function(e,t,n){var r=n(0),i=n(1),o=Object.preventExtensions;r(r.S,"Reflect",{preventExtensions:function(e){i(e);try{return o&&o(e),!0}catch(e){return!1}}})},function(e,t,n){var r=n(8),i=n(17),o=n(18),a=n(15),s=n(0),c=n(35),p=n(1),d=n(4);s(s.S,"Reflect",{set:function e(t,n,s){var u,l,m=arguments.length<4?t:arguments[3],f=i.f(p(t),n);if(!f){if(d(l=o(t)))return e(l,n,s,m);f=c(0)}if(a(f,"value")){if(!1===f.writable||!d(m))return!1;if(u=i.f(m,n)){if(u.get||u.set||!1===u.writable)return!1;u.value=s,r.f(m,n,u)}else r.f(m,n,c(0,s));return!0}return void 0!==f.set&&(f.set.call(m,s),!0)}})},function(e,t,n){var r=n(0),i=n(81);i&&r(r.S,"Reflect",{setPrototypeOf:function(e,t){i.check(e,t);try{return i.set(e,t),!0}catch(e){return!1}}})},function(e,t,n){"use strict";var r=n(0),i=n(59)(!0);r(r.P,"Array",{includes:function(e){return i(this,e,arguments.length>1?arguments[1]:void 0)}}),n(33)("includes")},function(e,t,n){"use strict";var r=n(0),i=n(133),o=n(9),a=n(6),s=n(10),c=n(94);r(r.P,"Array",{flatMap:function(e){var t,n,r=o(this);return s(e),t=a(r.length),n=c(r,0),i(n,r,r,t,0,1,e,arguments[1]),n}}),n(33)("flatMap")},function(e,t,n){"use strict";var r=n(0),i=n(133),o=n(9),a=n(6),s=n(22),c=n(94);r(r.P,"Array",{flatten:function(){var e=arguments[0],t=o(this),n=a(t.length),r=c(t,0);return i(r,t,t,n,0,void 0===e?1:s(e)),r}}),n(33)("flatten")},function(e,t,n){"use strict";var r=n(0),i=n(62)(!0);r(r.P,"String",{at:function(e){return i(this,e)}})},function(e,t,n){"use strict";var r=n(0),i=n(134),o=n(67),a=/Version\/10\.\d+(\.\d+)?( Mobile\/\w+)? Safari\//.test(o);r(r.P+r.F*a,"String",{padStart:function(e){return i(this,e,arguments.length>1?arguments[1]:void 0,!0)}})},function(e,t,n){"use strict";var r=n(0),i=n(134),o=n(67),a=/Version\/10\.\d+(\.\d+)?( Mobile\/\w+)? Safari\//.test(o);r(r.P+r.F*a,"String",{padEnd:function(e){return i(this,e,arguments.length>1?arguments[1]:void 0,!1)}})},function(e,t,n){"use strict";n(48)("trimLeft",function(e){return function(){return e(this,1)}},"trimStart")},function(e,t,n){"use strict";n(48)("trimRight",function(e){return function(){return e(this,2)}},"trimEnd")},function(e,t,n){"use strict";var r=n(0),i=n(25),o=n(6),a=n(63),s=n(54),c=RegExp.prototype,p=function(e,t){this._r=e,this._s=t};n(88)(p,"RegExp String",function(){var e=this._r.exec(this._s);return{value:e,done:null===e}}),r(r.P,"String",{matchAll:function(e){if(i(this),!a(e))throw TypeError(e+" is not a regexp!");var t=String(this),n="flags"in c?String(e.flags):s.call(e),r=new RegExp(e.source,~n.indexOf("g")?n:"g"+n);return r.lastIndex=o(e.lastIndex),new p(r,t)}})},function(e,t,n){n(77)("asyncIterator")},function(e,t,n){n(77)("observable")},function(e,t,n){var r=n(0),i=n(132),o=n(16),a=n(17),s=n(92);r(r.S,"Object",{getOwnPropertyDescriptors:function(e){for(var t,n,r=o(e),c=a.f,p=i(r),d={},u=0;p.length>u;)void 0!==(n=c(r,t=p[u++]))&&s(d,t,n);return d}})},function(e,t,n){var r=n(0),i=n(135)(!1);r(r.S,"Object",{values:function(e){return i(e)}})},function(e,t,n){var r=n(0),i=n(135)(!0);r(r.S,"Object",{entries:function(e){return i(e)}})},function(e,t,n){"use strict";var r=n(0),i=n(9),o=n(10),a=n(8);n(7)&&r(r.P+n(70),"Object",{__defineGetter__:function(e,t){a.f(i(this),e,{get:o(t),enumerable:!0,configurable:!0})}})},function(e,t,n){"use strict";var r=n(0),i=n(9),o=n(10),a=n(8);n(7)&&r(r.P+n(70),"Object",{__defineSetter__:function(e,t){a.f(i(this),e,{set:o(t),enumerable:!0,configurable:!0})}})},function(e,t,n){"use strict";var r=n(0),i=n(9),o=n(24),a=n(18),s=n(17).f;n(7)&&r(r.P+n(70),"Object",{__lookupGetter__:function(e){var t,n=i(this),r=o(e,!0);do{if(t=s(n,r))return t.get}while(n=a(n))}})},function(e,t,n){"use strict";var r=n(0),i=n(9),o=n(24),a=n(18),s=n(17).f;n(7)&&r(r.P+n(70),"Object",{__lookupSetter__:function(e){var t,n=i(this),r=o(e,!0);do{if(t=s(n,r))return t.set}while(n=a(n))}})},function(e,t,n){var r=n(0);r(r.P+r.R,"Map",{toJSON:n(136)("Map")})},function(e,t,n){var r=n(0);r(r.P+r.R,"Set",{toJSON:n(136)("Set")})},function(e,t,n){n(71)("Map")},function(e,t,n){n(71)("Set")},function(e,t,n){n(71)("WeakMap")},function(e,t,n){n(71)("WeakSet")},function(e,t,n){n(72)("Map")},function(e,t,n){n(72)("Set")},function(e,t,n){n(72)("WeakMap")},function(e,t,n){n(72)("WeakSet")},function(e,t,n){var r=n(0);r(r.G,{global:n(2)})},function(e,t,n){var r=n(0);r(r.S,"System",{global:n(2)})},function(e,t,n){var r=n(0),i=n(21);r(r.S,"Error",{isError:function(e){return"Error"===i(e)}})},function(e,t,n){var r=n(0);r(r.S,"Math",{clamp:function(e,t,n){return Math.min(n,Math.max(t,e))}})},function(e,t,n){var r=n(0);r(r.S,"Math",{DEG_PER_RAD:Math.PI/180})},function(e,t,n){var r=n(0),i=180/Math.PI;r(r.S,"Math",{degrees:function(e){return e*i}})},function(e,t,n){var r=n(0),i=n(138),o=n(117);r(r.S,"Math",{fscale:function(e,t,n,r,a){return o(i(e,t,n,r,a))}})},function(e,t,n){var r=n(0);r(r.S,"Math",{iaddh:function(e,t,n,r){var i=e>>>0,o=n>>>0;return(t>>>0)+(r>>>0)+((i&o|(i|o)&~(i+o>>>0))>>>31)|0}})},function(e,t,n){var r=n(0);r(r.S,"Math",{isubh:function(e,t,n,r){var i=e>>>0,o=n>>>0;return(t>>>0)-(r>>>0)-((~i&o|~(i^o)&i-o>>>0)>>>31)|0}})},function(e,t,n){var r=n(0);r(r.S,"Math",{imulh:function(e,t){var n=+e,r=+t,i=65535&n,o=65535&r,a=n>>16,s=r>>16,c=(a*o>>>0)+(i*o>>>16);return a*s+(c>>16)+((i*s>>>0)+(65535&c)>>16)}})},function(e,t,n){var r=n(0);r(r.S,"Math",{RAD_PER_DEG:180/Math.PI})},function(e,t,n){var r=n(0),i=Math.PI/180;r(r.S,"Math",{radians:function(e){return e*i}})},function(e,t,n){var r=n(0);r(r.S,"Math",{scale:n(138)})},function(e,t,n){var r=n(0);r(r.S,"Math",{umulh:function(e,t){var n=+e,r=+t,i=65535&n,o=65535&r,a=n>>>16,s=r>>>16,c=(a*o>>>0)+(i*o>>>16);return a*s+(c>>>16)+((i*s>>>0)+(65535&c)>>>16)}})},function(e,t,n){var r=n(0);r(r.S,"Math",{signbit:function(e){return(e=+e)!=e?e:0==e?1/e==1/0:e>0}})},function(e,t,n){"use strict";var r=n(0),i=n(19),o=n(2),a=n(55),s=n(125);r(r.P+r.R,"Promise",{finally:function(e){var t=a(this,i.Promise||o.Promise),n="function"==typeof e;return this.then(n?function(n){return s(t,e()).then(function(){return n})}:e,n?function(n){return s(t,e()).then(function(){throw n})}:e)}})},function(e,t,n){"use strict";var r=n(0),i=n(101),o=n(124);r(r.S,"Promise",{try:function(e){var t=i.f(this),n=o(e);return(n.e?t.reject:t.resolve)(n.v),t.promise}})},function(e,t,n){var r=n(29),i=n(1),o=r.key,a=r.set;r.exp({defineMetadata:function(e,t,n,r){a(e,t,i(n),o(r))}})},function(e,t,n){var r=n(29),i=n(1),o=r.key,a=r.map,s=r.store;r.exp({deleteMetadata:function(e,t){var n=arguments.length<3?void 0:o(arguments[2]),r=a(i(t),n,!1);if(void 0===r||!r.delete(e))return!1;if(r.size)return!0;var c=s.get(t);return c.delete(n),!!c.size||s.delete(t)}})},function(e,t,n){var r=n(29),i=n(1),o=n(18),a=r.has,s=r.get,c=r.key,p=function(e,t,n){if(a(e,t,n))return s(e,t,n);var r=o(t);return null!==r?p(e,r,n):void 0};r.exp({getMetadata:function(e,t){return p(e,i(t),arguments.length<3?void 0:c(arguments[2]))}})},function(e,t,n){var r=n(128),i=n(137),o=n(29),a=n(1),s=n(18),c=o.keys,p=o.key,d=function(e,t){var n=c(e,t),o=s(e);if(null===o)return n;var a=d(o,t);return a.length?n.length?i(new r(n.concat(a))):a:n};o.exp({getMetadataKeys:function(e){return d(a(e),arguments.length<2?void 0:p(arguments[1]))}})},function(e,t,n){var r=n(29),i=n(1),o=r.get,a=r.key;r.exp({getOwnMetadata:function(e,t){return o(e,i(t),arguments.length<3?void 0:a(arguments[2]))}})},function(e,t,n){var r=n(29),i=n(1),o=r.keys,a=r.key;r.exp({getOwnMetadataKeys:function(e){return o(i(e),arguments.length<2?void 0:a(arguments[1]))}})},function(e,t,n){var r=n(29),i=n(1),o=n(18),a=r.has,s=r.key,c=function(e,t,n){if(a(e,t,n))return!0;var r=o(t);return null!==r&&c(e,r,n)};r.exp({hasMetadata:function(e,t){return c(e,i(t),arguments.length<3?void 0:s(arguments[2]))}})},function(e,t,n){var r=n(29),i=n(1),o=r.has,a=r.key;r.exp({hasOwnMetadata:function(e,t){return o(e,i(t),arguments.length<3?void 0:a(arguments[2]))}})},function(e,t,n){var r=n(29),i=n(1),o=n(10),a=r.key,s=r.set;r.exp({metadata:function(e,t){return function(n,r){s(e,t,(void 0!==r?i:o)(n),a(r))}}})},function(e,t,n){var r=n(0),i=n(100)(),o=n(2).process,a="process"==n(21)(o);r(r.G,{asap:function(e){var t=a&&o.domain;i(t?t.bind(e):e)}})},function(e,t,n){"use strict";var r=n(0),i=n(2),o=n(19),a=n(100)(),s=n(5)("observable"),c=n(10),p=n(1),d=n(42),u=n(44),l=n(12),m=n(43),f=m.RETURN,h=function(e){return null==e?void 0:c(e)},g=function(e){var t=e._c;t&&(e._c=void 0,t())},y=function(e){return void 0===e._o},b=function(e){y(e)||(e._o=void 0,g(e))},v=function(e,t){p(e),this._c=void 0,this._o=e,e=new w(this);try{var n=t(e),r=n;null!=n&&("function"==typeof n.unsubscribe?n=function(){r.unsubscribe()}:c(n),this._c=n)}catch(t){return void e.error(t)}y(this)&&g(this)};v.prototype=u({},{unsubscribe:function(){b(this)}});var w=function(e){this._s=e};w.prototype=u({},{next:function(e){var t=this._s;if(!y(t)){var n=t._o;try{var r=h(n.next);if(r)return r.call(n,e)}catch(e){try{b(t)}finally{throw e}}}},error:function(e){var t=this._s;if(y(t))throw e;var n=t._o;t._o=void 0;try{var r=h(n.error);if(!r)throw e;e=r.call(n,e)}catch(e){try{g(t)}finally{throw e}}return g(t),e},complete:function(e){var t=this._s;if(!y(t)){var n=t._o;t._o=void 0;try{var r=h(n.complete);e=r?r.call(n,e):void 0}catch(e){try{g(t)}finally{throw e}}return g(t),e}}});var S=function(e){d(this,S,"Observable","_f")._f=c(e)};u(S.prototype,{subscribe:function(e){return new v(e,this._f)},forEach:function(e){var t=this;return new(o.Promise||i.Promise)(function(n,r){c(e);var i=t.subscribe({next:function(t){try{return e(t)}catch(e){r(e),i.unsubscribe()}},error:r,complete:n})})}}),u(S,{from:function(e){var t="function"==typeof this?this:S,n=h(p(e)[s]);if(n){var r=p(n.call(e));return r.constructor===t?r:new t(function(e){return r.subscribe(e)})}return new t(function(t){var n=!1;return a(function(){if(!n){try{if(m(e,!1,function(e){if(t.next(e),n)return f})===f)return}catch(e){if(n)throw e;return void t.error(e)}t.complete()}}),function(){n=!0}})},of:function(){for(var e=0,t=arguments.length,n=new Array(t);e<t;)n[e]=arguments[e++];return new("function"==typeof this?this:S)(function(e){var t=!1;return a(function(){if(!t){for(var r=0;r<n.length;++r)if(e.next(n[r]),t)return;e.complete()}}),function(){t=!0}})}}),l(S.prototype,s,function(){return this}),r(r.G,{Observable:S}),n(41)("Observable")},function(e,t,n){var r=n(2),i=n(0),o=n(67),a=[].slice,s=/MSIE .\./.test(o),c=function(e){return function(t,n){var r=arguments.length>2,i=!!r&&a.call(arguments,2);return e(r?function(){("function"==typeof t?t:Function(t)).apply(this,i)}:t,n)}};i(i.G+i.B+i.F*s,{setTimeout:c(r.setTimeout),setInterval:c(r.setInterval)})},function(e,t,n){var r=n(0),i=n(99);r(r.G+r.B,{setImmediate:i.set,clearImmediate:i.clear})},function(e,t,n){for(var r=n(96),i=n(37),o=n(13),a=n(2),s=n(12),c=n(49),p=n(5),d=p("iterator"),u=p("toStringTag"),l=c.Array,m={CSSRuleList:!0,CSSStyleDeclaration:!1,CSSValueList:!1,ClientRectList:!1,DOMRectList:!1,DOMStringList:!1,DOMTokenList:!0,DataTransferItemList:!1,FileList:!1,HTMLAllCollection:!1,HTMLCollection:!1,HTMLFormElement:!1,HTMLSelectElement:!1,MediaList:!0,MimeTypeArray:!1,NamedNodeMap:!1,NodeList:!0,PaintRequestList:!1,Plugin:!1,PluginArray:!1,SVGLengthList:!1,SVGNumberList:!1,SVGPathSegList:!1,SVGPointList:!1,SVGStringList:!1,SVGTransformList:!1,SourceBufferList:!1,StyleSheetList:!0,TextTrackCueList:!1,TextTrackList:!1,TouchList:!1},f=i(m),h=0;h<f.length;h++){var g,y=f[h],b=m[y],v=a[y],w=v&&v.prototype;if(w&&(w[d]||s(w,d,l),w[u]||s(w,u,y),c[y]=l,b))for(g in r)w[g]||o(w,g,r[g],!0)}},function(e,t,n){(function(t){!function(t){"use strict";var n,r=Object.prototype,i=r.hasOwnProperty,o="function"==typeof Symbol?Symbol:{},a=o.iterator||"@@iterator",s=o.asyncIterator||"@@asyncIterator",c=o.toStringTag||"@@toStringTag",p="object"==typeof e,d=t.regeneratorRuntime;if(d)p&&(e.exports=d);else{(d=t.regeneratorRuntime=p?e.exports:{}).wrap=w;var u="suspendedStart",l="suspendedYield",m="executing",f="completed",h={},g={};g[a]=function(){return this};var y=Object.getPrototypeOf,b=y&&y(y(D([])));b&&b!==r&&i.call(b,a)&&(g=b);var v=T.prototype=x.prototype=Object.create(g);I.prototype=v.constructor=T,T.constructor=I,T[c]=I.displayName="GeneratorFunction",d.isGeneratorFunction=function(e){var t="function"==typeof e&&e.constructor;return!!t&&(t===I||"GeneratorFunction"===(t.displayName||t.name))},d.mark=function(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,T):(e.__proto__=T,c in e||(e[c]="GeneratorFunction")),e.prototype=Object.create(v),e},d.awrap=function(e){return{__await:e}},R(k.prototype),k.prototype[s]=function(){return this},d.AsyncIterator=k,d.async=function(e,t,n,r){var i=new k(w(e,t,n,r));return d.isGeneratorFunction(t)?i:i.next().then(function(e){return e.done?e.value:i.next()})},R(v),v[c]="Generator",v[a]=function(){return this},v.toString=function(){return"[object Generator]"},d.keys=function(e){var t=[];for(var n in e)t.push(n);return t.reverse(),function n(){for(;t.length;){var r=t.pop();if(r in e)return n.value=r,n.done=!1,n}return n.done=!0,n}},d.values=D,E.prototype={constructor:E,reset:function(e){if(this.prev=0,this.next=0,this.sent=this._sent=n,this.done=!1,this.delegate=null,this.method="next",this.arg=n,this.tryEntries.forEach($),!e)for(var t in this)"t"===t.charAt(0)&&i.call(this,t)&&!isNaN(+t.slice(1))&&(this[t]=n)},stop:function(){this.done=!0;var e=this.tryEntries[0].completion;if("throw"===e.type)throw e.arg;return this.rval},dispatchException:function(e){if(this.done)throw e;var t=this;function r(r,i){return s.type="throw",s.arg=e,t.next=r,i&&(t.method="next",t.arg=n),!!i}for(var o=this.tryEntries.length-1;o>=0;--o){var a=this.tryEntries[o],s=a.completion;if("root"===a.tryLoc)return r("end");if(a.tryLoc<=this.prev){var c=i.call(a,"catchLoc"),p=i.call(a,"finallyLoc");if(c&&p){if(this.prev<a.catchLoc)return r(a.catchLoc,!0);if(this.prev<a.finallyLoc)return r(a.finallyLoc)}else if(c){if(this.prev<a.catchLoc)return r(a.catchLoc,!0)}else{if(!p)throw new Error("try statement without catch or finally");if(this.prev<a.finallyLoc)return r(a.finallyLoc)}}}},abrupt:function(e,t){for(var n=this.tryEntries.length-1;n>=0;--n){var r=this.tryEntries[n];if(r.tryLoc<=this.prev&&i.call(r,"finallyLoc")&&this.prev<r.finallyLoc){var o=r;break}}o&&("break"===e||"continue"===e)&&o.tryLoc<=t&&t<=o.finallyLoc&&(o=null);var a=o?o.completion:{};return a.type=e,a.arg=t,o?(this.method="next",this.next=o.finallyLoc,h):this.complete(a)},complete:function(e,t){if("throw"===e.type)throw e.arg;return"break"===e.type||"continue"===e.type?this.next=e.arg:"return"===e.type?(this.rval=this.arg=e.arg,this.method="return",this.next="end"):"normal"===e.type&&t&&(this.next=t),h},finish:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var n=this.tryEntries[t];if(n.finallyLoc===e)return this.complete(n.completion,n.afterLoc),$(n),h}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var n=this.tryEntries[t];if(n.tryLoc===e){var r=n.completion;if("throw"===r.type){var i=r.arg;$(n)}return i}}throw new Error("illegal catch attempt")},delegateYield:function(e,t,r){return this.delegate={iterator:D(e),resultName:t,nextLoc:r},"next"===this.method&&(this.arg=n),h}}}function w(e,t,n,r){var i=t&&t.prototype instanceof x?t:x,o=Object.create(i.prototype),a=new E(r||[]);return o._invoke=function(e,t,n){var r=u;return function(i,o){if(r===m)throw new Error("Generator is already running");if(r===f){if("throw"===i)throw o;return j()}for(n.method=i,n.arg=o;;){var a=n.delegate;if(a){var s=C(a,n);if(s){if(s===h)continue;return s}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if(r===u)throw r=f,n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);r=m;var c=S(e,t,n);if("normal"===c.type){if(r=n.done?f:l,c.arg===h)continue;return{value:c.arg,done:n.done}}"throw"===c.type&&(r=f,n.method="throw",n.arg=c.arg)}}}(e,n,a),o}function S(e,t,n){try{return{type:"normal",arg:e.call(t,n)}}catch(e){return{type:"throw",arg:e}}}function x(){}function I(){}function T(){}function R(e){["next","throw","return"].forEach(function(t){e[t]=function(e){return this._invoke(t,e)}})}function k(e){function n(t,r,o,a){var s=S(e[t],e,r);if("throw"!==s.type){var c=s.arg,p=c.value;return p&&"object"==typeof p&&i.call(p,"__await")?Promise.resolve(p.__await).then(function(e){n("next",e,o,a)},function(e){n("throw",e,o,a)}):Promise.resolve(p).then(function(e){c.value=e,o(c)},a)}a(s.arg)}var r;"object"==typeof t.process&&t.process.domain&&(n=t.process.domain.bind(n)),this._invoke=function(e,t){function i(){return new Promise(function(r,i){n(e,t,r,i)})}return r=r?r.then(i,i):i()}}function C(e,t){var r=e.iterator[t.method];if(r===n){if(t.delegate=null,"throw"===t.method){if(e.iterator.return&&(t.method="return",t.arg=n,C(e,t),"throw"===t.method))return h;t.method="throw",t.arg=new TypeError("The iterator does not provide a 'throw' method")}return h}var i=S(r,e.iterator,t.arg);if("throw"===i.type)return t.method="throw",t.arg=i.arg,t.delegate=null,h;var o=i.arg;return o?o.done?(t[e.resultName]=o.value,t.next=e.nextLoc,"return"!==t.method&&(t.method="next",t.arg=n),t.delegate=null,h):o:(t.method="throw",t.arg=new TypeError("iterator result is not an object"),t.delegate=null,h)}function O(e){var t={tryLoc:e[0]};1 in e&&(t.catchLoc=e[1]),2 in e&&(t.finallyLoc=e[2],t.afterLoc=e[3]),this.tryEntries.push(t)}function $(e){var t=e.completion||{};t.type="normal",delete t.arg,e.completion=t}function E(e){this.tryEntries=[{tryLoc:"root"}],e.forEach(O,this),this.reset(!0)}function D(e){if(e){var t=e[a];if(t)return t.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length)){var r=-1,o=function t(){for(;++r<e.length;)if(i.call(e,r))return t.value=e[r],t.done=!1,t;return t.value=n,t.done=!0,t};return o.next=o}}return{next:j}}function j(){return{value:n,done:!0}}}("object"==typeof t?t:"object"==typeof window?window:"object"==typeof self?self:this)}).call(this,n(11))},function(e,t,n){n(353),e.exports=n(19).RegExp.escape},function(e,t,n){var r=n(0),i=n(354)(/[\\^$*+?.()|[\]{}]/g,"\\$&");r(r.S,"RegExp",{escape:function(e){return i(e)}})},function(e,t){e.exports=function(e,t){var n=t===Object(t)?function(e){return t[e]}:t;return function(t){return String(t).replace(e,n)}}},function(e,t,n){"use strict";(function(t){const r=n(56),i=n(139),o=n(378);e.exports=function(e,n){"function"==typeof e&&(n=e,e=void 0);const i=new r;return"function"==typeof n?(t.nextTick(()=>{new o(e,i)}),i.once("connect",n)):new Promise((t,n)=>{i.once("connect",t),i.once("error",n),new o(e,i)})},e.exports.Protocol=i.Protocol,e.exports.List=i.List,e.exports.New=i.New,e.exports.Activate=i.Activate,e.exports.Close=i.Close,e.exports.Version=i.Version}).call(this,n(30))},function(e,t,n){(function(t,r,i){var o=n(142),a=n(34),s=n(143),c=n(144),p=n(366),d=s.IncomingMessage,u=s.readyStates;var l=e.exports=function(e){var n,r=this;c.Writable.call(r),r._opts=e,r._body=[],r._headers={},e.auth&&r.setHeader("Authorization","Basic "+new t(e.auth).toString("base64")),Object.keys(e.headers).forEach(function(t){r.setHeader(t,e.headers[t])});var i=!0;if("disable-fetch"===e.mode||"requestTimeout"in e&&!o.abortController)i=!1,n=!0;else if("prefer-streaming"===e.mode)n=!1;else if("allow-wrong-content-type"===e.mode)n=!o.overrideMimeType;else{if(e.mode&&"default"!==e.mode&&"prefer-fast"!==e.mode)throw new Error("Invalid value for opts.mode");n=!0}r._mode=function(e,t){return o.fetch&&t?"fetch":o.mozchunkedarraybuffer?"moz-chunked-arraybuffer":o.msstream?"ms-stream":o.arraybuffer&&e?"arraybuffer":o.vbArray&&e?"text:vbarray":"text"}(n,i),r._fetchTimer=null,r.on("finish",function(){r._onFinish()})};a(l,c.Writable),l.prototype.setHeader=function(e,t){var n=e.toLowerCase();-1===m.indexOf(n)&&(this._headers[n]={name:e,value:t})},l.prototype.getHeader=function(e){var t=this._headers[e.toLowerCase()];return t?t.value:null},l.prototype.removeHeader=function(e){delete this._headers[e.toLowerCase()]},l.prototype._onFinish=function(){var e=this;if(!e._destroyed){var n=e._opts,a=e._headers,s=null;"GET"!==n.method&&"HEAD"!==n.method&&(s=o.arraybuffer?p(t.concat(e._body)):o.blobConstructor?new r.Blob(e._body.map(function(e){return p(e)}),{type:(a["content-type"]||{}).value||""}):t.concat(e._body).toString());var c=[];if(Object.keys(a).forEach(function(e){var t=a[e].name,n=a[e].value;Array.isArray(n)?n.forEach(function(e){c.push([t,e])}):c.push([t,n])}),"fetch"===e._mode){var d=null;if(o.abortController){var l=new AbortController;d=l.signal,e._fetchAbortController=l,"requestTimeout"in n&&0!==n.requestTimeout&&(e._fetchTimer=r.setTimeout(function(){e.emit("requestTimeout"),e._fetchAbortController&&e._fetchAbortController.abort()},n.requestTimeout))}r.fetch(e._opts.url,{method:e._opts.method,headers:c,body:s||void 0,mode:"cors",credentials:n.withCredentials?"include":"same-origin",signal:d}).then(function(t){e._fetchResponse=t,e._connect()},function(t){r.clearTimeout(e._fetchTimer),e._destroyed||e.emit("error",t)})}else{var m=e._xhr=new r.XMLHttpRequest;try{m.open(e._opts.method,e._opts.url,!0)}catch(t){return void i.nextTick(function(){e.emit("error",t)})}"responseType"in m&&(m.responseType=e._mode.split(":")[0]),"withCredentials"in m&&(m.withCredentials=!!n.withCredentials),"text"===e._mode&&"overrideMimeType"in m&&m.overrideMimeType("text/plain; charset=x-user-defined"),"requestTimeout"in n&&(m.timeout=n.requestTimeout,m.ontimeout=function(){e.emit("requestTimeout")}),c.forEach(function(e){m.setRequestHeader(e[0],e[1])}),e._response=null,m.onreadystatechange=function(){switch(m.readyState){case u.LOADING:case u.DONE:e._onXHRProgress()}},"moz-chunked-arraybuffer"===e._mode&&(m.onprogress=function(){e._onXHRProgress()}),m.onerror=function(){e._destroyed||e.emit("error",new Error("XHR error"))};try{m.send(s)}catch(t){return void i.nextTick(function(){e.emit("error",t)})}}}},l.prototype._onXHRProgress=function(){(function(e){try{var t=e.status;return null!==t&&0!==t}catch(e){return!1}})(this._xhr)&&!this._destroyed&&(this._response||this._connect(),this._response._onXHRProgress())},l.prototype._connect=function(){var e=this;e._destroyed||(e._response=new d(e._xhr,e._fetchResponse,e._mode,e._fetchTimer),e._response.on("error",function(t){e.emit("error",t)}),e.emit("response",e._response))},l.prototype._write=function(e,t,n){this._body.push(e),n()},l.prototype.abort=l.prototype.destroy=function(){this._destroyed=!0,r.clearTimeout(this._fetchTimer),this._response&&(this._response._destroyed=!0),this._xhr?this._xhr.abort():this._fetchAbortController&&this._fetchAbortController.abort()},l.prototype.end=function(e,t,n){"function"==typeof e&&(n=e,e=void 0),c.Writable.prototype.end.call(this,e,t,n)},l.prototype.flushHeaders=function(){},l.prototype.setTimeout=function(){},l.prototype.setNoDelay=function(){},l.prototype.setSocketKeepAlive=function(){};var m=["accept-charset","accept-encoding","access-control-request-headers","access-control-request-method","connection","content-length","cookie","cookie2","date","dnt","expect","host","keep-alive","origin","referer","te","trailer","transfer-encoding","upgrade","via"]}).call(this,n(57).Buffer,n(11),n(30))},function(e,t,n){"use strict";t.byteLength=function(e){var t=p(e),n=t[0],r=t[1];return 3*(n+r)/4-r},t.toByteArray=function(e){for(var t,n=p(e),r=n[0],a=n[1],s=new o(function(e,t,n){return 3*(t+n)/4-n}(0,r,a)),c=0,d=a>0?r-4:r,u=0;u<d;u+=4)t=i[e.charCodeAt(u)]<<18|i[e.charCodeAt(u+1)]<<12|i[e.charCodeAt(u+2)]<<6|i[e.charCodeAt(u+3)],s[c++]=t>>16&255,s[c++]=t>>8&255,s[c++]=255&t;2===a&&(t=i[e.charCodeAt(u)]<<2|i[e.charCodeAt(u+1)]>>4,s[c++]=255&t);1===a&&(t=i[e.charCodeAt(u)]<<10|i[e.charCodeAt(u+1)]<<4|i[e.charCodeAt(u+2)]>>2,s[c++]=t>>8&255,s[c++]=255&t);return s},t.fromByteArray=function(e){for(var t,n=e.length,i=n%3,o=[],a=0,s=n-i;a<s;a+=16383)o.push(d(e,a,a+16383>s?s:a+16383));1===i?(t=e[n-1],o.push(r[t>>2]+r[t<<4&63]+"==")):2===i&&(t=(e[n-2]<<8)+e[n-1],o.push(r[t>>10]+r[t>>4&63]+r[t<<2&63]+"="));return o.join("")};for(var r=[],i=[],o="undefined"!=typeof Uint8Array?Uint8Array:Array,a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",s=0,c=a.length;s<c;++s)r[s]=a[s],i[a.charCodeAt(s)]=s;function p(e){var t=e.length;if(t%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var n=e.indexOf("=");return-1===n&&(n=t),[n,n===t?0:4-n%4]}function d(e,t,n){for(var i,o,a=[],s=t;s<n;s+=3)i=(e[s]<<16&16711680)+(e[s+1]<<8&65280)+(255&e[s+2]),a.push(r[(o=i)>>18&63]+r[o>>12&63]+r[o>>6&63]+r[63&o]);return a.join("")}i["-".charCodeAt(0)]=62,i["_".charCodeAt(0)]=63},function(e,t){t.read=function(e,t,n,r,i){var o,a,s=8*i-r-1,c=(1<<s)-1,p=c>>1,d=-7,u=n?i-1:0,l=n?-1:1,m=e[t+u];for(u+=l,o=m&(1<<-d)-1,m>>=-d,d+=s;d>0;o=256*o+e[t+u],u+=l,d-=8);for(a=o&(1<<-d)-1,o>>=-d,d+=r;d>0;a=256*a+e[t+u],u+=l,d-=8);if(0===o)o=1-p;else{if(o===c)return a?NaN:1/0*(m?-1:1);a+=Math.pow(2,r),o-=p}return(m?-1:1)*a*Math.pow(2,o-r)},t.write=function(e,t,n,r,i,o){var a,s,c,p=8*o-i-1,d=(1<<p)-1,u=d>>1,l=23===i?Math.pow(2,-24)-Math.pow(2,-77):0,m=r?0:o-1,f=r?1:-1,h=t<0||0===t&&1/t<0?1:0;for(t=Math.abs(t),isNaN(t)||t===1/0?(s=isNaN(t)?1:0,a=d):(a=Math.floor(Math.log(t)/Math.LN2),t*(c=Math.pow(2,-a))<1&&(a--,c*=2),(t+=a+u>=1?l/c:l*Math.pow(2,1-u))*c>=2&&(a++,c/=2),a+u>=d?(s=0,a=d):a+u>=1?(s=(t*c-1)*Math.pow(2,i),a+=u):(s=t*Math.pow(2,u-1)*Math.pow(2,i),a=0));i>=8;e[n+m]=255&s,m+=f,s/=256,i-=8);for(a=a<<i|s,p+=i;p>0;e[n+m]=255&a,m+=f,a/=256,p-=8);e[n+m-f]|=128*h}},function(e,t){},function(e,t,n){"use strict";var r=n(74).Buffer,i=n(361);e.exports=function(){function e(){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this.head=null,this.tail=null,this.length=0}return e.prototype.push=function(e){var t={data:e,next:null};this.length>0?this.tail.next=t:this.head=t,this.tail=t,++this.length},e.prototype.unshift=function(e){var t={data:e,next:this.head};0===this.length&&(this.tail=t),this.head=t,++this.length},e.prototype.shift=function(){if(0!==this.length){var e=this.head.data;return 1===this.length?this.head=this.tail=null:this.head=this.head.next,--this.length,e}},e.prototype.clear=function(){this.head=this.tail=null,this.length=0},e.prototype.join=function(e){if(0===this.length)return"";for(var t=this.head,n=""+t.data;t=t.next;)n+=e+t.data;return n},e.prototype.concat=function(e){if(0===this.length)return r.alloc(0);if(1===this.length)return this.head.data;for(var t,n,i,o=r.allocUnsafe(e>>>0),a=this.head,s=0;a;)t=a.data,n=o,i=s,t.copy(n,i),s+=a.data.length,a=a.next;return o},e}(),i&&i.inspect&&i.inspect.custom&&(e.exports.prototype[i.inspect.custom]=function(){var e=i.inspect({length:this.length});return this.constructor.name+" "+e})},function(e,t){},function(e,t,n){(function(e){var r=void 0!==e&&e||"undefined"!=typeof self&&self||window,i=Function.prototype.apply;function o(e,t){this._id=e,this._clearFn=t}t.setTimeout=function(){return new o(i.call(setTimeout,r,arguments),clearTimeout)},t.setInterval=function(){return new o(i.call(setInterval,r,arguments),clearInterval)},t.clearTimeout=t.clearInterval=function(e){e&&e.close()},o.prototype.unref=o.prototype.ref=function(){},o.prototype.close=function(){this._clearFn.call(r,this._id)},t.enroll=function(e,t){clearTimeout(e._idleTimeoutId),e._idleTimeout=t},t.unenroll=function(e){clearTimeout(e._idleTimeoutId),e._idleTimeout=-1},t._unrefActive=t.active=function(e){clearTimeout(e._idleTimeoutId);var t=e._idleTimeout;t>=0&&(e._idleTimeoutId=setTimeout(function(){e._onTimeout&&e._onTimeout()},t))},n(363),t.setImmediate="undefined"!=typeof self&&self.setImmediate||void 0!==e&&e.setImmediate||this&&this.setImmediate,t.clearImmediate="undefined"!=typeof self&&self.clearImmediate||void 0!==e&&e.clearImmediate||this&&this.clearImmediate}).call(this,n(11))},function(e,t,n){(function(e,t){!function(e,n){"use strict";if(!e.setImmediate){var r,i,o,a,s,c=1,p={},d=!1,u=e.document,l=Object.getPrototypeOf&&Object.getPrototypeOf(e);l=l&&l.setTimeout?l:e,"[object process]"==={}.toString.call(e.process)?r=function(e){t.nextTick(function(){f(e)})}:!function(){if(e.postMessage&&!e.importScripts){var t=!0,n=e.onmessage;return e.onmessage=function(){t=!1},e.postMessage("","*"),e.onmessage=n,t}}()?e.MessageChannel?((o=new MessageChannel).port1.onmessage=function(e){f(e.data)},r=function(e){o.port2.postMessage(e)}):u&&"onreadystatechange"in u.createElement("script")?(i=u.documentElement,r=function(e){var t=u.createElement("script");t.onreadystatechange=function(){f(e),t.onreadystatechange=null,i.removeChild(t),t=null},i.appendChild(t)}):r=function(e){setTimeout(f,0,e)}:(a="setImmediate$"+Math.random()+"$",s=function(t){t.source===e&&"string"==typeof t.data&&0===t.data.indexOf(a)&&f(+t.data.slice(a.length))},e.addEventListener?e.addEventListener("message",s,!1):e.attachEvent("onmessage",s),r=function(t){e.postMessage(a+t,"*")}),l.setImmediate=function(e){"function"!=typeof e&&(e=new Function(""+e));for(var t=new Array(arguments.length-1),n=0;n<t.length;n++)t[n]=arguments[n+1];var i={callback:e,args:t};return p[c]=i,r(c),c++},l.clearImmediate=m}function m(e){delete p[e]}function f(e){if(d)setTimeout(f,0,e);else{var t=p[e];if(t){d=!0;try{!function(e){var t=e.callback,r=e.args;switch(r.length){case 0:t();break;case 1:t(r[0]);break;case 2:t(r[0],r[1]);break;case 3:t(r[0],r[1],r[2]);break;default:t.apply(n,r)}}(t)}finally{m(e),d=!1}}}}}("undefined"==typeof self?void 0===e?this:e:self)}).call(this,n(11),n(30))},function(e,t,n){(function(t){function n(e){try{if(!t.localStorage)return!1}catch(e){return!1}var n=t.localStorage[e];return null!=n&&"true"===String(n).toLowerCase()}e.exports=function(e,t){if(n("noDeprecation"))return e;var r=!1;return function(){if(!r){if(n("throwDeprecation"))throw new Error(t);n("traceDeprecation")?console.trace(t):console.warn(t),r=!0}return e.apply(this,arguments)}}}).call(this,n(11))},function(e,t,n){"use strict";e.exports=o;var r=n(150),i=n(58);function o(e){if(!(this instanceof o))return new o(e);r.call(this,e)}i.inherits=n(34),i.inherits(o,r),o.prototype._transform=function(e,t,n){n(null,e)}},function(e,t,n){var r=n(57).Buffer;e.exports=function(e){if(e instanceof Uint8Array){if(0===e.byteOffset&&e.byteLength===e.buffer.byteLength)return e.buffer;if("function"==typeof e.buffer.slice)return e.buffer.slice(e.byteOffset,e.byteOffset+e.byteLength)}if(r.isBuffer(e)){for(var t=new Uint8Array(e.length),n=e.length,i=0;i<n;i++)t[i]=e[i];return t.buffer}throw new Error("Argument must be a Buffer")}},function(e,t){e.exports=function(){for(var e={},t=0;t<arguments.length;t++){var r=arguments[t];for(var i in r)n.call(r,i)&&(e[i]=r[i])}return e};var n=Object.prototype.hasOwnProperty},function(e,t){e.exports={100:"Continue",101:"Switching Protocols",102:"Processing",200:"OK",201:"Created",202:"Accepted",203:"Non-Authoritative Information",204:"No Content",205:"Reset Content",206:"Partial Content",207:"Multi-Status",208:"Already Reported",226:"IM Used",300:"Multiple Choices",301:"Moved Permanently",302:"Found",303:"See Other",304:"Not Modified",305:"Use Proxy",307:"Temporary Redirect",308:"Permanent Redirect",400:"Bad Request",401:"Unauthorized",402:"Payment Required",403:"Forbidden",404:"Not Found",405:"Method Not Allowed",406:"Not Acceptable",407:"Proxy Authentication Required",408:"Request Timeout",409:"Conflict",410:"Gone",411:"Length Required",412:"Precondition Failed",413:"Payload Too Large",414:"URI Too Long",415:"Unsupported Media Type",416:"Range Not Satisfiable",417:"Expectation Failed",418:"I'm a teapot",421:"Misdirected Request",422:"Unprocessable Entity",423:"Locked",424:"Failed Dependency",425:"Unordered Collection",426:"Upgrade Required",428:"Precondition Required",429:"Too Many Requests",431:"Request Header Fields Too Large",451:"Unavailable For Legal Reasons",500:"Internal Server Error",501:"Not Implemented",502:"Bad Gateway",503:"Service Unavailable",504:"Gateway Timeout",505:"HTTP Version Not Supported",506:"Variant Also Negotiates",507:"Insufficient Storage",508:"Loop Detected",509:"Bandwidth Limit Exceeded",510:"Not Extended",511:"Network Authentication Required"}},function(e,t,n){(function(e,r){var i;/*! https://mths.be/punycode v1.4.1 by @mathias */!function(o){t&&t.nodeType,e&&e.nodeType;var a="object"==typeof r&&r;a.global!==a&&a.window!==a&&a.self;var s,c=2147483647,p=36,d=1,u=26,l=38,m=700,f=72,h=128,g="-",y=/^xn--/,b=/[^\x20-\x7E]/,v=/[\x2E\u3002\uFF0E\uFF61]/g,w={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},S=p-d,x=Math.floor,I=String.fromCharCode;function T(e){throw new RangeError(w[e])}function R(e,t){for(var n=e.length,r=[];n--;)r[n]=t(e[n]);return r}function k(e,t){var n=e.split("@"),r="";return n.length>1&&(r=n[0]+"@",e=n[1]),r+R((e=e.replace(v,".")).split("."),t).join(".")}function C(e){for(var t,n,r=[],i=0,o=e.length;i<o;)(t=e.charCodeAt(i++))>=55296&&t<=56319&&i<o?56320==(64512&(n=e.charCodeAt(i++)))?r.push(((1023&t)<<10)+(1023&n)+65536):(r.push(t),i--):r.push(t);return r}function O(e){return R(e,function(e){var t="";return e>65535&&(t+=I((e-=65536)>>>10&1023|55296),e=56320|1023&e),t+=I(e)}).join("")}function $(e,t){return e+22+75*(e<26)-((0!=t)<<5)}function E(e,t,n){var r=0;for(e=n?x(e/m):e>>1,e+=x(e/t);e>S*u>>1;r+=p)e=x(e/S);return x(r+(S+1)*e/(e+l))}function D(e){var t,n,r,i,o,a,s,l,m,y,b,v=[],w=e.length,S=0,I=h,R=f;for((n=e.lastIndexOf(g))<0&&(n=0),r=0;r<n;++r)e.charCodeAt(r)>=128&&T("not-basic"),v.push(e.charCodeAt(r));for(i=n>0?n+1:0;i<w;){for(o=S,a=1,s=p;i>=w&&T("invalid-input"),((l=(b=e.charCodeAt(i++))-48<10?b-22:b-65<26?b-65:b-97<26?b-97:p)>=p||l>x((c-S)/a))&&T("overflow"),S+=l*a,!(l<(m=s<=R?d:s>=R+u?u:s-R));s+=p)a>x(c/(y=p-m))&&T("overflow"),a*=y;R=E(S-o,t=v.length+1,0==o),x(S/t)>c-I&&T("overflow"),I+=x(S/t),S%=t,v.splice(S++,0,I)}return O(v)}function j(e){var t,n,r,i,o,a,s,l,m,y,b,v,w,S,R,k=[];for(v=(e=C(e)).length,t=h,n=0,o=f,a=0;a<v;++a)(b=e[a])<128&&k.push(I(b));for(r=i=k.length,i&&k.push(g);r<v;){for(s=c,a=0;a<v;++a)(b=e[a])>=t&&b<s&&(s=b);for(s-t>x((c-n)/(w=r+1))&&T("overflow"),n+=(s-t)*w,t=s,a=0;a<v;++a)if((b=e[a])<t&&++n>c&&T("overflow"),b==t){for(l=n,m=p;!(l<(y=m<=o?d:m>=o+u?u:m-o));m+=p)R=l-y,S=p-y,k.push(I($(y+R%S,0))),l=x(R/S);k.push(I($(l,0))),o=E(n,w,r==i),n=0,++r}++n,++t}return k.join("")}s={version:"1.4.1",ucs2:{decode:C,encode:O},decode:D,encode:j,toASCII:function(e){return k(e,function(e){return b.test(e)?"xn--"+j(e):e})},toUnicode:function(e){return k(e,function(e){return y.test(e)?D(e.slice(4).toLowerCase()):e})}},void 0===(i=function(){return s}.call(t,n,t,e))||(e.exports=i)}()}).call(this,n(370)(e),n(11))},function(e,t){e.exports=function(e){return e.webpackPolyfill||(e.deprecate=function(){},e.paths=[],e.children||(e.children=[]),Object.defineProperty(e,"loaded",{enumerable:!0,get:function(){return e.l}}),Object.defineProperty(e,"id",{enumerable:!0,get:function(){return e.i}}),e.webpackPolyfill=1),e}},function(e,t,n){"use strict";e.exports={isString:function(e){return"string"==typeof e},isObject:function(e){return"object"==typeof e&&null!==e},isNull:function(e){return null===e},isNullOrUndefined:function(e){return null==e}}},function(e,t,n){"use strict";t.decode=t.parse=n(373),t.encode=t.stringify=n(374)},function(e,t,n){"use strict";function r(e,t){return Object.prototype.hasOwnProperty.call(e,t)}e.exports=function(e,t,n,o){t=t||"&",n=n||"=";var a={};if("string"!=typeof e||0===e.length)return a;var s=/\+/g;e=e.split(t);var c=1e3;o&&"number"==typeof o.maxKeys&&(c=o.maxKeys);var p=e.length;c>0&&p>c&&(p=c);for(var d=0;d<p;++d){var u,l,m,f,h=e[d].replace(s,"%20"),g=h.indexOf(n);g>=0?(u=h.substr(0,g),l=h.substr(g+1)):(u=h,l=""),m=decodeURIComponent(u),f=decodeURIComponent(l),r(a,m)?i(a[m])?a[m].push(f):a[m]=[a[m],f]:a[m]=f}return a};var i=Array.isArray||function(e){return"[object Array]"===Object.prototype.toString.call(e)}},function(e,t,n){"use strict";var r=function(e){switch(typeof e){case"string":return e;case"boolean":return e?"true":"false";case"number":return isFinite(e)?e:"";default:return""}};e.exports=function(e,t,n,s){return t=t||"&",n=n||"=",null===e&&(e=void 0),"object"==typeof e?o(a(e),function(a){var s=encodeURIComponent(r(a))+n;return i(e[a])?o(e[a],function(e){return s+encodeURIComponent(r(e))}).join(t):s+encodeURIComponent(r(e[a]))}).join(t):s?encodeURIComponent(r(s))+n+encodeURIComponent(r(e)):""};var i=Array.isArray||function(e){return"[object Array]"===Object.prototype.toString.call(e)};function o(e,t){if(e.map)return e.map(t);for(var n=[],r=0;r<e.length;r++)n.push(t(e[r],r));return n}var a=Object.keys||function(e){var t=[];for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&t.push(n);return t}},function(e,t,n){var r=n(140),i=n(75),o=e.exports;for(var a in r)r.hasOwnProperty(a)&&(o[a]=r[a]);function s(e){if("string"==typeof e&&(e=i.parse(e)),e.protocol||(e.protocol="https:"),"https:"!==e.protocol)throw new Error('Protocol "'+e.protocol+'" not supported. Expected "https:"');return e}o.request=function(e,t){return e=s(e),r.request.call(this,e,t)},o.get=function(e,t){return e=s(e),r.get.call(this,e,t)}},function(e,t){e.exports=function(e,t,n){window.criRequest(t,n)}},function(e){e.exports=JSON.parse('{"version":{"major":"1","minor":"3"},"domains":[{"domain":"Accessibility","experimental":true,"dependencies":["DOM"],"types":[{"id":"AXNodeId","description":"Unique accessibility node identifier.","type":"string"},{"id":"AXValueType","description":"Enum of possible property types.","type":"string","enum":["boolean","tristate","booleanOrUndefined","idref","idrefList","integer","node","nodeList","number","string","computedString","token","tokenList","domRelation","role","internalRole","valueUndefined"]},{"id":"AXValueSourceType","description":"Enum of possible property sources.","type":"string","enum":["attribute","implicit","style","contents","placeholder","relatedElement"]},{"id":"AXValueNativeSourceType","description":"Enum of possible native property sources (as a subtype of a particular AXValueSourceType).","type":"string","enum":["figcaption","label","labelfor","labelwrapped","legend","tablecaption","title","other"]},{"id":"AXValueSource","description":"A single source for a computed AX property.","type":"object","properties":[{"name":"type","description":"What type of source this is.","$ref":"AXValueSourceType"},{"name":"value","description":"The value of this property source.","optional":true,"$ref":"AXValue"},{"name":"attribute","description":"The name of the relevant attribute, if any.","optional":true,"type":"string"},{"name":"attributeValue","description":"The value of the relevant attribute, if any.","optional":true,"$ref":"AXValue"},{"name":"superseded","description":"Whether this source is superseded by a higher priority source.","optional":true,"type":"boolean"},{"name":"nativeSource","description":"The native markup source for this value, e.g. a <label> element.","optional":true,"$ref":"AXValueNativeSourceType"},{"name":"nativeSourceValue","description":"The value, such as a node or node list, of the native source.","optional":true,"$ref":"AXValue"},{"name":"invalid","description":"Whether the value for this property is invalid.","optional":true,"type":"boolean"},{"name":"invalidReason","description":"Reason for the value being invalid, if it is.","optional":true,"type":"string"}]},{"id":"AXRelatedNode","type":"object","properties":[{"name":"backendDOMNodeId","description":"The BackendNodeId of the related DOM node.","$ref":"DOM.BackendNodeId"},{"name":"idref","description":"The IDRef value provided, if any.","optional":true,"type":"string"},{"name":"text","description":"The text alternative of this node in the current context.","optional":true,"type":"string"}]},{"id":"AXProperty","type":"object","properties":[{"name":"name","description":"The name of this property.","$ref":"AXPropertyName"},{"name":"value","description":"The value of this property.","$ref":"AXValue"}]},{"id":"AXValue","description":"A single computed AX property.","type":"object","properties":[{"name":"type","description":"The type of this value.","$ref":"AXValueType"},{"name":"value","description":"The computed value of this property.","optional":true,"type":"any"},{"name":"relatedNodes","description":"One or more related nodes, if applicable.","optional":true,"type":"array","items":{"$ref":"AXRelatedNode"}},{"name":"sources","description":"The sources which contributed to the computation of this property.","optional":true,"type":"array","items":{"$ref":"AXValueSource"}}]},{"id":"AXPropertyName","description":"Values of AXProperty name:\\n- from \'busy\' to \'roledescription\': states which apply to every AX node\\n- from \'live\' to \'root\': attributes which apply to nodes in live regions\\n- from \'autocomplete\' to \'valuetext\': attributes which apply to widgets\\n- from \'checked\' to \'selected\': states which apply to widgets\\n- from \'activedescendant\' to \'owns\' - relationships between elements other than parent/child/sibling.","type":"string","enum":["busy","disabled","editable","focusable","focused","hidden","hiddenRoot","invalid","keyshortcuts","settable","roledescription","live","atomic","relevant","root","autocomplete","hasPopup","level","multiselectable","orientation","multiline","readonly","required","valuemin","valuemax","valuetext","checked","expanded","modal","pressed","selected","activedescendant","controls","describedby","details","errormessage","flowto","labelledby","owns"]},{"id":"AXNode","description":"A node in the accessibility tree.","type":"object","properties":[{"name":"nodeId","description":"Unique identifier for this node.","$ref":"AXNodeId"},{"name":"ignored","description":"Whether this node is ignored for accessibility","type":"boolean"},{"name":"ignoredReasons","description":"Collection of reasons why this node is hidden.","optional":true,"type":"array","items":{"$ref":"AXProperty"}},{"name":"role","description":"This `Node`\'s role, whether explicit or implicit.","optional":true,"$ref":"AXValue"},{"name":"name","description":"The accessible name for this `Node`.","optional":true,"$ref":"AXValue"},{"name":"description","description":"The accessible description for this `Node`.","optional":true,"$ref":"AXValue"},{"name":"value","description":"The value for this `Node`.","optional":true,"$ref":"AXValue"},{"name":"properties","description":"All other properties","optional":true,"type":"array","items":{"$ref":"AXProperty"}},{"name":"childIds","description":"IDs for each of this node\'s child nodes.","optional":true,"type":"array","items":{"$ref":"AXNodeId"}},{"name":"backendDOMNodeId","description":"The backend ID for the associated DOM node, if any.","optional":true,"$ref":"DOM.BackendNodeId"}]}],"commands":[{"name":"disable","description":"Disables the accessibility domain."},{"name":"enable","description":"Enables the accessibility domain which causes `AXNodeId`s to remain consistent between method calls.\\nThis turns on accessibility for the page, which can impact performance until accessibility is disabled."},{"name":"getPartialAXTree","description":"Fetches the accessibility node and partial accessibility tree for this DOM node, if it exists.","experimental":true,"parameters":[{"name":"nodeId","description":"Identifier of the node to get the partial accessibility tree for.","optional":true,"$ref":"DOM.NodeId"},{"name":"backendNodeId","description":"Identifier of the backend node to get the partial accessibility tree for.","optional":true,"$ref":"DOM.BackendNodeId"},{"name":"objectId","description":"JavaScript object id of the node wrapper to get the partial accessibility tree for.","optional":true,"$ref":"Runtime.RemoteObjectId"},{"name":"fetchRelatives","description":"Whether to fetch this nodes ancestors, siblings and children. Defaults to true.","optional":true,"type":"boolean"}],"returns":[{"name":"nodes","description":"The `Accessibility.AXNode` for this DOM node, if it exists, plus its ancestors, siblings and\\nchildren, if requested.","type":"array","items":{"$ref":"AXNode"}}]},{"name":"getFullAXTree","description":"Fetches the entire accessibility tree","experimental":true,"returns":[{"name":"nodes","type":"array","items":{"$ref":"AXNode"}}]}]},{"domain":"Animation","experimental":true,"dependencies":["Runtime","DOM"],"types":[{"id":"Animation","description":"Animation instance.","type":"object","properties":[{"name":"id","description":"`Animation`\'s id.","type":"string"},{"name":"name","description":"`Animation`\'s name.","type":"string"},{"name":"pausedState","description":"`Animation`\'s internal paused state.","type":"boolean"},{"name":"playState","description":"`Animation`\'s play state.","type":"string"},{"name":"playbackRate","description":"`Animation`\'s playback rate.","type":"number"},{"name":"startTime","description":"`Animation`\'s start time.","type":"number"},{"name":"currentTime","description":"`Animation`\'s current time.","type":"number"},{"name":"type","description":"Animation type of `Animation`.","type":"string","enum":["CSSTransition","CSSAnimation","WebAnimation"]},{"name":"source","description":"`Animation`\'s source animation node.","optional":true,"$ref":"AnimationEffect"},{"name":"cssId","description":"A unique ID for `Animation` representing the sources that triggered this CSS\\nanimation/transition.","optional":true,"type":"string"}]},{"id":"AnimationEffect","description":"AnimationEffect instance","type":"object","properties":[{"name":"delay","description":"`AnimationEffect`\'s delay.","type":"number"},{"name":"endDelay","description":"`AnimationEffect`\'s end delay.","type":"number"},{"name":"iterationStart","description":"`AnimationEffect`\'s iteration start.","type":"number"},{"name":"iterations","description":"`AnimationEffect`\'s iterations.","type":"number"},{"name":"duration","description":"`AnimationEffect`\'s iteration duration.","type":"number"},{"name":"direction","description":"`AnimationEffect`\'s playback direction.","type":"string"},{"name":"fill","description":"`AnimationEffect`\'s fill mode.","type":"string"},{"name":"backendNodeId","description":"`AnimationEffect`\'s target node.","optional":true,"$ref":"DOM.BackendNodeId"},{"name":"keyframesRule","description":"`AnimationEffect`\'s keyframes.","optional":true,"$ref":"KeyframesRule"},{"name":"easing","description":"`AnimationEffect`\'s timing function.","type":"string"}]},{"id":"KeyframesRule","description":"Keyframes Rule","type":"object","properties":[{"name":"name","description":"CSS keyframed animation\'s name.","optional":true,"type":"string"},{"name":"keyframes","description":"List of animation keyframes.","type":"array","items":{"$ref":"KeyframeStyle"}}]},{"id":"KeyframeStyle","description":"Keyframe Style","type":"object","properties":[{"name":"offset","description":"Keyframe\'s time offset.","type":"string"},{"name":"easing","description":"`AnimationEffect`\'s timing function.","type":"string"}]}],"commands":[{"name":"disable","description":"Disables animation domain notifications."},{"name":"enable","description":"Enables animation domain notifications."},{"name":"getCurrentTime","description":"Returns the current time of the an animation.","parameters":[{"name":"id","description":"Id of animation.","type":"string"}],"returns":[{"name":"currentTime","description":"Current time of the page.","type":"number"}]},{"name":"getPlaybackRate","description":"Gets the playback rate of the document timeline.","returns":[{"name":"playbackRate","description":"Playback rate for animations on page.","type":"number"}]},{"name":"releaseAnimations","description":"Releases a set of animations to no longer be manipulated.","parameters":[{"name":"animations","description":"List of animation ids to seek.","type":"array","items":{"type":"string"}}]},{"name":"resolveAnimation","description":"Gets the remote object of the Animation.","parameters":[{"name":"animationId","description":"Animation id.","type":"string"}],"returns":[{"name":"remoteObject","description":"Corresponding remote object.","$ref":"Runtime.RemoteObject"}]},{"name":"seekAnimations","description":"Seek a set of animations to a particular time within each animation.","parameters":[{"name":"animations","description":"List of animation ids to seek.","type":"array","items":{"type":"string"}},{"name":"currentTime","description":"Set the current time of each animation.","type":"number"}]},{"name":"setPaused","description":"Sets the paused state of a set of animations.","parameters":[{"name":"animations","description":"Animations to set the pause state of.","type":"array","items":{"type":"string"}},{"name":"paused","description":"Paused state to set to.","type":"boolean"}]},{"name":"setPlaybackRate","description":"Sets the playback rate of the document timeline.","parameters":[{"name":"playbackRate","description":"Playback rate for animations on page","type":"number"}]},{"name":"setTiming","description":"Sets the timing of an animation node.","parameters":[{"name":"animationId","description":"Animation id.","type":"string"},{"name":"duration","description":"Duration of the animation.","type":"number"},{"name":"delay","description":"Delay of the animation.","type":"number"}]}],"events":[{"name":"animationCanceled","description":"Event for when an animation has been cancelled.","parameters":[{"name":"id","description":"Id of the animation that was cancelled.","type":"string"}]},{"name":"animationCreated","description":"Event for each animation that has been created.","parameters":[{"name":"id","description":"Id of the animation that was created.","type":"string"}]},{"name":"animationStarted","description":"Event for animation that has been started.","parameters":[{"name":"animation","description":"Animation that was started.","$ref":"Animation"}]}]},{"domain":"ApplicationCache","experimental":true,"types":[{"id":"ApplicationCacheResource","description":"Detailed application cache resource information.","type":"object","properties":[{"name":"url","description":"Resource url.","type":"string"},{"name":"size","description":"Resource size.","type":"integer"},{"name":"type","description":"Resource type.","type":"string"}]},{"id":"ApplicationCache","description":"Detailed application cache information.","type":"object","properties":[{"name":"manifestURL","description":"Manifest URL.","type":"string"},{"name":"size","description":"Application cache size.","type":"number"},{"name":"creationTime","description":"Application cache creation time.","type":"number"},{"name":"updateTime","description":"Application cache update time.","type":"number"},{"name":"resources","description":"Application cache resources.","type":"array","items":{"$ref":"ApplicationCacheResource"}}]},{"id":"FrameWithManifest","description":"Frame identifier - manifest URL pair.","type":"object","properties":[{"name":"frameId","description":"Frame identifier.","$ref":"Page.FrameId"},{"name":"manifestURL","description":"Manifest URL.","type":"string"},{"name":"status","description":"Application cache status.","type":"integer"}]}],"commands":[{"name":"enable","description":"Enables application cache domain notifications."},{"name":"getApplicationCacheForFrame","description":"Returns relevant application cache data for the document in given frame.","parameters":[{"name":"frameId","description":"Identifier of the frame containing document whose application cache is retrieved.","$ref":"Page.FrameId"}],"returns":[{"name":"applicationCache","description":"Relevant application cache data for the document in given frame.","$ref":"ApplicationCache"}]},{"name":"getFramesWithManifests","description":"Returns array of frame identifiers with manifest urls for each frame containing a document\\nassociated with some application cache.","returns":[{"name":"frameIds","description":"Array of frame identifiers with manifest urls for each frame containing a document\\nassociated with some application cache.","type":"array","items":{"$ref":"FrameWithManifest"}}]},{"name":"getManifestForFrame","description":"Returns manifest URL for document in the given frame.","parameters":[{"name":"frameId","description":"Identifier of the frame containing document whose manifest is retrieved.","$ref":"Page.FrameId"}],"returns":[{"name":"manifestURL","description":"Manifest URL for document in the given frame.","type":"string"}]}],"events":[{"name":"applicationCacheStatusUpdated","parameters":[{"name":"frameId","description":"Identifier of the frame containing document whose application cache updated status.","$ref":"Page.FrameId"},{"name":"manifestURL","description":"Manifest URL.","type":"string"},{"name":"status","description":"Updated application cache status.","type":"integer"}]},{"name":"networkStateUpdated","parameters":[{"name":"isNowOnline","type":"boolean"}]}]},{"domain":"Audits","description":"Audits domain allows investigation of page violations and possible improvements.","experimental":true,"dependencies":["Network"],"commands":[{"name":"getEncodedResponse","description":"Returns the response body and size if it were re-encoded with the specified settings. Only\\napplies to images.","parameters":[{"name":"requestId","description":"Identifier of the network request to get content for.","$ref":"Network.RequestId"},{"name":"encoding","description":"The encoding to use.","type":"string","enum":["webp","jpeg","png"]},{"name":"quality","description":"The quality of the encoding (0-1). (defaults to 1)","optional":true,"type":"number"},{"name":"sizeOnly","description":"Whether to only return the size information (defaults to false).","optional":true,"type":"boolean"}],"returns":[{"name":"body","description":"The encoded body as a base64 string. Omitted if sizeOnly is true.","optional":true,"type":"string"},{"name":"originalSize","description":"Size before re-encoding.","type":"integer"},{"name":"encodedSize","description":"Size after re-encoding.","type":"integer"}]}]},{"domain":"BackgroundService","description":"Defines events for background web platform features.","experimental":true,"types":[{"id":"ServiceName","description":"The Background Service that will be associated with the commands/events.\\nEvery Background Service operates independently, but they share the same\\nAPI.","type":"string","enum":["backgroundFetch","backgroundSync","pushMessaging","notifications","paymentHandler"]},{"id":"EventMetadata","description":"A key-value pair for additional event information to pass along.","type":"object","properties":[{"name":"key","type":"string"},{"name":"value","type":"string"}]},{"id":"BackgroundServiceEvent","type":"object","properties":[{"name":"timestamp","description":"Timestamp of the event (in seconds).","$ref":"Network.TimeSinceEpoch"},{"name":"origin","description":"The origin this event belongs to.","type":"string"},{"name":"serviceWorkerRegistrationId","description":"The Service Worker ID that initiated the event.","$ref":"ServiceWorker.RegistrationID"},{"name":"service","description":"The Background Service this event belongs to.","$ref":"ServiceName"},{"name":"eventName","description":"A description of the event.","type":"string"},{"name":"instanceId","description":"An identifier that groups related events together.","type":"string"},{"name":"eventMetadata","description":"A list of event-specific information.","type":"array","items":{"$ref":"EventMetadata"}}]}],"commands":[{"name":"startObserving","description":"Enables event updates for the service.","parameters":[{"name":"service","$ref":"ServiceName"}]},{"name":"stopObserving","description":"Disables event updates for the service.","parameters":[{"name":"service","$ref":"ServiceName"}]},{"name":"setRecording","description":"Set the recording state for the service.","parameters":[{"name":"shouldRecord","type":"boolean"},{"name":"service","$ref":"ServiceName"}]},{"name":"clearEvents","description":"Clears all stored data for the service.","parameters":[{"name":"service","$ref":"ServiceName"}]}],"events":[{"name":"recordingStateChanged","description":"Called when the recording state for the service has been updated.","parameters":[{"name":"isRecording","type":"boolean"},{"name":"service","$ref":"ServiceName"}]},{"name":"backgroundServiceEventReceived","description":"Called with all existing backgroundServiceEvents when enabled, and all new\\nevents afterwards if enabled and recording.","parameters":[{"name":"backgroundServiceEvent","$ref":"BackgroundServiceEvent"}]}]},{"domain":"Browser","description":"The Browser domain defines methods and events for browser managing.","types":[{"id":"WindowID","experimental":true,"type":"integer"},{"id":"WindowState","description":"The state of the browser window.","experimental":true,"type":"string","enum":["normal","minimized","maximized","fullscreen"]},{"id":"Bounds","description":"Browser window bounds information","experimental":true,"type":"object","properties":[{"name":"left","description":"The offset from the left edge of the screen to the window in pixels.","optional":true,"type":"integer"},{"name":"top","description":"The offset from the top edge of the screen to the window in pixels.","optional":true,"type":"integer"},{"name":"width","description":"The window width in pixels.","optional":true,"type":"integer"},{"name":"height","description":"The window height in pixels.","optional":true,"type":"integer"},{"name":"windowState","description":"The window state. Default to normal.","optional":true,"$ref":"WindowState"}]},{"id":"PermissionType","experimental":true,"type":"string","enum":["accessibilityEvents","audioCapture","backgroundSync","backgroundFetch","clipboardRead","clipboardWrite","durableStorage","flash","geolocation","midi","midiSysex","notifications","paymentHandler","periodicBackgroundSync","protectedMediaIdentifier","sensors","videoCapture","idleDetection","wakeLockScreen","wakeLockSystem"]},{"id":"Bucket","description":"Chrome histogram bucket.","experimental":true,"type":"object","properties":[{"name":"low","description":"Minimum value (inclusive).","type":"integer"},{"name":"high","description":"Maximum value (exclusive).","type":"integer"},{"name":"count","description":"Number of samples.","type":"integer"}]},{"id":"Histogram","description":"Chrome histogram.","experimental":true,"type":"object","properties":[{"name":"name","description":"Name.","type":"string"},{"name":"sum","description":"Sum of sample values.","type":"integer"},{"name":"count","description":"Total number of samples.","type":"integer"},{"name":"buckets","description":"Buckets.","type":"array","items":{"$ref":"Bucket"}}]}],"commands":[{"name":"grantPermissions","description":"Grant specific permissions to the given origin and reject all others.","experimental":true,"parameters":[{"name":"origin","type":"string"},{"name":"permissions","type":"array","items":{"$ref":"PermissionType"}},{"name":"browserContextId","description":"BrowserContext to override permissions. When omitted, default browser context is used.","optional":true,"$ref":"Target.BrowserContextID"}]},{"name":"resetPermissions","description":"Reset all permission management for all origins.","experimental":true,"parameters":[{"name":"browserContextId","description":"BrowserContext to reset permissions. When omitted, default browser context is used.","optional":true,"$ref":"Target.BrowserContextID"}]},{"name":"close","description":"Close browser gracefully."},{"name":"crash","description":"Crashes browser on the main thread.","experimental":true},{"name":"crashGpuProcess","description":"Crashes GPU process.","experimental":true},{"name":"getVersion","description":"Returns version information.","returns":[{"name":"protocolVersion","description":"Protocol version.","type":"string"},{"name":"product","description":"Product name.","type":"string"},{"name":"revision","description":"Product revision.","type":"string"},{"name":"userAgent","description":"User-Agent.","type":"string"},{"name":"jsVersion","description":"V8 version.","type":"string"}]},{"name":"getBrowserCommandLine","description":"Returns the command line switches for the browser process if, and only if\\n--enable-automation is on the commandline.","experimental":true,"returns":[{"name":"arguments","description":"Commandline parameters","type":"array","items":{"type":"string"}}]},{"name":"getHistograms","description":"Get Chrome histograms.","experimental":true,"parameters":[{"name":"query","description":"Requested substring in name. Only histograms which have query as a\\nsubstring in their name are extracted. An empty or absent query returns\\nall histograms.","optional":true,"type":"string"},{"name":"delta","description":"If true, retrieve delta since last call.","optional":true,"type":"boolean"}],"returns":[{"name":"histograms","description":"Histograms.","type":"array","items":{"$ref":"Histogram"}}]},{"name":"getHistogram","description":"Get a Chrome histogram by name.","experimental":true,"parameters":[{"name":"name","description":"Requested histogram name.","type":"string"},{"name":"delta","description":"If true, retrieve delta since last call.","optional":true,"type":"boolean"}],"returns":[{"name":"histogram","description":"Histogram.","$ref":"Histogram"}]},{"name":"getWindowBounds","description":"Get position and size of the browser window.","experimental":true,"parameters":[{"name":"windowId","description":"Browser window id.","$ref":"WindowID"}],"returns":[{"name":"bounds","description":"Bounds information of the window. When window state is \'minimized\', the restored window\\nposition and size are returned.","$ref":"Bounds"}]},{"name":"getWindowForTarget","description":"Get the browser window that contains the devtools target.","experimental":true,"parameters":[{"name":"targetId","description":"Devtools agent host id. If called as a part of the session, associated targetId is used.","optional":true,"$ref":"Target.TargetID"}],"returns":[{"name":"windowId","description":"Browser window id.","$ref":"WindowID"},{"name":"bounds","description":"Bounds information of the window. When window state is \'minimized\', the restored window\\nposition and size are returned.","$ref":"Bounds"}]},{"name":"setWindowBounds","description":"Set position and/or size of the browser window.","experimental":true,"parameters":[{"name":"windowId","description":"Browser window id.","$ref":"WindowID"},{"name":"bounds","description":"New window bounds. The \'minimized\', \'maximized\' and \'fullscreen\' states cannot be combined\\nwith \'left\', \'top\', \'width\' or \'height\'. Leaves unspecified fields unchanged.","$ref":"Bounds"}]},{"name":"setDockTile","description":"Set dock tile details, platform-specific.","experimental":true,"parameters":[{"name":"badgeLabel","optional":true,"type":"string"},{"name":"image","description":"Png encoded image.","optional":true,"type":"string"}]}]},{"domain":"CSS","description":"This domain exposes CSS read/write operations. All CSS objects (stylesheets, rules, and styles)\\nhave an associated `id` used in subsequent operations on the related object. Each object type has\\na specific `id` structure, and those are not interchangeable between objects of different kinds.\\nCSS objects can be loaded using the `get*ForNode()` calls (which accept a DOM node id). A client\\ncan also keep track of stylesheets via the `styleSheetAdded`/`styleSheetRemoved` events and\\nsubsequently load the required stylesheet contents using the `getStyleSheet[Text]()` methods.","experimental":true,"dependencies":["DOM"],"types":[{"id":"StyleSheetId","type":"string"},{"id":"StyleSheetOrigin","description":"Stylesheet type: \\"injected\\" for stylesheets injected via extension, \\"user-agent\\" for user-agent\\nstylesheets, \\"inspector\\" for stylesheets created by the inspector (i.e. those holding the \\"via\\ninspector\\" rules), \\"regular\\" for regular stylesheets.","type":"string","enum":["injected","user-agent","inspector","regular"]},{"id":"PseudoElementMatches","description":"CSS rule collection for a single pseudo style.","type":"object","properties":[{"name":"pseudoType","description":"Pseudo element type.","$ref":"DOM.PseudoType"},{"name":"matches","description":"Matches of CSS rules applicable to the pseudo style.","type":"array","items":{"$ref":"RuleMatch"}}]},{"id":"InheritedStyleEntry","description":"Inherited CSS rule collection from ancestor node.","type":"object","properties":[{"name":"inlineStyle","description":"The ancestor node\'s inline style, if any, in the style inheritance chain.","optional":true,"$ref":"CSSStyle"},{"name":"matchedCSSRules","description":"Matches of CSS rules matching the ancestor node in the style inheritance chain.","type":"array","items":{"$ref":"RuleMatch"}}]},{"id":"RuleMatch","description":"Match data for a CSS rule.","type":"object","properties":[{"name":"rule","description":"CSS rule in the match.","$ref":"CSSRule"},{"name":"matchingSelectors","description":"Matching selector indices in the rule\'s selectorList selectors (0-based).","type":"array","items":{"type":"integer"}}]},{"id":"Value","description":"Data for a simple selector (these are delimited by commas in a selector list).","type":"object","properties":[{"name":"text","description":"Value text.","type":"string"},{"name":"range","description":"Value range in the underlying resource (if available).","optional":true,"$ref":"SourceRange"}]},{"id":"SelectorList","description":"Selector list data.","type":"object","properties":[{"name":"selectors","description":"Selectors in the list.","type":"array","items":{"$ref":"Value"}},{"name":"text","description":"Rule selector text.","type":"string"}]},{"id":"CSSStyleSheetHeader","description":"CSS stylesheet metainformation.","type":"object","properties":[{"name":"styleSheetId","description":"The stylesheet identifier.","$ref":"StyleSheetId"},{"name":"frameId","description":"Owner frame identifier.","$ref":"Page.FrameId"},{"name":"sourceURL","description":"Stylesheet resource URL.","type":"string"},{"name":"sourceMapURL","description":"URL of source map associated with the stylesheet (if any).","optional":true,"type":"string"},{"name":"origin","description":"Stylesheet origin.","$ref":"StyleSheetOrigin"},{"name":"title","description":"Stylesheet title.","type":"string"},{"name":"ownerNode","description":"The backend id for the owner node of the stylesheet.","optional":true,"$ref":"DOM.BackendNodeId"},{"name":"disabled","description":"Denotes whether the stylesheet is disabled.","type":"boolean"},{"name":"hasSourceURL","description":"Whether the sourceURL field value comes from the sourceURL comment.","optional":true,"type":"boolean"},{"name":"isInline","description":"Whether this stylesheet is created for STYLE tag by parser. This flag is not set for\\ndocument.written STYLE tags.","type":"boolean"},{"name":"startLine","description":"Line offset of the stylesheet within the resource (zero based).","type":"number"},{"name":"startColumn","description":"Column offset of the stylesheet within the resource (zero based).","type":"number"},{"name":"length","description":"Size of the content (in characters).","type":"number"}]},{"id":"CSSRule","description":"CSS rule representation.","type":"object","properties":[{"name":"styleSheetId","description":"The css style sheet identifier (absent for user agent stylesheet and user-specified\\nstylesheet rules) this rule came from.","optional":true,"$ref":"StyleSheetId"},{"name":"selectorList","description":"Rule selector data.","$ref":"SelectorList"},{"name":"origin","description":"Parent stylesheet\'s origin.","$ref":"StyleSheetOrigin"},{"name":"style","description":"Associated style declaration.","$ref":"CSSStyle"},{"name":"media","description":"Media list array (for rules involving media queries). The array enumerates media queries\\nstarting with the innermost one, going outwards.","optional":true,"type":"array","items":{"$ref":"CSSMedia"}}]},{"id":"RuleUsage","description":"CSS coverage information.","type":"object","properties":[{"name":"styleSheetId","description":"The css style sheet identifier (absent for user agent stylesheet and user-specified\\nstylesheet rules) this rule came from.","$ref":"StyleSheetId"},{"name":"startOffset","description":"Offset of the start of the rule (including selector) from the beginning of the stylesheet.","type":"number"},{"name":"endOffset","description":"Offset of the end of the rule body from the beginning of the stylesheet.","type":"number"},{"name":"used","description":"Indicates whether the rule was actually used by some element in the page.","type":"boolean"}]},{"id":"SourceRange","description":"Text range within a resource. All numbers are zero-based.","type":"object","properties":[{"name":"startLine","description":"Start line of range.","type":"integer"},{"name":"startColumn","description":"Start column of range (inclusive).","type":"integer"},{"name":"endLine","description":"End line of range","type":"integer"},{"name":"endColumn","description":"End column of range (exclusive).","type":"integer"}]},{"id":"ShorthandEntry","type":"object","properties":[{"name":"name","description":"Shorthand name.","type":"string"},{"name":"value","description":"Shorthand value.","type":"string"},{"name":"important","description":"Whether the property has \\"!important\\" annotation (implies `false` if absent).","optional":true,"type":"boolean"}]},{"id":"CSSComputedStyleProperty","type":"object","properties":[{"name":"name","description":"Computed style property name.","type":"string"},{"name":"value","description":"Computed style property value.","type":"string"}]},{"id":"CSSStyle","description":"CSS style representation.","type":"object","properties":[{"name":"styleSheetId","description":"The css style sheet identifier (absent for user agent stylesheet and user-specified\\nstylesheet rules) this rule came from.","optional":true,"$ref":"StyleSheetId"},{"name":"cssProperties","description":"CSS properties in the style.","type":"array","items":{"$ref":"CSSProperty"}},{"name":"shorthandEntries","description":"Computed values for all shorthands found in the style.","type":"array","items":{"$ref":"ShorthandEntry"}},{"name":"cssText","description":"Style declaration text (if available).","optional":true,"type":"string"},{"name":"range","description":"Style declaration range in the enclosing stylesheet (if available).","optional":true,"$ref":"SourceRange"}]},{"id":"CSSProperty","description":"CSS property declaration data.","type":"object","properties":[{"name":"name","description":"The property name.","type":"string"},{"name":"value","description":"The property value.","type":"string"},{"name":"important","description":"Whether the property has \\"!important\\" annotation (implies `false` if absent).","optional":true,"type":"boolean"},{"name":"implicit","description":"Whether the property is implicit (implies `false` if absent).","optional":true,"type":"boolean"},{"name":"text","description":"The full property text as specified in the style.","optional":true,"type":"string"},{"name":"parsedOk","description":"Whether the property is understood by the browser (implies `true` if absent).","optional":true,"type":"boolean"},{"name":"disabled","description":"Whether the property is disabled by the user (present for source-based properties only).","optional":true,"type":"boolean"},{"name":"range","description":"The entire property range in the enclosing style declaration (if available).","optional":true,"$ref":"SourceRange"}]},{"id":"CSSMedia","description":"CSS media rule descriptor.","type":"object","properties":[{"name":"text","description":"Media query text.","type":"string"},{"name":"source","description":"Source of the media query: \\"mediaRule\\" if specified by a @media rule, \\"importRule\\" if\\nspecified by an @import rule, \\"linkedSheet\\" if specified by a \\"media\\" attribute in a linked\\nstylesheet\'s LINK tag, \\"inlineSheet\\" if specified by a \\"media\\" attribute in an inline\\nstylesheet\'s STYLE tag.","type":"string","enum":["mediaRule","importRule","linkedSheet","inlineSheet"]},{"name":"sourceURL","description":"URL of the document containing the media query description.","optional":true,"type":"string"},{"name":"range","description":"The associated rule (@media or @import) header range in the enclosing stylesheet (if\\navailable).","optional":true,"$ref":"SourceRange"},{"name":"styleSheetId","description":"Identifier of the stylesheet containing this object (if exists).","optional":true,"$ref":"StyleSheetId"},{"name":"mediaList","description":"Array of media queries.","optional":true,"type":"array","items":{"$ref":"MediaQuery"}}]},{"id":"MediaQuery","description":"Media query descriptor.","type":"object","properties":[{"name":"expressions","description":"Array of media query expressions.","type":"array","items":{"$ref":"MediaQueryExpression"}},{"name":"active","description":"Whether the media query condition is satisfied.","type":"boolean"}]},{"id":"MediaQueryExpression","description":"Media query expression descriptor.","type":"object","properties":[{"name":"value","description":"Media query expression value.","type":"number"},{"name":"unit","description":"Media query expression units.","type":"string"},{"name":"feature","description":"Media query expression feature.","type":"string"},{"name":"valueRange","description":"The associated range of the value text in the enclosing stylesheet (if available).","optional":true,"$ref":"SourceRange"},{"name":"computedLength","description":"Computed length of media query expression (if applicable).","optional":true,"type":"number"}]},{"id":"PlatformFontUsage","description":"Information about amount of glyphs that were rendered with given font.","type":"object","properties":[{"name":"familyName","description":"Font\'s family name reported by platform.","type":"string"},{"name":"isCustomFont","description":"Indicates if the font was downloaded or resolved locally.","type":"boolean"},{"name":"glyphCount","description":"Amount of glyphs that were rendered with this font.","type":"number"}]},{"id":"FontFace","description":"Properties of a web font: https://www.w3.org/TR/2008/REC-CSS2-20080411/fonts.html#font-descriptions","type":"object","properties":[{"name":"fontFamily","description":"The font-family.","type":"string"},{"name":"fontStyle","description":"The font-style.","type":"string"},{"name":"fontVariant","description":"The font-variant.","type":"string"},{"name":"fontWeight","description":"The font-weight.","type":"string"},{"name":"fontStretch","description":"The font-stretch.","type":"string"},{"name":"unicodeRange","description":"The unicode-range.","type":"string"},{"name":"src","description":"The src.","type":"string"},{"name":"platformFontFamily","description":"The resolved platform font family","type":"string"}]},{"id":"CSSKeyframesRule","description":"CSS keyframes rule representation.","type":"object","properties":[{"name":"animationName","description":"Animation name.","$ref":"Value"},{"name":"keyframes","description":"List of keyframes.","type":"array","items":{"$ref":"CSSKeyframeRule"}}]},{"id":"CSSKeyframeRule","description":"CSS keyframe rule representation.","type":"object","properties":[{"name":"styleSheetId","description":"The css style sheet identifier (absent for user agent stylesheet and user-specified\\nstylesheet rules) this rule came from.","optional":true,"$ref":"StyleSheetId"},{"name":"origin","description":"Parent stylesheet\'s origin.","$ref":"StyleSheetOrigin"},{"name":"keyText","description":"Associated key text.","$ref":"Value"},{"name":"style","description":"Associated style declaration.","$ref":"CSSStyle"}]},{"id":"StyleDeclarationEdit","description":"A descriptor of operation to mutate style declaration text.","type":"object","properties":[{"name":"styleSheetId","description":"The css style sheet identifier.","$ref":"StyleSheetId"},{"name":"range","description":"The range of the style text in the enclosing stylesheet.","$ref":"SourceRange"},{"name":"text","description":"New style text.","type":"string"}]}],"commands":[{"name":"addRule","description":"Inserts a new rule with the given `ruleText` in a stylesheet with given `styleSheetId`, at the\\nposition specified by `location`.","parameters":[{"name":"styleSheetId","description":"The css style sheet identifier where a new rule should be inserted.","$ref":"StyleSheetId"},{"name":"ruleText","description":"The text of a new rule.","type":"string"},{"name":"location","description":"Text position of a new rule in the target style sheet.","$ref":"SourceRange"}],"returns":[{"name":"rule","description":"The newly created rule.","$ref":"CSSRule"}]},{"name":"collectClassNames","description":"Returns all class names from specified stylesheet.","parameters":[{"name":"styleSheetId","$ref":"StyleSheetId"}],"returns":[{"name":"classNames","description":"Class name list.","type":"array","items":{"type":"string"}}]},{"name":"createStyleSheet","description":"Creates a new special \\"via-inspector\\" stylesheet in the frame with given `frameId`.","parameters":[{"name":"frameId","description":"Identifier of the frame where \\"via-inspector\\" stylesheet should be created.","$ref":"Page.FrameId"}],"returns":[{"name":"styleSheetId","description":"Identifier of the created \\"via-inspector\\" stylesheet.","$ref":"StyleSheetId"}]},{"name":"disable","description":"Disables the CSS agent for the given page."},{"name":"enable","description":"Enables the CSS agent for the given page. Clients should not assume that the CSS agent has been\\nenabled until the result of this command is received."},{"name":"forcePseudoState","description":"Ensures that the given node will have specified pseudo-classes whenever its style is computed by\\nthe browser.","parameters":[{"name":"nodeId","description":"The element id for which to force the pseudo state.","$ref":"DOM.NodeId"},{"name":"forcedPseudoClasses","description":"Element pseudo classes to force when computing the element\'s style.","type":"array","items":{"type":"string"}}]},{"name":"getBackgroundColors","parameters":[{"name":"nodeId","description":"Id of the node to get background colors for.","$ref":"DOM.NodeId"}],"returns":[{"name":"backgroundColors","description":"The range of background colors behind this element, if it contains any visible text. If no\\nvisible text is present, this will be undefined. In the case of a flat background color,\\nthis will consist of simply that color. In the case of a gradient, this will consist of each\\nof the color stops. For anything more complicated, this will be an empty array. Images will\\nbe ignored (as if the image had failed to load).","optional":true,"type":"array","items":{"type":"string"}},{"name":"computedFontSize","description":"The computed font size for this node, as a CSS computed value string (e.g. \'12px\').","optional":true,"type":"string"},{"name":"computedFontWeight","description":"The computed font weight for this node, as a CSS computed value string (e.g. \'normal\' or\\n\'100\').","optional":true,"type":"string"}]},{"name":"getComputedStyleForNode","description":"Returns the computed style for a DOM node identified by `nodeId`.","parameters":[{"name":"nodeId","$ref":"DOM.NodeId"}],"returns":[{"name":"computedStyle","description":"Computed style for the specified DOM node.","type":"array","items":{"$ref":"CSSComputedStyleProperty"}}]},{"name":"getInlineStylesForNode","description":"Returns the styles defined inline (explicitly in the \\"style\\" attribute and implicitly, using DOM\\nattributes) for a DOM node identified by `nodeId`.","parameters":[{"name":"nodeId","$ref":"DOM.NodeId"}],"returns":[{"name":"inlineStyle","description":"Inline style for the specified DOM node.","optional":true,"$ref":"CSSStyle"},{"name":"attributesStyle","description":"Attribute-defined element style (e.g. resulting from \\"width=20 height=100%\\").","optional":true,"$ref":"CSSStyle"}]},{"name":"getMatchedStylesForNode","description":"Returns requested styles for a DOM node identified by `nodeId`.","parameters":[{"name":"nodeId","$ref":"DOM.NodeId"}],"returns":[{"name":"inlineStyle","description":"Inline style for the specified DOM node.","optional":true,"$ref":"CSSStyle"},{"name":"attributesStyle","description":"Attribute-defined element style (e.g. resulting from \\"width=20 height=100%\\").","optional":true,"$ref":"CSSStyle"},{"name":"matchedCSSRules","description":"CSS rules matching this node, from all applicable stylesheets.","optional":true,"type":"array","items":{"$ref":"RuleMatch"}},{"name":"pseudoElements","description":"Pseudo style matches for this node.","optional":true,"type":"array","items":{"$ref":"PseudoElementMatches"}},{"name":"inherited","description":"A chain of inherited styles (from the immediate node parent up to the DOM tree root).","optional":true,"type":"array","items":{"$ref":"InheritedStyleEntry"}},{"name":"cssKeyframesRules","description":"A list of CSS keyframed animations matching this node.","optional":true,"type":"array","items":{"$ref":"CSSKeyframesRule"}}]},{"name":"getMediaQueries","description":"Returns all media queries parsed by the rendering engine.","returns":[{"name":"medias","type":"array","items":{"$ref":"CSSMedia"}}]},{"name":"getPlatformFontsForNode","description":"Requests information about platform fonts which we used to render child TextNodes in the given\\nnode.","parameters":[{"name":"nodeId","$ref":"DOM.NodeId"}],"returns":[{"name":"fonts","description":"Usage statistics for every employed platform font.","type":"array","items":{"$ref":"PlatformFontUsage"}}]},{"name":"getStyleSheetText","description":"Returns the current textual content for a stylesheet.","parameters":[{"name":"styleSheetId","$ref":"StyleSheetId"}],"returns":[{"name":"text","description":"The stylesheet text.","type":"string"}]},{"name":"setEffectivePropertyValueForNode","description":"Find a rule with the given active property for the given node and set the new value for this\\nproperty","parameters":[{"name":"nodeId","description":"The element id for which to set property.","$ref":"DOM.NodeId"},{"name":"propertyName","type":"string"},{"name":"value","type":"string"}]},{"name":"setKeyframeKey","description":"Modifies the keyframe rule key text.","parameters":[{"name":"styleSheetId","$ref":"StyleSheetId"},{"name":"range","$ref":"SourceRange"},{"name":"keyText","type":"string"}],"returns":[{"name":"keyText","description":"The resulting key text after modification.","$ref":"Value"}]},{"name":"setMediaText","description":"Modifies the rule selector.","parameters":[{"name":"styleSheetId","$ref":"StyleSheetId"},{"name":"range","$ref":"SourceRange"},{"name":"text","type":"string"}],"returns":[{"name":"media","description":"The resulting CSS media rule after modification.","$ref":"CSSMedia"}]},{"name":"setRuleSelector","description":"Modifies the rule selector.","parameters":[{"name":"styleSheetId","$ref":"StyleSheetId"},{"name":"range","$ref":"SourceRange"},{"name":"selector","type":"string"}],"returns":[{"name":"selectorList","description":"The resulting selector list after modification.","$ref":"SelectorList"}]},{"name":"setStyleSheetText","description":"Sets the new stylesheet text.","parameters":[{"name":"styleSheetId","$ref":"StyleSheetId"},{"name":"text","type":"string"}],"returns":[{"name":"sourceMapURL","description":"URL of source map associated with script (if any).","optional":true,"type":"string"}]},{"name":"setStyleTexts","description":"Applies specified style edits one after another in the given order.","parameters":[{"name":"edits","type":"array","items":{"$ref":"StyleDeclarationEdit"}}],"returns":[{"name":"styles","description":"The resulting styles after modification.","type":"array","items":{"$ref":"CSSStyle"}}]},{"name":"startRuleUsageTracking","description":"Enables the selector recording."},{"name":"stopRuleUsageTracking","description":"Stop tracking rule usage and return the list of rules that were used since last call to\\n`takeCoverageDelta` (or since start of coverage instrumentation)","returns":[{"name":"ruleUsage","type":"array","items":{"$ref":"RuleUsage"}}]},{"name":"takeCoverageDelta","description":"Obtain list of rules that became used since last call to this method (or since start of coverage\\ninstrumentation)","returns":[{"name":"coverage","type":"array","items":{"$ref":"RuleUsage"}}]}],"events":[{"name":"fontsUpdated","description":"Fires whenever a web font is updated. A non-empty font parameter indicates a successfully loaded\\nweb font","parameters":[{"name":"font","description":"The web font that has loaded.","optional":true,"$ref":"FontFace"}]},{"name":"mediaQueryResultChanged","description":"Fires whenever a MediaQuery result changes (for example, after a browser window has been\\nresized.) The current implementation considers only viewport-dependent media features."},{"name":"styleSheetAdded","description":"Fired whenever an active document stylesheet is added.","parameters":[{"name":"header","description":"Added stylesheet metainfo.","$ref":"CSSStyleSheetHeader"}]},{"name":"styleSheetChanged","description":"Fired whenever a stylesheet is changed as a result of the client operation.","parameters":[{"name":"styleSheetId","$ref":"StyleSheetId"}]},{"name":"styleSheetRemoved","description":"Fired whenever an active document stylesheet is removed.","parameters":[{"name":"styleSheetId","description":"Identifier of the removed stylesheet.","$ref":"StyleSheetId"}]}]},{"domain":"CacheStorage","experimental":true,"types":[{"id":"CacheId","description":"Unique identifier of the Cache object.","type":"string"},{"id":"CachedResponseType","description":"type of HTTP response cached","type":"string","enum":["basic","cors","default","error","opaqueResponse","opaqueRedirect"]},{"id":"DataEntry","description":"Data entry.","type":"object","properties":[{"name":"requestURL","description":"Request URL.","type":"string"},{"name":"requestMethod","description":"Request method.","type":"string"},{"name":"requestHeaders","description":"Request headers","type":"array","items":{"$ref":"Header"}},{"name":"responseTime","description":"Number of seconds since epoch.","type":"number"},{"name":"responseStatus","description":"HTTP response status code.","type":"integer"},{"name":"responseStatusText","description":"HTTP response status text.","type":"string"},{"name":"responseType","description":"HTTP response type","$ref":"CachedResponseType"},{"name":"responseHeaders","description":"Response headers","type":"array","items":{"$ref":"Header"}}]},{"id":"Cache","description":"Cache identifier.","type":"object","properties":[{"name":"cacheId","description":"An opaque unique id of the cache.","$ref":"CacheId"},{"name":"securityOrigin","description":"Security origin of the cache.","type":"string"},{"name":"cacheName","description":"The name of the cache.","type":"string"}]},{"id":"Header","type":"object","properties":[{"name":"name","type":"string"},{"name":"value","type":"string"}]},{"id":"CachedResponse","description":"Cached response","type":"object","properties":[{"name":"body","description":"Entry content, base64-encoded.","type":"string"}]}],"commands":[{"name":"deleteCache","description":"Deletes a cache.","parameters":[{"name":"cacheId","description":"Id of cache for deletion.","$ref":"CacheId"}]},{"name":"deleteEntry","description":"Deletes a cache entry.","parameters":[{"name":"cacheId","description":"Id of cache where the entry will be deleted.","$ref":"CacheId"},{"name":"request","description":"URL spec of the request.","type":"string"}]},{"name":"requestCacheNames","description":"Requests cache names.","parameters":[{"name":"securityOrigin","description":"Security origin.","type":"string"}],"returns":[{"name":"caches","description":"Caches for the security origin.","type":"array","items":{"$ref":"Cache"}}]},{"name":"requestCachedResponse","description":"Fetches cache entry.","parameters":[{"name":"cacheId","description":"Id of cache that contains the entry.","$ref":"CacheId"},{"name":"requestURL","description":"URL spec of the request.","type":"string"},{"name":"requestHeaders","description":"headers of the request.","type":"array","items":{"$ref":"Header"}}],"returns":[{"name":"response","description":"Response read from the cache.","$ref":"CachedResponse"}]},{"name":"requestEntries","description":"Requests data from cache.","parameters":[{"name":"cacheId","description":"ID of cache to get entries from.","$ref":"CacheId"},{"name":"skipCount","description":"Number of records to skip.","type":"integer"},{"name":"pageSize","description":"Number of records to fetch.","type":"integer"},{"name":"pathFilter","description":"If present, only return the entries containing this substring in the path","optional":true,"type":"string"}],"returns":[{"name":"cacheDataEntries","description":"Array of object store data entries.","type":"array","items":{"$ref":"DataEntry"}},{"name":"returnCount","description":"Count of returned entries from this storage. If pathFilter is empty, it\\nis the count of all entries from this storage.","type":"number"}]}]},{"domain":"Cast","description":"A domain for interacting with Cast, Presentation API, and Remote Playback API\\nfunctionalities.","experimental":true,"types":[{"id":"Sink","type":"object","properties":[{"name":"name","type":"string"},{"name":"id","type":"string"},{"name":"session","description":"Text describing the current session. Present only if there is an active\\nsession on the sink.","optional":true,"type":"string"}]}],"commands":[{"name":"enable","description":"Starts observing for sinks that can be used for tab mirroring, and if set,\\nsinks compatible with |presentationUrl| as well. When sinks are found, a\\n|sinksUpdated| event is fired.\\nAlso starts observing for issue messages. When an issue is added or removed,\\nan |issueUpdated| event is fired.","parameters":[{"name":"presentationUrl","optional":true,"type":"string"}]},{"name":"disable","description":"Stops observing for sinks and issues."},{"name":"setSinkToUse","description":"Sets a sink to be used when the web page requests the browser to choose a\\nsink via Presentation API, Remote Playback API, or Cast SDK.","parameters":[{"name":"sinkName","type":"string"}]},{"name":"startTabMirroring","description":"Starts mirroring the tab to the sink.","parameters":[{"name":"sinkName","type":"string"}]},{"name":"stopCasting","description":"Stops the active Cast session on the sink.","parameters":[{"name":"sinkName","type":"string"}]}],"events":[{"name":"sinksUpdated","description":"This is fired whenever the list of available sinks changes. A sink is a\\ndevice or a software surface that you can cast to.","parameters":[{"name":"sinks","type":"array","items":{"$ref":"Sink"}}]},{"name":"issueUpdated","description":"This is fired whenever the outstanding issue/error message changes.\\n|issueMessage| is empty if there is no issue.","parameters":[{"name":"issueMessage","type":"string"}]}]},{"domain":"DOM","description":"This domain exposes DOM read/write operations. Each DOM Node is represented with its mirror object\\nthat has an `id`. This `id` can be used to get additional information on the Node, resolve it into\\nthe JavaScript object wrapper, etc. It is important that client receives DOM events only for the\\nnodes that are known to the client. Backend keeps track of the nodes that were sent to the client\\nand never sends the same node twice. It is client\'s responsibility to collect information about\\nthe nodes that were sent to the client.<p>Note that `iframe` owner elements will return\\ncorresponding document elements as their child nodes.</p>","dependencies":["Runtime"],"types":[{"id":"NodeId","description":"Unique DOM node identifier.","type":"integer"},{"id":"BackendNodeId","description":"Unique DOM node identifier used to reference a node that may not have been pushed to the\\nfront-end.","type":"integer"},{"id":"BackendNode","description":"Backend node with a friendly name.","type":"object","properties":[{"name":"nodeType","description":"`Node`\'s nodeType.","type":"integer"},{"name":"nodeName","description":"`Node`\'s nodeName.","type":"string"},{"name":"backendNodeId","$ref":"BackendNodeId"}]},{"id":"PseudoType","description":"Pseudo element type.","type":"string","enum":["first-line","first-letter","before","after","backdrop","selection","first-line-inherited","scrollbar","scrollbar-thumb","scrollbar-button","scrollbar-track","scrollbar-track-piece","scrollbar-corner","resizer","input-list-button"]},{"id":"ShadowRootType","description":"Shadow root type.","type":"string","enum":["user-agent","open","closed"]},{"id":"Node","description":"DOM interaction is implemented in terms of mirror objects that represent the actual DOM nodes.\\nDOMNode is a base node mirror type.","type":"object","properties":[{"name":"nodeId","description":"Node identifier that is passed into the rest of the DOM messages as the `nodeId`. Backend\\nwill only push node with given `id` once. It is aware of all requested nodes and will only\\nfire DOM events for nodes known to the client.","$ref":"NodeId"},{"name":"parentId","description":"The id of the parent node if any.","optional":true,"$ref":"NodeId"},{"name":"backendNodeId","description":"The BackendNodeId for this node.","$ref":"BackendNodeId"},{"name":"nodeType","description":"`Node`\'s nodeType.","type":"integer"},{"name":"nodeName","description":"`Node`\'s nodeName.","type":"string"},{"name":"localName","description":"`Node`\'s localName.","type":"string"},{"name":"nodeValue","description":"`Node`\'s nodeValue.","type":"string"},{"name":"childNodeCount","description":"Child count for `Container` nodes.","optional":true,"type":"integer"},{"name":"children","description":"Child nodes of this node when requested with children.","optional":true,"type":"array","items":{"$ref":"Node"}},{"name":"attributes","description":"Attributes of the `Element` node in the form of flat array `[name1, value1, name2, value2]`.","optional":true,"type":"array","items":{"type":"string"}},{"name":"documentURL","description":"Document URL that `Document` or `FrameOwner` node points to.","optional":true,"type":"string"},{"name":"baseURL","description":"Base URL that `Document` or `FrameOwner` node uses for URL completion.","optional":true,"type":"string"},{"name":"publicId","description":"`DocumentType`\'s publicId.","optional":true,"type":"string"},{"name":"systemId","description":"`DocumentType`\'s systemId.","optional":true,"type":"string"},{"name":"internalSubset","description":"`DocumentType`\'s internalSubset.","optional":true,"type":"string"},{"name":"xmlVersion","description":"`Document`\'s XML version in case of XML documents.","optional":true,"type":"string"},{"name":"name","description":"`Attr`\'s name.","optional":true,"type":"string"},{"name":"value","description":"`Attr`\'s value.","optional":true,"type":"string"},{"name":"pseudoType","description":"Pseudo element type for this node.","optional":true,"$ref":"PseudoType"},{"name":"shadowRootType","description":"Shadow root type.","optional":true,"$ref":"ShadowRootType"},{"name":"frameId","description":"Frame ID for frame owner elements.","optional":true,"$ref":"Page.FrameId"},{"name":"contentDocument","description":"Content document for frame owner elements.","optional":true,"$ref":"Node"},{"name":"shadowRoots","description":"Shadow root list for given element host.","optional":true,"type":"array","items":{"$ref":"Node"}},{"name":"templateContent","description":"Content document fragment for template elements.","optional":true,"$ref":"Node"},{"name":"pseudoElements","description":"Pseudo elements associated with this node.","optional":true,"type":"array","items":{"$ref":"Node"}},{"name":"importedDocument","description":"Import document for the HTMLImport links.","optional":true,"$ref":"Node"},{"name":"distributedNodes","description":"Distributed nodes for given insertion point.","optional":true,"type":"array","items":{"$ref":"BackendNode"}},{"name":"isSVG","description":"Whether the node is SVG.","optional":true,"type":"boolean"}]},{"id":"RGBA","description":"A structure holding an RGBA color.","type":"object","properties":[{"name":"r","description":"The red component, in the [0-255] range.","type":"integer"},{"name":"g","description":"The green component, in the [0-255] range.","type":"integer"},{"name":"b","description":"The blue component, in the [0-255] range.","type":"integer"},{"name":"a","description":"The alpha component, in the [0-1] range (default: 1).","optional":true,"type":"number"}]},{"id":"Quad","description":"An array of quad vertices, x immediately followed by y for each point, points clock-wise.","type":"array","items":{"type":"number"}},{"id":"BoxModel","description":"Box model.","type":"object","properties":[{"name":"content","description":"Content box","$ref":"Quad"},{"name":"padding","description":"Padding box","$ref":"Quad"},{"name":"border","description":"Border box","$ref":"Quad"},{"name":"margin","description":"Margin box","$ref":"Quad"},{"name":"width","description":"Node width","type":"integer"},{"name":"height","description":"Node height","type":"integer"},{"name":"shapeOutside","description":"Shape outside coordinates","optional":true,"$ref":"ShapeOutsideInfo"}]},{"id":"ShapeOutsideInfo","description":"CSS Shape Outside details.","type":"object","properties":[{"name":"bounds","description":"Shape bounds","$ref":"Quad"},{"name":"shape","description":"Shape coordinate details","type":"array","items":{"type":"any"}},{"name":"marginShape","description":"Margin shape bounds","type":"array","items":{"type":"any"}}]},{"id":"Rect","description":"Rectangle.","type":"object","properties":[{"name":"x","description":"X coordinate","type":"number"},{"name":"y","description":"Y coordinate","type":"number"},{"name":"width","description":"Rectangle width","type":"number"},{"name":"height","description":"Rectangle height","type":"number"}]}],"commands":[{"name":"collectClassNamesFromSubtree","description":"Collects class names for the node with given id and all of it\'s child nodes.","experimental":true,"parameters":[{"name":"nodeId","description":"Id of the node to collect class names.","$ref":"NodeId"}],"returns":[{"name":"classNames","description":"Class name list.","type":"array","items":{"type":"string"}}]},{"name":"copyTo","description":"Creates a deep copy of the specified node and places it into the target container before the\\ngiven anchor.","experimental":true,"parameters":[{"name":"nodeId","description":"Id of the node to copy.","$ref":"NodeId"},{"name":"targetNodeId","description":"Id of the element to drop the copy into.","$ref":"NodeId"},{"name":"insertBeforeNodeId","description":"Drop the copy before this node (if absent, the copy becomes the last child of\\n`targetNodeId`).","optional":true,"$ref":"NodeId"}],"returns":[{"name":"nodeId","description":"Id of the node clone.","$ref":"NodeId"}]},{"name":"describeNode","description":"Describes node given its id, does not require domain to be enabled. Does not start tracking any\\nobjects, can be used for automation.","parameters":[{"name":"nodeId","description":"Identifier of the node.","optional":true,"$ref":"NodeId"},{"name":"backendNodeId","description":"Identifier of the backend node.","optional":true,"$ref":"BackendNodeId"},{"name":"objectId","description":"JavaScript object id of the node wrapper.","optional":true,"$ref":"Runtime.RemoteObjectId"},{"name":"depth","description":"The maximum depth at which children should be retrieved, defaults to 1. Use -1 for the\\nentire subtree or provide an integer larger than 0.","optional":true,"type":"integer"},{"name":"pierce","description":"Whether or not iframes and shadow roots should be traversed when returning the subtree\\n(default is false).","optional":true,"type":"boolean"}],"returns":[{"name":"node","description":"Node description.","$ref":"Node"}]},{"name":"disable","description":"Disables DOM agent for the given page."},{"name":"discardSearchResults","description":"Discards search results from the session with the given id. `getSearchResults` should no longer\\nbe called for that search.","experimental":true,"parameters":[{"name":"searchId","description":"Unique search session identifier.","type":"string"}]},{"name":"enable","description":"Enables DOM agent for the given page."},{"name":"focus","description":"Focuses the given element.","parameters":[{"name":"nodeId","description":"Identifier of the node.","optional":true,"$ref":"NodeId"},{"name":"backendNodeId","description":"Identifier of the backend node.","optional":true,"$ref":"BackendNodeId"},{"name":"objectId","description":"JavaScript object id of the node wrapper.","optional":true,"$ref":"Runtime.RemoteObjectId"}]},{"name":"getAttributes","description":"Returns attributes for the specified node.","parameters":[{"name":"nodeId","description":"Id of the node to retrieve attibutes for.","$ref":"NodeId"}],"returns":[{"name":"attributes","description":"An interleaved array of node attribute names and values.","type":"array","items":{"type":"string"}}]},{"name":"getBoxModel","description":"Returns boxes for the given node.","parameters":[{"name":"nodeId","description":"Identifier of the node.","optional":true,"$ref":"NodeId"},{"name":"backendNodeId","description":"Identifier of the backend node.","optional":true,"$ref":"BackendNodeId"},{"name":"objectId","description":"JavaScript object id of the node wrapper.","optional":true,"$ref":"Runtime.RemoteObjectId"}],"returns":[{"name":"model","description":"Box model for the node.","$ref":"BoxModel"}]},{"name":"getContentQuads","description":"Returns quads that describe node position on the page. This method\\nmight return multiple quads for inline nodes.","experimental":true,"parameters":[{"name":"nodeId","description":"Identifier of the node.","optional":true,"$ref":"NodeId"},{"name":"backendNodeId","description":"Identifier of the backend node.","optional":true,"$ref":"BackendNodeId"},{"name":"objectId","description":"JavaScript object id of the node wrapper.","optional":true,"$ref":"Runtime.RemoteObjectId"}],"returns":[{"name":"quads","description":"Quads that describe node layout relative to viewport.","type":"array","items":{"$ref":"Quad"}}]},{"name":"getDocument","description":"Returns the root DOM node (and optionally the subtree) to the caller.","parameters":[{"name":"depth","description":"The maximum depth at which children should be retrieved, defaults to 1. Use -1 for the\\nentire subtree or provide an integer larger than 0.","optional":true,"type":"integer"},{"name":"pierce","description":"Whether or not iframes and shadow roots should be traversed when returning the subtree\\n(default is false).","optional":true,"type":"boolean"}],"returns":[{"name":"root","description":"Resulting node.","$ref":"Node"}]},{"name":"getFlattenedDocument","description":"Returns the root DOM node (and optionally the subtree) to the caller.","parameters":[{"name":"depth","description":"The maximum depth at which children should be retrieved, defaults to 1. Use -1 for the\\nentire subtree or provide an integer larger than 0.","optional":true,"type":"integer"},{"name":"pierce","description":"Whether or not iframes and shadow roots should be traversed when returning the subtree\\n(default is false).","optional":true,"type":"boolean"}],"returns":[{"name":"nodes","description":"Resulting node.","type":"array","items":{"$ref":"Node"}}]},{"name":"getNodeForLocation","description":"Returns node id at given location. Depending on whether DOM domain is enabled, nodeId is\\neither returned or not.","experimental":true,"parameters":[{"name":"x","description":"X coordinate.","type":"integer"},{"name":"y","description":"Y coordinate.","type":"integer"},{"name":"includeUserAgentShadowDOM","description":"False to skip to the nearest non-UA shadow root ancestor (default: false).","optional":true,"type":"boolean"}],"returns":[{"name":"backendNodeId","description":"Resulting node.","$ref":"BackendNodeId"},{"name":"nodeId","description":"Id of the node at given coordinates, only when enabled and requested document.","optional":true,"$ref":"NodeId"}]},{"name":"getOuterHTML","description":"Returns node\'s HTML markup.","parameters":[{"name":"nodeId","description":"Identifier of the node.","optional":true,"$ref":"NodeId"},{"name":"backendNodeId","description":"Identifier of the backend node.","optional":true,"$ref":"BackendNodeId"},{"name":"objectId","description":"JavaScript object id of the node wrapper.","optional":true,"$ref":"Runtime.RemoteObjectId"}],"returns":[{"name":"outerHTML","description":"Outer HTML markup.","type":"string"}]},{"name":"getRelayoutBoundary","description":"Returns the id of the nearest ancestor that is a relayout boundary.","experimental":true,"parameters":[{"name":"nodeId","description":"Id of the node.","$ref":"NodeId"}],"returns":[{"name":"nodeId","description":"Relayout boundary node id for the given node.","$ref":"NodeId"}]},{"name":"getSearchResults","description":"Returns search results from given `fromIndex` to given `toIndex` from the search with the given\\nidentifier.","experimental":true,"parameters":[{"name":"searchId","description":"Unique search session identifier.","type":"string"},{"name":"fromIndex","description":"Start index of the search result to be returned.","type":"integer"},{"name":"toIndex","description":"End index of the search result to be returned.","type":"integer"}],"returns":[{"name":"nodeIds","description":"Ids of the search result nodes.","type":"array","items":{"$ref":"NodeId"}}]},{"name":"hideHighlight","description":"Hides any highlight.","redirect":"Overlay"},{"name":"highlightNode","description":"Highlights DOM node.","redirect":"Overlay"},{"name":"highlightRect","description":"Highlights given rectangle.","redirect":"Overlay"},{"name":"markUndoableState","description":"Marks last undoable state.","experimental":true},{"name":"moveTo","description":"Moves node into the new container, places it before the given anchor.","parameters":[{"name":"nodeId","description":"Id of the node to move.","$ref":"NodeId"},{"name":"targetNodeId","description":"Id of the element to drop the moved node into.","$ref":"NodeId"},{"name":"insertBeforeNodeId","description":"Drop node before this one (if absent, the moved node becomes the last child of\\n`targetNodeId`).","optional":true,"$ref":"NodeId"}],"returns":[{"name":"nodeId","description":"New id of the moved node.","$ref":"NodeId"}]},{"name":"performSearch","description":"Searches for a given string in the DOM tree. Use `getSearchResults` to access search results or\\n`cancelSearch` to end this search session.","experimental":true,"parameters":[{"name":"query","description":"Plain text or query selector or XPath search query.","type":"string"},{"name":"includeUserAgentShadowDOM","description":"True to search in user agent shadow DOM.","optional":true,"type":"boolean"}],"returns":[{"name":"searchId","description":"Unique search session identifier.","type":"string"},{"name":"resultCount","description":"Number of search results.","type":"integer"}]},{"name":"pushNodeByPathToFrontend","description":"Requests that the node is sent to the caller given its path. // FIXME, use XPath","experimental":true,"parameters":[{"name":"path","description":"Path to node in the proprietary format.","type":"string"}],"returns":[{"name":"nodeId","description":"Id of the node for given path.","$ref":"NodeId"}]},{"name":"pushNodesByBackendIdsToFrontend","description":"Requests that a batch of nodes is sent to the caller given their backend node ids.","experimental":true,"parameters":[{"name":"backendNodeIds","description":"The array of backend node ids.","type":"array","items":{"$ref":"BackendNodeId"}}],"returns":[{"name":"nodeIds","description":"The array of ids of pushed nodes that correspond to the backend ids specified in\\nbackendNodeIds.","type":"array","items":{"$ref":"NodeId"}}]},{"name":"querySelector","description":"Executes `querySelector` on a given node.","parameters":[{"name":"nodeId","description":"Id of the node to query upon.","$ref":"NodeId"},{"name":"selector","description":"Selector string.","type":"string"}],"returns":[{"name":"nodeId","description":"Query selector result.","$ref":"NodeId"}]},{"name":"querySelectorAll","description":"Executes `querySelectorAll` on a given node.","parameters":[{"name":"nodeId","description":"Id of the node to query upon.","$ref":"NodeId"},{"name":"selector","description":"Selector string.","type":"string"}],"returns":[{"name":"nodeIds","description":"Query selector result.","type":"array","items":{"$ref":"NodeId"}}]},{"name":"redo","description":"Re-does the last undone action.","experimental":true},{"name":"removeAttribute","description":"Removes attribute with given name from an element with given id.","parameters":[{"name":"nodeId","description":"Id of the element to remove attribute from.","$ref":"NodeId"},{"name":"name","description":"Name of the attribute to remove.","type":"string"}]},{"name":"removeNode","description":"Removes node with given id.","parameters":[{"name":"nodeId","description":"Id of the node to remove.","$ref":"NodeId"}]},{"name":"requestChildNodes","description":"Requests that children of the node with given id are returned to the caller in form of\\n`setChildNodes` events where not only immediate children are retrieved, but all children down to\\nthe specified depth.","parameters":[{"name":"nodeId","description":"Id of the node to get children for.","$ref":"NodeId"},{"name":"depth","description":"The maximum depth at which children should be retrieved, defaults to 1. Use -1 for the\\nentire subtree or provide an integer larger than 0.","optional":true,"type":"integer"},{"name":"pierce","description":"Whether or not iframes and shadow roots should be traversed when returning the sub-tree\\n(default is false).","optional":true,"type":"boolean"}]},{"name":"requestNode","description":"Requests that the node is sent to the caller given the JavaScript node object reference. All\\nnodes that form the path from the node to the root are also sent to the client as a series of\\n`setChildNodes` notifications.","parameters":[{"name":"objectId","description":"JavaScript object id to convert into node.","$ref":"Runtime.RemoteObjectId"}],"returns":[{"name":"nodeId","description":"Node id for given object.","$ref":"NodeId"}]},{"name":"resolveNode","description":"Resolves the JavaScript node object for a given NodeId or BackendNodeId.","parameters":[{"name":"nodeId","description":"Id of the node to resolve.","optional":true,"$ref":"NodeId"},{"name":"backendNodeId","description":"Backend identifier of the node to resolve.","optional":true,"$ref":"DOM.BackendNodeId"},{"name":"objectGroup","description":"Symbolic group name that can be used to release multiple objects.","optional":true,"type":"string"},{"name":"executionContextId","description":"Execution context in which to resolve the node.","optional":true,"$ref":"Runtime.ExecutionContextId"}],"returns":[{"name":"object","description":"JavaScript object wrapper for given node.","$ref":"Runtime.RemoteObject"}]},{"name":"setAttributeValue","description":"Sets attribute for an element with given id.","parameters":[{"name":"nodeId","description":"Id of the element to set attribute for.","$ref":"NodeId"},{"name":"name","description":"Attribute name.","type":"string"},{"name":"value","description":"Attribute value.","type":"string"}]},{"name":"setAttributesAsText","description":"Sets attributes on element with given id. This method is useful when user edits some existing\\nattribute value and types in several attribute name/value pairs.","parameters":[{"name":"nodeId","description":"Id of the element to set attributes for.","$ref":"NodeId"},{"name":"text","description":"Text with a number of attributes. Will parse this text using HTML parser.","type":"string"},{"name":"name","description":"Attribute name to replace with new attributes derived from text in case text parsed\\nsuccessfully.","optional":true,"type":"string"}]},{"name":"setFileInputFiles","description":"Sets files for the given file input element.","parameters":[{"name":"files","description":"Array of file paths to set.","type":"array","items":{"type":"string"}},{"name":"nodeId","description":"Identifier of the node.","optional":true,"$ref":"NodeId"},{"name":"backendNodeId","description":"Identifier of the backend node.","optional":true,"$ref":"BackendNodeId"},{"name":"objectId","description":"JavaScript object id of the node wrapper.","optional":true,"$ref":"Runtime.RemoteObjectId"}]},{"name":"getFileInfo","description":"Returns file information for the given\\nFile wrapper.","experimental":true,"parameters":[{"name":"objectId","description":"JavaScript object id of the node wrapper.","$ref":"Runtime.RemoteObjectId"}],"returns":[{"name":"path","type":"string"}]},{"name":"setInspectedNode","description":"Enables console to refer to the node with given id via $x (see Command Line API for more details\\n$x functions).","experimental":true,"parameters":[{"name":"nodeId","description":"DOM node id to be accessible by means of $x command line API.","$ref":"NodeId"}]},{"name":"setNodeName","description":"Sets node name for a node with given id.","parameters":[{"name":"nodeId","description":"Id of the node to set name for.","$ref":"NodeId"},{"name":"name","description":"New node\'s name.","type":"string"}],"returns":[{"name":"nodeId","description":"New node\'s id.","$ref":"NodeId"}]},{"name":"setNodeValue","description":"Sets node value for a node with given id.","parameters":[{"name":"nodeId","description":"Id of the node to set value for.","$ref":"NodeId"},{"name":"value","description":"New node\'s value.","type":"string"}]},{"name":"setOuterHTML","description":"Sets node HTML markup, returns new node id.","parameters":[{"name":"nodeId","description":"Id of the node to set markup for.","$ref":"NodeId"},{"name":"outerHTML","description":"Outer HTML markup to set.","type":"string"}]},{"name":"undo","description":"Undoes the last performed action.","experimental":true},{"name":"getFrameOwner","description":"Returns iframe node that owns iframe with the given domain.","experimental":true,"parameters":[{"name":"frameId","$ref":"Page.FrameId"}],"returns":[{"name":"backendNodeId","description":"Resulting node.","$ref":"BackendNodeId"},{"name":"nodeId","description":"Id of the node at given coordinates, only when enabled and requested document.","optional":true,"$ref":"NodeId"}]}],"events":[{"name":"attributeModified","description":"Fired when `Element`\'s attribute is modified.","parameters":[{"name":"nodeId","description":"Id of the node that has changed.","$ref":"NodeId"},{"name":"name","description":"Attribute name.","type":"string"},{"name":"value","description":"Attribute value.","type":"string"}]},{"name":"attributeRemoved","description":"Fired when `Element`\'s attribute is removed.","parameters":[{"name":"nodeId","description":"Id of the node that has changed.","$ref":"NodeId"},{"name":"name","description":"A ttribute name.","type":"string"}]},{"name":"characterDataModified","description":"Mirrors `DOMCharacterDataModified` event.","parameters":[{"name":"nodeId","description":"Id of the node that has changed.","$ref":"NodeId"},{"name":"characterData","description":"New text value.","type":"string"}]},{"name":"childNodeCountUpdated","description":"Fired when `Container`\'s child node count has changed.","parameters":[{"name":"nodeId","description":"Id of the node that has changed.","$ref":"NodeId"},{"name":"childNodeCount","description":"New node count.","type":"integer"}]},{"name":"childNodeInserted","description":"Mirrors `DOMNodeInserted` event.","parameters":[{"name":"parentNodeId","description":"Id of the node that has changed.","$ref":"NodeId"},{"name":"previousNodeId","description":"If of the previous siblint.","$ref":"NodeId"},{"name":"node","description":"Inserted node data.","$ref":"Node"}]},{"name":"childNodeRemoved","description":"Mirrors `DOMNodeRemoved` event.","parameters":[{"name":"parentNodeId","description":"Parent id.","$ref":"NodeId"},{"name":"nodeId","description":"Id of the node that has been removed.","$ref":"NodeId"}]},{"name":"distributedNodesUpdated","description":"Called when distrubution is changed.","experimental":true,"parameters":[{"name":"insertionPointId","description":"Insertion point where distrubuted nodes were updated.","$ref":"NodeId"},{"name":"distributedNodes","description":"Distributed nodes for given insertion point.","type":"array","items":{"$ref":"BackendNode"}}]},{"name":"documentUpdated","description":"Fired when `Document` has been totally updated. Node ids are no longer valid."},{"name":"inlineStyleInvalidated","description":"Fired when `Element`\'s inline style is modified via a CSS property modification.","experimental":true,"parameters":[{"name":"nodeIds","description":"Ids of the nodes for which the inline styles have been invalidated.","type":"array","items":{"$ref":"NodeId"}}]},{"name":"pseudoElementAdded","description":"Called when a pseudo element is added to an element.","experimental":true,"parameters":[{"name":"parentId","description":"Pseudo element\'s parent element id.","$ref":"NodeId"},{"name":"pseudoElement","description":"The added pseudo element.","$ref":"Node"}]},{"name":"pseudoElementRemoved","description":"Called when a pseudo element is removed from an element.","experimental":true,"parameters":[{"name":"parentId","description":"Pseudo element\'s parent element id.","$ref":"NodeId"},{"name":"pseudoElementId","description":"The removed pseudo element id.","$ref":"NodeId"}]},{"name":"setChildNodes","description":"Fired when backend wants to provide client with the missing DOM structure. This happens upon\\nmost of the calls requesting node ids.","parameters":[{"name":"parentId","description":"Parent node id to populate with children.","$ref":"NodeId"},{"name":"nodes","description":"Child nodes array.","type":"array","items":{"$ref":"Node"}}]},{"name":"shadowRootPopped","description":"Called when shadow root is popped from the element.","experimental":true,"parameters":[{"name":"hostId","description":"Host element id.","$ref":"NodeId"},{"name":"rootId","description":"Shadow root id.","$ref":"NodeId"}]},{"name":"shadowRootPushed","description":"Called when shadow root is pushed into the element.","experimental":true,"parameters":[{"name":"hostId","description":"Host element id.","$ref":"NodeId"},{"name":"root","description":"Shadow root.","$ref":"Node"}]}]},{"domain":"DOMDebugger","description":"DOM debugging allows setting breakpoints on particular DOM operations and events. JavaScript\\nexecution will stop on these operations as if there was a regular breakpoint set.","dependencies":["DOM","Debugger","Runtime"],"types":[{"id":"DOMBreakpointType","description":"DOM breakpoint type.","type":"string","enum":["subtree-modified","attribute-modified","node-removed"]},{"id":"EventListener","description":"Object event listener.","type":"object","properties":[{"name":"type","description":"`EventListener`\'s type.","type":"string"},{"name":"useCapture","description":"`EventListener`\'s useCapture.","type":"boolean"},{"name":"passive","description":"`EventListener`\'s passive flag.","type":"boolean"},{"name":"once","description":"`EventListener`\'s once flag.","type":"boolean"},{"name":"scriptId","description":"Script id of the handler code.","$ref":"Runtime.ScriptId"},{"name":"lineNumber","description":"Line number in the script (0-based).","type":"integer"},{"name":"columnNumber","description":"Column number in the script (0-based).","type":"integer"},{"name":"handler","description":"Event handler function value.","optional":true,"$ref":"Runtime.RemoteObject"},{"name":"originalHandler","description":"Event original handler function value.","optional":true,"$ref":"Runtime.RemoteObject"},{"name":"backendNodeId","description":"Node the listener is added to (if any).","optional":true,"$ref":"DOM.BackendNodeId"}]}],"commands":[{"name":"getEventListeners","description":"Returns event listeners of the given object.","parameters":[{"name":"objectId","description":"Identifier of the object to return listeners for.","$ref":"Runtime.RemoteObjectId"},{"name":"depth","description":"The maximum depth at which Node children should be retrieved, defaults to 1. Use -1 for the\\nentire subtree or provide an integer larger than 0.","optional":true,"type":"integer"},{"name":"pierce","description":"Whether or not iframes and shadow roots should be traversed when returning the subtree\\n(default is false). Reports listeners for all contexts if pierce is enabled.","optional":true,"type":"boolean"}],"returns":[{"name":"listeners","description":"Array of relevant listeners.","type":"array","items":{"$ref":"EventListener"}}]},{"name":"removeDOMBreakpoint","description":"Removes DOM breakpoint that was set using `setDOMBreakpoint`.","parameters":[{"name":"nodeId","description":"Identifier of the node to remove breakpoint from.","$ref":"DOM.NodeId"},{"name":"type","description":"Type of the breakpoint to remove.","$ref":"DOMBreakpointType"}]},{"name":"removeEventListenerBreakpoint","description":"Removes breakpoint on particular DOM event.","parameters":[{"name":"eventName","description":"Event name.","type":"string"},{"name":"targetName","description":"EventTarget interface name.","experimental":true,"optional":true,"type":"string"}]},{"name":"removeInstrumentationBreakpoint","description":"Removes breakpoint on particular native event.","experimental":true,"parameters":[{"name":"eventName","description":"Instrumentation name to stop on.","type":"string"}]},{"name":"removeXHRBreakpoint","description":"Removes breakpoint from XMLHttpRequest.","parameters":[{"name":"url","description":"Resource URL substring.","type":"string"}]},{"name":"setDOMBreakpoint","description":"Sets breakpoint on particular operation with DOM.","parameters":[{"name":"nodeId","description":"Identifier of the node to set breakpoint on.","$ref":"DOM.NodeId"},{"name":"type","description":"Type of the operation to stop upon.","$ref":"DOMBreakpointType"}]},{"name":"setEventListenerBreakpoint","description":"Sets breakpoint on particular DOM event.","parameters":[{"name":"eventName","description":"DOM Event name to stop on (any DOM event will do).","type":"string"},{"name":"targetName","description":"EventTarget interface name to stop on. If equal to `\\"*\\"` or not provided, will stop on any\\nEventTarget.","experimental":true,"optional":true,"type":"string"}]},{"name":"setInstrumentationBreakpoint","description":"Sets breakpoint on particular native event.","experimental":true,"parameters":[{"name":"eventName","description":"Instrumentation name to stop on.","type":"string"}]},{"name":"setXHRBreakpoint","description":"Sets breakpoint on XMLHttpRequest.","parameters":[{"name":"url","description":"Resource URL substring. All XHRs having this substring in the URL will get stopped upon.","type":"string"}]}]},{"domain":"DOMSnapshot","description":"This domain facilitates obtaining document snapshots with DOM, layout, and style information.","experimental":true,"dependencies":["CSS","DOM","DOMDebugger","Page"],"types":[{"id":"DOMNode","description":"A Node in the DOM tree.","type":"object","properties":[{"name":"nodeType","description":"`Node`\'s nodeType.","type":"integer"},{"name":"nodeName","description":"`Node`\'s nodeName.","type":"string"},{"name":"nodeValue","description":"`Node`\'s nodeValue.","type":"string"},{"name":"textValue","description":"Only set for textarea elements, contains the text value.","optional":true,"type":"string"},{"name":"inputValue","description":"Only set for input elements, contains the input\'s associated text value.","optional":true,"type":"string"},{"name":"inputChecked","description":"Only set for radio and checkbox input elements, indicates if the element has been checked","optional":true,"type":"boolean"},{"name":"optionSelected","description":"Only set for option elements, indicates if the element has been selected","optional":true,"type":"boolean"},{"name":"backendNodeId","description":"`Node`\'s id, corresponds to DOM.Node.backendNodeId.","$ref":"DOM.BackendNodeId"},{"name":"childNodeIndexes","description":"The indexes of the node\'s child nodes in the `domNodes` array returned by `getSnapshot`, if\\nany.","optional":true,"type":"array","items":{"type":"integer"}},{"name":"attributes","description":"Attributes of an `Element` node.","optional":true,"type":"array","items":{"$ref":"NameValue"}},{"name":"pseudoElementIndexes","description":"Indexes of pseudo elements associated with this node in the `domNodes` array returned by\\n`getSnapshot`, if any.","optional":true,"type":"array","items":{"type":"integer"}},{"name":"layoutNodeIndex","description":"The index of the node\'s related layout tree node in the `layoutTreeNodes` array returned by\\n`getSnapshot`, if any.","optional":true,"type":"integer"},{"name":"documentURL","description":"Document URL that `Document` or `FrameOwner` node points to.","optional":true,"type":"string"},{"name":"baseURL","description":"Base URL that `Document` or `FrameOwner` node uses for URL completion.","optional":true,"type":"string"},{"name":"contentLanguage","description":"Only set for documents, contains the document\'s content language.","optional":true,"type":"string"},{"name":"documentEncoding","description":"Only set for documents, contains the document\'s character set encoding.","optional":true,"type":"string"},{"name":"publicId","description":"`DocumentType` node\'s publicId.","optional":true,"type":"string"},{"name":"systemId","description":"`DocumentType` node\'s systemId.","optional":true,"type":"string"},{"name":"frameId","description":"Frame ID for frame owner elements and also for the document node.","optional":true,"$ref":"Page.FrameId"},{"name":"contentDocumentIndex","description":"The index of a frame owner element\'s content document in the `domNodes` array returned by\\n`getSnapshot`, if any.","optional":true,"type":"integer"},{"name":"pseudoType","description":"Type of a pseudo element node.","optional":true,"$ref":"DOM.PseudoType"},{"name":"shadowRootType","description":"Shadow root type.","optional":true,"$ref":"DOM.ShadowRootType"},{"name":"isClickable","description":"Whether this DOM node responds to mouse clicks. This includes nodes that have had click\\nevent listeners attached via JavaScript as well as anchor tags that naturally navigate when\\nclicked.","optional":true,"type":"boolean"},{"name":"eventListeners","description":"Details of the node\'s event listeners, if any.","optional":true,"type":"array","items":{"$ref":"DOMDebugger.EventListener"}},{"name":"currentSourceURL","description":"The selected url for nodes with a srcset attribute.","optional":true,"type":"string"},{"name":"originURL","description":"The url of the script (if any) that generates this node.","optional":true,"type":"string"},{"name":"scrollOffsetX","description":"Scroll offsets, set when this node is a Document.","optional":true,"type":"number"},{"name":"scrollOffsetY","optional":true,"type":"number"}]},{"id":"InlineTextBox","description":"Details of post layout rendered text positions. The exact layout should not be regarded as\\nstable and may change between versions.","type":"object","properties":[{"name":"boundingBox","description":"The bounding box in document coordinates. Note that scroll offset of the document is ignored.","$ref":"DOM.Rect"},{"name":"startCharacterIndex","description":"The starting index in characters, for this post layout textbox substring. Characters that\\nwould be represented as a surrogate pair in UTF-16 have length 2.","type":"integer"},{"name":"numCharacters","description":"The number of characters in this post layout textbox substring. Characters that would be\\nrepresented as a surrogate pair in UTF-16 have length 2.","type":"integer"}]},{"id":"LayoutTreeNode","description":"Details of an element in the DOM tree with a LayoutObject.","type":"object","properties":[{"name":"domNodeIndex","description":"The index of the related DOM node in the `domNodes` array returned by `getSnapshot`.","type":"integer"},{"name":"boundingBox","description":"The bounding box in document coordinates. Note that scroll offset of the document is ignored.","$ref":"DOM.Rect"},{"name":"layoutText","description":"Contents of the LayoutText, if any.","optional":true,"type":"string"},{"name":"inlineTextNodes","description":"The post-layout inline text nodes, if any.","optional":true,"type":"array","items":{"$ref":"InlineTextBox"}},{"name":"styleIndex","description":"Index into the `computedStyles` array returned by `getSnapshot`.","optional":true,"type":"integer"},{"name":"paintOrder","description":"Global paint order index, which is determined by the stacking order of the nodes. Nodes\\nthat are painted together will have the same index. Only provided if includePaintOrder in\\ngetSnapshot was true.","optional":true,"type":"integer"},{"name":"isStackingContext","description":"Set to true to indicate the element begins a new stacking context.","optional":true,"type":"boolean"}]},{"id":"ComputedStyle","description":"A subset of the full ComputedStyle as defined by the request whitelist.","type":"object","properties":[{"name":"properties","description":"Name/value pairs of computed style properties.","type":"array","items":{"$ref":"NameValue"}}]},{"id":"NameValue","description":"A name/value pair.","type":"object","properties":[{"name":"name","description":"Attribute/property name.","type":"string"},{"name":"value","description":"Attribute/property value.","type":"string"}]},{"id":"StringIndex","description":"Index of the string in the strings table.","type":"integer"},{"id":"ArrayOfStrings","description":"Index of the string in the strings table.","type":"array","items":{"$ref":"StringIndex"}},{"id":"RareStringData","description":"Data that is only present on rare nodes.","type":"object","properties":[{"name":"index","type":"array","items":{"type":"integer"}},{"name":"value","type":"array","items":{"$ref":"StringIndex"}}]},{"id":"RareBooleanData","type":"object","properties":[{"name":"index","type":"array","items":{"type":"integer"}}]},{"id":"RareIntegerData","type":"object","properties":[{"name":"index","type":"array","items":{"type":"integer"}},{"name":"value","type":"array","items":{"type":"integer"}}]},{"id":"Rectangle","type":"array","items":{"type":"number"}},{"id":"DocumentSnapshot","description":"Document snapshot.","type":"object","properties":[{"name":"documentURL","description":"Document URL that `Document` or `FrameOwner` node points to.","$ref":"StringIndex"},{"name":"baseURL","description":"Base URL that `Document` or `FrameOwner` node uses for URL completion.","$ref":"StringIndex"},{"name":"contentLanguage","description":"Contains the document\'s content language.","$ref":"StringIndex"},{"name":"encodingName","description":"Contains the document\'s character set encoding.","$ref":"StringIndex"},{"name":"publicId","description":"`DocumentType` node\'s publicId.","$ref":"StringIndex"},{"name":"systemId","description":"`DocumentType` node\'s systemId.","$ref":"StringIndex"},{"name":"frameId","description":"Frame ID for frame owner elements and also for the document node.","$ref":"StringIndex"},{"name":"nodes","description":"A table with dom nodes.","$ref":"NodeTreeSnapshot"},{"name":"layout","description":"The nodes in the layout tree.","$ref":"LayoutTreeSnapshot"},{"name":"textBoxes","description":"The post-layout inline text nodes.","$ref":"TextBoxSnapshot"},{"name":"scrollOffsetX","description":"Horizontal scroll offset.","optional":true,"type":"number"},{"name":"scrollOffsetY","description":"Vertical scroll offset.","optional":true,"type":"number"}]},{"id":"NodeTreeSnapshot","description":"Table containing nodes.","type":"object","properties":[{"name":"parentIndex","description":"Parent node index.","optional":true,"type":"array","items":{"type":"integer"}},{"name":"nodeType","description":"`Node`\'s nodeType.","optional":true,"type":"array","items":{"type":"integer"}},{"name":"nodeName","description":"`Node`\'s nodeName.","optional":true,"type":"array","items":{"$ref":"StringIndex"}},{"name":"nodeValue","description":"`Node`\'s nodeValue.","optional":true,"type":"array","items":{"$ref":"StringIndex"}},{"name":"backendNodeId","description":"`Node`\'s id, corresponds to DOM.Node.backendNodeId.","optional":true,"type":"array","items":{"$ref":"DOM.BackendNodeId"}},{"name":"attributes","description":"Attributes of an `Element` node. Flatten name, value pairs.","optional":true,"type":"array","items":{"$ref":"ArrayOfStrings"}},{"name":"textValue","description":"Only set for textarea elements, contains the text value.","optional":true,"$ref":"RareStringData"},{"name":"inputValue","description":"Only set for input elements, contains the input\'s associated text value.","optional":true,"$ref":"RareStringData"},{"name":"inputChecked","description":"Only set for radio and checkbox input elements, indicates if the element has been checked","optional":true,"$ref":"RareBooleanData"},{"name":"optionSelected","description":"Only set for option elements, indicates if the element has been selected","optional":true,"$ref":"RareBooleanData"},{"name":"contentDocumentIndex","description":"The index of the document in the list of the snapshot documents.","optional":true,"$ref":"RareIntegerData"},{"name":"pseudoType","description":"Type of a pseudo element node.","optional":true,"$ref":"RareStringData"},{"name":"isClickable","description":"Whether this DOM node responds to mouse clicks. This includes nodes that have had click\\nevent listeners attached via JavaScript as well as anchor tags that naturally navigate when\\nclicked.","optional":true,"$ref":"RareBooleanData"},{"name":"currentSourceURL","description":"The selected url for nodes with a srcset attribute.","optional":true,"$ref":"RareStringData"},{"name":"originURL","description":"The url of the script (if any) that generates this node.","optional":true,"$ref":"RareStringData"}]},{"id":"LayoutTreeSnapshot","description":"Table of details of an element in the DOM tree with a LayoutObject.","type":"object","properties":[{"name":"nodeIndex","description":"Index of the corresponding node in the `NodeTreeSnapshot` array returned by `captureSnapshot`.","type":"array","items":{"type":"integer"}},{"name":"styles","description":"Array of indexes specifying computed style strings, filtered according to the `computedStyles` parameter passed to `captureSnapshot`.","type":"array","items":{"$ref":"ArrayOfStrings"}},{"name":"bounds","description":"The absolute position bounding box.","type":"array","items":{"$ref":"Rectangle"}},{"name":"text","description":"Contents of the LayoutText, if any.","type":"array","items":{"$ref":"StringIndex"}},{"name":"stackingContexts","description":"Stacking context information.","$ref":"RareBooleanData"},{"name":"offsetRects","description":"The offset rect of nodes. Only available when includeDOMRects is set to true","optional":true,"type":"array","items":{"$ref":"Rectangle"}},{"name":"scrollRects","description":"The scroll rect of nodes. Only available when includeDOMRects is set to true","optional":true,"type":"array","items":{"$ref":"Rectangle"}},{"name":"clientRects","description":"The client rect of nodes. Only available when includeDOMRects is set to true","optional":true,"type":"array","items":{"$ref":"Rectangle"}}]},{"id":"TextBoxSnapshot","description":"Table of details of the post layout rendered text positions. The exact layout should not be regarded as\\nstable and may change between versions.","type":"object","properties":[{"name":"layoutIndex","description":"Index of the layout tree node that owns this box collection.","type":"array","items":{"type":"integer"}},{"name":"bounds","description":"The absolute position bounding box.","type":"array","items":{"$ref":"Rectangle"}},{"name":"start","description":"The starting index in characters, for this post layout textbox substring. Characters that\\nwould be represented as a surrogate pair in UTF-16 have length 2.","type":"array","items":{"type":"integer"}},{"name":"length","description":"The number of characters in this post layout textbox substring. Characters that would be\\nrepresented as a surrogate pair in UTF-16 have length 2.","type":"array","items":{"type":"integer"}}]}],"commands":[{"name":"disable","description":"Disables DOM snapshot agent for the given page."},{"name":"enable","description":"Enables DOM snapshot agent for the given page."},{"name":"getSnapshot","description":"Returns a document snapshot, including the full DOM tree of the root node (including iframes,\\ntemplate contents, and imported documents) in a flattened array, as well as layout and\\nwhite-listed computed style information for the nodes. Shadow DOM in the returned DOM tree is\\nflattened.","deprecated":true,"parameters":[{"name":"computedStyleWhitelist","description":"Whitelist of computed styles to return.","type":"array","items":{"type":"string"}},{"name":"includeEventListeners","description":"Whether or not to retrieve details of DOM listeners (default false).","optional":true,"type":"boolean"},{"name":"includePaintOrder","description":"Whether to determine and include the paint order index of LayoutTreeNodes (default false).","optional":true,"type":"boolean"},{"name":"includeUserAgentShadowTree","description":"Whether to include UA shadow tree in the snapshot (default false).","optional":true,"type":"boolean"}],"returns":[{"name":"domNodes","description":"The nodes in the DOM tree. The DOMNode at index 0 corresponds to the root document.","type":"array","items":{"$ref":"DOMNode"}},{"name":"layoutTreeNodes","description":"The nodes in the layout tree.","type":"array","items":{"$ref":"LayoutTreeNode"}},{"name":"computedStyles","description":"Whitelisted ComputedStyle properties for each node in the layout tree.","type":"array","items":{"$ref":"ComputedStyle"}}]},{"name":"captureSnapshot","description":"Returns a document snapshot, including the full DOM tree of the root node (including iframes,\\ntemplate contents, and imported documents) in a flattened array, as well as layout and\\nwhite-listed computed style information for the nodes. Shadow DOM in the returned DOM tree is\\nflattened.","parameters":[{"name":"computedStyles","description":"Whitelist of computed styles to return.","type":"array","items":{"type":"string"}},{"name":"includeDOMRects","description":"Whether to include DOM rectangles (offsetRects, clientRects, scrollRects) into the snapshot","optional":true,"type":"boolean"}],"returns":[{"name":"documents","description":"The nodes in the DOM tree. The DOMNode at index 0 corresponds to the root document.","type":"array","items":{"$ref":"DocumentSnapshot"}},{"name":"strings","description":"Shared string table that all string properties refer to with indexes.","type":"array","items":{"type":"string"}}]}]},{"domain":"DOMStorage","description":"Query and modify DOM storage.","experimental":true,"types":[{"id":"StorageId","description":"DOM Storage identifier.","type":"object","properties":[{"name":"securityOrigin","description":"Security origin for the storage.","type":"string"},{"name":"isLocalStorage","description":"Whether the storage is local storage (not session storage).","type":"boolean"}]},{"id":"Item","description":"DOM Storage item.","type":"array","items":{"type":"string"}}],"commands":[{"name":"clear","parameters":[{"name":"storageId","$ref":"StorageId"}]},{"name":"disable","description":"Disables storage tracking, prevents storage events from being sent to the client."},{"name":"enable","description":"Enables storage tracking, storage events will now be delivered to the client."},{"name":"getDOMStorageItems","parameters":[{"name":"storageId","$ref":"StorageId"}],"returns":[{"name":"entries","type":"array","items":{"$ref":"Item"}}]},{"name":"removeDOMStorageItem","parameters":[{"name":"storageId","$ref":"StorageId"},{"name":"key","type":"string"}]},{"name":"setDOMStorageItem","parameters":[{"name":"storageId","$ref":"StorageId"},{"name":"key","type":"string"},{"name":"value","type":"string"}]}],"events":[{"name":"domStorageItemAdded","parameters":[{"name":"storageId","$ref":"StorageId"},{"name":"key","type":"string"},{"name":"newValue","type":"string"}]},{"name":"domStorageItemRemoved","parameters":[{"name":"storageId","$ref":"StorageId"},{"name":"key","type":"string"}]},{"name":"domStorageItemUpdated","parameters":[{"name":"storageId","$ref":"StorageId"},{"name":"key","type":"string"},{"name":"oldValue","type":"string"},{"name":"newValue","type":"string"}]},{"name":"domStorageItemsCleared","parameters":[{"name":"storageId","$ref":"StorageId"}]}]},{"domain":"Database","experimental":true,"types":[{"id":"DatabaseId","description":"Unique identifier of Database object.","type":"string"},{"id":"Database","description":"Database object.","type":"object","properties":[{"name":"id","description":"Database ID.","$ref":"DatabaseId"},{"name":"domain","description":"Database domain.","type":"string"},{"name":"name","description":"Database name.","type":"string"},{"name":"version","description":"Database version.","type":"string"}]},{"id":"Error","description":"Database error.","type":"object","properties":[{"name":"message","description":"Error message.","type":"string"},{"name":"code","description":"Error code.","type":"integer"}]}],"commands":[{"name":"disable","description":"Disables database tracking, prevents database events from being sent to the client."},{"name":"enable","description":"Enables database tracking, database events will now be delivered to the client."},{"name":"executeSQL","parameters":[{"name":"databaseId","$ref":"DatabaseId"},{"name":"query","type":"string"}],"returns":[{"name":"columnNames","optional":true,"type":"array","items":{"type":"string"}},{"name":"values","optional":true,"type":"array","items":{"type":"any"}},{"name":"sqlError","optional":true,"$ref":"Error"}]},{"name":"getDatabaseTableNames","parameters":[{"name":"databaseId","$ref":"DatabaseId"}],"returns":[{"name":"tableNames","type":"array","items":{"type":"string"}}]}],"events":[{"name":"addDatabase","parameters":[{"name":"database","$ref":"Database"}]}]},{"domain":"DeviceOrientation","experimental":true,"commands":[{"name":"clearDeviceOrientationOverride","description":"Clears the overridden Device Orientation."},{"name":"setDeviceOrientationOverride","description":"Overrides the Device Orientation.","parameters":[{"name":"alpha","description":"Mock alpha","type":"number"},{"name":"beta","description":"Mock beta","type":"number"},{"name":"gamma","description":"Mock gamma","type":"number"}]}]},{"domain":"Emulation","description":"This domain emulates different environments for the page.","dependencies":["DOM","Page","Runtime"],"types":[{"id":"ScreenOrientation","description":"Screen orientation.","type":"object","properties":[{"name":"type","description":"Orientation type.","type":"string","enum":["portraitPrimary","portraitSecondary","landscapePrimary","landscapeSecondary"]},{"name":"angle","description":"Orientation angle.","type":"integer"}]},{"id":"VirtualTimePolicy","description":"advance: If the scheduler runs out of immediate work, the virtual time base may fast forward to\\nallow the next delayed task (if any) to run; pause: The virtual time base may not advance;\\npauseIfNetworkFetchesPending: The virtual time base may not advance if there are any pending\\nresource fetches.","experimental":true,"type":"string","enum":["advance","pause","pauseIfNetworkFetchesPending"]}],"commands":[{"name":"canEmulate","description":"Tells whether emulation is supported.","returns":[{"name":"result","description":"True if emulation is supported.","type":"boolean"}]},{"name":"clearDeviceMetricsOverride","description":"Clears the overriden device metrics."},{"name":"clearGeolocationOverride","description":"Clears the overriden Geolocation Position and Error."},{"name":"resetPageScaleFactor","description":"Requests that page scale factor is reset to initial values.","experimental":true},{"name":"setFocusEmulationEnabled","description":"Enables or disables simulating a focused and active page.","experimental":true,"parameters":[{"name":"enabled","description":"Whether to enable to disable focus emulation.","type":"boolean"}]},{"name":"setCPUThrottlingRate","description":"Enables CPU throttling to emulate slow CPUs.","experimental":true,"parameters":[{"name":"rate","description":"Throttling rate as a slowdown factor (1 is no throttle, 2 is 2x slowdown, etc).","type":"number"}]},{"name":"setDefaultBackgroundColorOverride","description":"Sets or clears an override of the default background color of the frame. This override is used\\nif the content does not specify one.","parameters":[{"name":"color","description":"RGBA of the default background color. If not specified, any existing override will be\\ncleared.","optional":true,"$ref":"DOM.RGBA"}]},{"name":"setDeviceMetricsOverride","description":"Overrides the values of device screen dimensions (window.screen.width, window.screen.height,\\nwindow.innerWidth, window.innerHeight, and \\"device-width\\"/\\"device-height\\"-related CSS media\\nquery results).","parameters":[{"name":"width","description":"Overriding width value in pixels (minimum 0, maximum 10000000). 0 disables the override.","type":"integer"},{"name":"height","description":"Overriding height value in pixels (minimum 0, maximum 10000000). 0 disables the override.","type":"integer"},{"name":"deviceScaleFactor","description":"Overriding device scale factor value. 0 disables the override.","type":"number"},{"name":"mobile","description":"Whether to emulate mobile device. This includes viewport meta tag, overlay scrollbars, text\\nautosizing and more.","type":"boolean"},{"name":"scale","description":"Scale to apply to resulting view image.","experimental":true,"optional":true,"type":"number"},{"name":"screenWidth","description":"Overriding screen width value in pixels (minimum 0, maximum 10000000).","experimental":true,"optional":true,"type":"integer"},{"name":"screenHeight","description":"Overriding screen height value in pixels (minimum 0, maximum 10000000).","experimental":true,"optional":true,"type":"integer"},{"name":"positionX","description":"Overriding view X position on screen in pixels (minimum 0, maximum 10000000).","experimental":true,"optional":true,"type":"integer"},{"name":"positionY","description":"Overriding view Y position on screen in pixels (minimum 0, maximum 10000000).","experimental":true,"optional":true,"type":"integer"},{"name":"dontSetVisibleSize","description":"Do not set visible view size, rely upon explicit setVisibleSize call.","experimental":true,"optional":true,"type":"boolean"},{"name":"screenOrientation","description":"Screen orientation override.","optional":true,"$ref":"ScreenOrientation"},{"name":"viewport","description":"If set, the visible area of the page will be overridden to this viewport. This viewport\\nchange is not observed by the page, e.g. viewport-relative elements do not change positions.","experimental":true,"optional":true,"$ref":"Page.Viewport"}]},{"name":"setScrollbarsHidden","experimental":true,"parameters":[{"name":"hidden","description":"Whether scrollbars should be always hidden.","type":"boolean"}]},{"name":"setDocumentCookieDisabled","experimental":true,"parameters":[{"name":"disabled","description":"Whether document.coookie API should be disabled.","type":"boolean"}]},{"name":"setEmitTouchEventsForMouse","experimental":true,"parameters":[{"name":"enabled","description":"Whether touch emulation based on mouse input should be enabled.","type":"boolean"},{"name":"configuration","description":"Touch/gesture events configuration. Default: current platform.","optional":true,"type":"string","enum":["mobile","desktop"]}]},{"name":"setEmulatedMedia","description":"Emulates the given media for CSS media queries.","parameters":[{"name":"media","description":"Media type to emulate. Empty string disables the override.","type":"string"}]},{"name":"setGeolocationOverride","description":"Overrides the Geolocation Position or Error. Omitting any of the parameters emulates position\\nunavailable.","parameters":[{"name":"latitude","description":"Mock latitude","optional":true,"type":"number"},{"name":"longitude","description":"Mock longitude","optional":true,"type":"number"},{"name":"accuracy","description":"Mock accuracy","optional":true,"type":"number"}]},{"name":"setNavigatorOverrides","description":"Overrides value returned by the javascript navigator object.","experimental":true,"deprecated":true,"parameters":[{"name":"platform","description":"The platform navigator.platform should return.","type":"string"}]},{"name":"setPageScaleFactor","description":"Sets a specified page scale factor.","experimental":true,"parameters":[{"name":"pageScaleFactor","description":"Page scale factor.","type":"number"}]},{"name":"setScriptExecutionDisabled","description":"Switches script execution in the page.","parameters":[{"name":"value","description":"Whether script execution should be disabled in the page.","type":"boolean"}]},{"name":"setTouchEmulationEnabled","description":"Enables touch on platforms which do not support them.","parameters":[{"name":"enabled","description":"Whether the touch event emulation should be enabled.","type":"boolean"},{"name":"maxTouchPoints","description":"Maximum touch points supported. Defaults to one.","optional":true,"type":"integer"}]},{"name":"setVirtualTimePolicy","description":"Turns on virtual time for all frames (replacing real-time with a synthetic time source) and sets\\nthe current virtual time policy. Note this supersedes any previous time budget.","experimental":true,"parameters":[{"name":"policy","$ref":"VirtualTimePolicy"},{"name":"budget","description":"If set, after this many virtual milliseconds have elapsed virtual time will be paused and a\\nvirtualTimeBudgetExpired event is sent.","optional":true,"type":"number"},{"name":"maxVirtualTimeTaskStarvationCount","description":"If set this specifies the maximum number of tasks that can be run before virtual is forced\\nforwards to prevent deadlock.","optional":true,"type":"integer"},{"name":"waitForNavigation","description":"If set the virtual time policy change should be deferred until any frame starts navigating.\\nNote any previous deferred policy change is superseded.","optional":true,"type":"boolean"},{"name":"initialVirtualTime","description":"If set, base::Time::Now will be overriden to initially return this value.","optional":true,"$ref":"Network.TimeSinceEpoch"}],"returns":[{"name":"virtualTimeTicksBase","description":"Absolute timestamp at which virtual time was first enabled (up time in milliseconds).","type":"number"}]},{"name":"setTimezoneOverride","description":"Overrides default host system timezone with the specified one.","experimental":true,"parameters":[{"name":"timezoneId","description":"The timezone identifier. If empty, disables the override and\\nrestores default host system timezone.","type":"string"}]},{"name":"setVisibleSize","description":"Resizes the frame/viewport of the page. Note that this does not affect the frame\'s container\\n(e.g. browser window). Can be used to produce screenshots of the specified size. Not supported\\non Android.","experimental":true,"deprecated":true,"parameters":[{"name":"width","description":"Frame width (DIP).","type":"integer"},{"name":"height","description":"Frame height (DIP).","type":"integer"}]},{"name":"setUserAgentOverride","description":"Allows overriding user agent with the given string.","parameters":[{"name":"userAgent","description":"User agent to use.","type":"string"},{"name":"acceptLanguage","description":"Browser langugage to emulate.","optional":true,"type":"string"},{"name":"platform","description":"The platform navigator.platform should return.","optional":true,"type":"string"}]}],"events":[{"name":"virtualTimeBudgetExpired","description":"Notification sent after the virtual time budget for the current VirtualTimePolicy has run out.","experimental":true}]},{"domain":"HeadlessExperimental","description":"This domain provides experimental commands only supported in headless mode.","experimental":true,"dependencies":["Page","Runtime"],"types":[{"id":"ScreenshotParams","description":"Encoding options for a screenshot.","type":"object","properties":[{"name":"format","description":"Image compression format (defaults to png).","optional":true,"type":"string","enum":["jpeg","png"]},{"name":"quality","description":"Compression quality from range [0..100] (jpeg only).","optional":true,"type":"integer"}]}],"commands":[{"name":"beginFrame","description":"Sends a BeginFrame to the target and returns when the frame was completed. Optionally captures a\\nscreenshot from the resulting frame. Requires that the target was created with enabled\\nBeginFrameControl. Designed for use with --run-all-compositor-stages-before-draw, see also\\nhttps://goo.gl/3zHXhB for more background.","parameters":[{"name":"frameTimeTicks","description":"Timestamp of this BeginFrame in Renderer TimeTicks (milliseconds of uptime). If not set,\\nthe current time will be used.","optional":true,"type":"number"},{"name":"interval","description":"The interval between BeginFrames that is reported to the compositor, in milliseconds.\\nDefaults to a 60 frames/second interval, i.e. about 16.666 milliseconds.","optional":true,"type":"number"},{"name":"noDisplayUpdates","description":"Whether updates should not be committed and drawn onto the display. False by default. If\\ntrue, only side effects of the BeginFrame will be run, such as layout and animations, but\\nany visual updates may not be visible on the display or in screenshots.","optional":true,"type":"boolean"},{"name":"screenshot","description":"If set, a screenshot of the frame will be captured and returned in the response. Otherwise,\\nno screenshot will be captured. Note that capturing a screenshot can fail, for example,\\nduring renderer initialization. In such a case, no screenshot data will be returned.","optional":true,"$ref":"ScreenshotParams"}],"returns":[{"name":"hasDamage","description":"Whether the BeginFrame resulted in damage and, thus, a new frame was committed to the\\ndisplay. Reported for diagnostic uses, may be removed in the future.","type":"boolean"},{"name":"screenshotData","description":"Base64-encoded image data of the screenshot, if one was requested and successfully taken.","optional":true,"type":"string"}]},{"name":"disable","description":"Disables headless events for the target."},{"name":"enable","description":"Enables headless events for the target."}],"events":[{"name":"needsBeginFramesChanged","description":"Issued when the target starts or stops needing BeginFrames.","parameters":[{"name":"needsBeginFrames","description":"True if BeginFrames are needed, false otherwise.","type":"boolean"}]}]},{"domain":"IO","description":"Input/Output operations for streams produced by DevTools.","types":[{"id":"StreamHandle","description":"This is either obtained from another method or specifed as `blob:<uuid>` where\\n`<uuid>` is an UUID of a Blob.","type":"string"}],"commands":[{"name":"close","description":"Close the stream, discard any temporary backing storage.","parameters":[{"name":"handle","description":"Handle of the stream to close.","$ref":"StreamHandle"}]},{"name":"read","description":"Read a chunk of the stream","parameters":[{"name":"handle","description":"Handle of the stream to read.","$ref":"StreamHandle"},{"name":"offset","description":"Seek to the specified offset before reading (if not specificed, proceed with offset\\nfollowing the last read). Some types of streams may only support sequential reads.","optional":true,"type":"integer"},{"name":"size","description":"Maximum number of bytes to read (left upon the agent discretion if not specified).","optional":true,"type":"integer"}],"returns":[{"name":"base64Encoded","description":"Set if the data is base64-encoded","optional":true,"type":"boolean"},{"name":"data","description":"Data that were read.","type":"string"},{"name":"eof","description":"Set if the end-of-file condition occured while reading.","type":"boolean"}]},{"name":"resolveBlob","description":"Return UUID of Blob object specified by a remote object id.","parameters":[{"name":"objectId","description":"Object id of a Blob object wrapper.","$ref":"Runtime.RemoteObjectId"}],"returns":[{"name":"uuid","description":"UUID of the specified Blob.","type":"string"}]}]},{"domain":"IndexedDB","experimental":true,"dependencies":["Runtime"],"types":[{"id":"DatabaseWithObjectStores","description":"Database with an array of object stores.","type":"object","properties":[{"name":"name","description":"Database name.","type":"string"},{"name":"version","description":"Database version (type is not \'integer\', as the standard\\nrequires the version number to be \'unsigned long long\')","type":"number"},{"name":"objectStores","description":"Object stores in this database.","type":"array","items":{"$ref":"ObjectStore"}}]},{"id":"ObjectStore","description":"Object store.","type":"object","properties":[{"name":"name","description":"Object store name.","type":"string"},{"name":"keyPath","description":"Object store key path.","$ref":"KeyPath"},{"name":"autoIncrement","description":"If true, object store has auto increment flag set.","type":"boolean"},{"name":"indexes","description":"Indexes in this object store.","type":"array","items":{"$ref":"ObjectStoreIndex"}}]},{"id":"ObjectStoreIndex","description":"Object store index.","type":"object","properties":[{"name":"name","description":"Index name.","type":"string"},{"name":"keyPath","description":"Index key path.","$ref":"KeyPath"},{"name":"unique","description":"If true, index is unique.","type":"boolean"},{"name":"multiEntry","description":"If true, index allows multiple entries for a key.","type":"boolean"}]},{"id":"Key","description":"Key.","type":"object","properties":[{"name":"type","description":"Key type.","type":"string","enum":["number","string","date","array"]},{"name":"number","description":"Number value.","optional":true,"type":"number"},{"name":"string","description":"String value.","optional":true,"type":"string"},{"name":"date","description":"Date value.","optional":true,"type":"number"},{"name":"array","description":"Array value.","optional":true,"type":"array","items":{"$ref":"Key"}}]},{"id":"KeyRange","description":"Key range.","type":"object","properties":[{"name":"lower","description":"Lower bound.","optional":true,"$ref":"Key"},{"name":"upper","description":"Upper bound.","optional":true,"$ref":"Key"},{"name":"lowerOpen","description":"If true lower bound is open.","type":"boolean"},{"name":"upperOpen","description":"If true upper bound is open.","type":"boolean"}]},{"id":"DataEntry","description":"Data entry.","type":"object","properties":[{"name":"key","description":"Key object.","$ref":"Runtime.RemoteObject"},{"name":"primaryKey","description":"Primary key object.","$ref":"Runtime.RemoteObject"},{"name":"value","description":"Value object.","$ref":"Runtime.RemoteObject"}]},{"id":"KeyPath","description":"Key path.","type":"object","properties":[{"name":"type","description":"Key path type.","type":"string","enum":["null","string","array"]},{"name":"string","description":"String value.","optional":true,"type":"string"},{"name":"array","description":"Array value.","optional":true,"type":"array","items":{"type":"string"}}]}],"commands":[{"name":"clearObjectStore","description":"Clears all entries from an object store.","parameters":[{"name":"securityOrigin","description":"Security origin.","type":"string"},{"name":"databaseName","description":"Database name.","type":"string"},{"name":"objectStoreName","description":"Object store name.","type":"string"}]},{"name":"deleteDatabase","description":"Deletes a database.","parameters":[{"name":"securityOrigin","description":"Security origin.","type":"string"},{"name":"databaseName","description":"Database name.","type":"string"}]},{"name":"deleteObjectStoreEntries","description":"Delete a range of entries from an object store","parameters":[{"name":"securityOrigin","type":"string"},{"name":"databaseName","type":"string"},{"name":"objectStoreName","type":"string"},{"name":"keyRange","description":"Range of entry keys to delete","$ref":"KeyRange"}]},{"name":"disable","description":"Disables events from backend."},{"name":"enable","description":"Enables events from backend."},{"name":"requestData","description":"Requests data from object store or index.","parameters":[{"name":"securityOrigin","description":"Security origin.","type":"string"},{"name":"databaseName","description":"Database name.","type":"string"},{"name":"objectStoreName","description":"Object store name.","type":"string"},{"name":"indexName","description":"Index name, empty string for object store data requests.","type":"string"},{"name":"skipCount","description":"Number of records to skip.","type":"integer"},{"name":"pageSize","description":"Number of records to fetch.","type":"integer"},{"name":"keyRange","description":"Key range.","optional":true,"$ref":"KeyRange"}],"returns":[{"name":"objectStoreDataEntries","description":"Array of object store data entries.","type":"array","items":{"$ref":"DataEntry"}},{"name":"hasMore","description":"If true, there are more entries to fetch in the given range.","type":"boolean"}]},{"name":"getMetadata","description":"Gets metadata of an object store","parameters":[{"name":"securityOrigin","description":"Security origin.","type":"string"},{"name":"databaseName","description":"Database name.","type":"string"},{"name":"objectStoreName","description":"Object store name.","type":"string"}],"returns":[{"name":"entriesCount","description":"the entries count","type":"number"},{"name":"keyGeneratorValue","description":"the current value of key generator, to become the next inserted\\nkey into the object store. Valid if objectStore.autoIncrement\\nis true.","type":"number"}]},{"name":"requestDatabase","description":"Requests database with given name in given frame.","parameters":[{"name":"securityOrigin","description":"Security origin.","type":"string"},{"name":"databaseName","description":"Database name.","type":"string"}],"returns":[{"name":"databaseWithObjectStores","description":"Database with an array of object stores.","$ref":"DatabaseWithObjectStores"}]},{"name":"requestDatabaseNames","description":"Requests database names for given security origin.","parameters":[{"name":"securityOrigin","description":"Security origin.","type":"string"}],"returns":[{"name":"databaseNames","description":"Database names for origin.","type":"array","items":{"type":"string"}}]}]},{"domain":"Input","types":[{"id":"TouchPoint","type":"object","properties":[{"name":"x","description":"X coordinate of the event relative to the main frame\'s viewport in CSS pixels.","type":"number"},{"name":"y","description":"Y coordinate of the event relative to the main frame\'s viewport in CSS pixels. 0 refers to\\nthe top of the viewport and Y increases as it proceeds towards the bottom of the viewport.","type":"number"},{"name":"radiusX","description":"X radius of the touch area (default: 1.0).","optional":true,"type":"number"},{"name":"radiusY","description":"Y radius of the touch area (default: 1.0).","optional":true,"type":"number"},{"name":"rotationAngle","description":"Rotation angle (default: 0.0).","optional":true,"type":"number"},{"name":"force","description":"Force (default: 1.0).","optional":true,"type":"number"},{"name":"id","description":"Identifier used to track touch sources between events, must be unique within an event.","optional":true,"type":"number"}]},{"id":"GestureSourceType","experimental":true,"type":"string","enum":["default","touch","mouse"]},{"id":"TimeSinceEpoch","description":"UTC time in seconds, counted from January 1, 1970.","type":"number"}],"commands":[{"name":"dispatchKeyEvent","description":"Dispatches a key event to the page.","parameters":[{"name":"type","description":"Type of the key event.","type":"string","enum":["keyDown","keyUp","rawKeyDown","char"]},{"name":"modifiers","description":"Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8\\n(default: 0).","optional":true,"type":"integer"},{"name":"timestamp","description":"Time at which the event occurred.","optional":true,"$ref":"TimeSinceEpoch"},{"name":"text","description":"Text as generated by processing a virtual key code with a keyboard layout. Not needed for\\nfor `keyUp` and `rawKeyDown` events (default: \\"\\")","optional":true,"type":"string"},{"name":"unmodifiedText","description":"Text that would have been generated by the keyboard if no modifiers were pressed (except for\\nshift). Useful for shortcut (accelerator) key handling (default: \\"\\").","optional":true,"type":"string"},{"name":"keyIdentifier","description":"Unique key identifier (e.g., \'U+0041\') (default: \\"\\").","optional":true,"type":"string"},{"name":"code","description":"Unique DOM defined string value for each physical key (e.g., \'KeyA\') (default: \\"\\").","optional":true,"type":"string"},{"name":"key","description":"Unique DOM defined string value describing the meaning of the key in the context of active\\nmodifiers, keyboard layout, etc (e.g., \'AltGr\') (default: \\"\\").","optional":true,"type":"string"},{"name":"windowsVirtualKeyCode","description":"Windows virtual key code (default: 0).","optional":true,"type":"integer"},{"name":"nativeVirtualKeyCode","description":"Native virtual key code (default: 0).","optional":true,"type":"integer"},{"name":"autoRepeat","description":"Whether the event was generated from auto repeat (default: false).","optional":true,"type":"boolean"},{"name":"isKeypad","description":"Whether the event was generated from the keypad (default: false).","optional":true,"type":"boolean"},{"name":"isSystemKey","description":"Whether the event was a system key event (default: false).","optional":true,"type":"boolean"},{"name":"location","description":"Whether the event was from the left or right side of the keyboard. 1=Left, 2=Right (default:\\n0).","optional":true,"type":"integer"}]},{"name":"insertText","description":"This method emulates inserting text that doesn\'t come from a key press,\\nfor example an emoji keyboard or an IME.","experimental":true,"parameters":[{"name":"text","description":"The text to insert.","type":"string"}]},{"name":"dispatchMouseEvent","description":"Dispatches a mouse event to the page.","parameters":[{"name":"type","description":"Type of the mouse event.","type":"string","enum":["mousePressed","mouseReleased","mouseMoved","mouseWheel"]},{"name":"x","description":"X coordinate of the event relative to the main frame\'s viewport in CSS pixels.","type":"number"},{"name":"y","description":"Y coordinate of the event relative to the main frame\'s viewport in CSS pixels. 0 refers to\\nthe top of the viewport and Y increases as it proceeds towards the bottom of the viewport.","type":"number"},{"name":"modifiers","description":"Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8\\n(default: 0).","optional":true,"type":"integer"},{"name":"timestamp","description":"Time at which the event occurred.","optional":true,"$ref":"TimeSinceEpoch"},{"name":"button","description":"Mouse button (default: \\"none\\").","optional":true,"type":"string","enum":["none","left","middle","right","back","forward"]},{"name":"buttons","description":"A number indicating which buttons are pressed on the mouse when a mouse event is triggered.\\nLeft=1, Right=2, Middle=4, Back=8, Forward=16, None=0.","optional":true,"type":"integer"},{"name":"clickCount","description":"Number of times the mouse button was clicked (default: 0).","optional":true,"type":"integer"},{"name":"deltaX","description":"X delta in CSS pixels for mouse wheel event (default: 0).","optional":true,"type":"number"},{"name":"deltaY","description":"Y delta in CSS pixels for mouse wheel event (default: 0).","optional":true,"type":"number"},{"name":"pointerType","description":"Pointer type (default: \\"mouse\\").","optional":true,"type":"string","enum":["mouse","pen"]}]},{"name":"dispatchTouchEvent","description":"Dispatches a touch event to the page.","parameters":[{"name":"type","description":"Type of the touch event. TouchEnd and TouchCancel must not contain any touch points, while\\nTouchStart and TouchMove must contains at least one.","type":"string","enum":["touchStart","touchEnd","touchMove","touchCancel"]},{"name":"touchPoints","description":"Active touch points on the touch device. One event per any changed point (compared to\\nprevious touch event in a sequence) is generated, emulating pressing/moving/releasing points\\none by one.","type":"array","items":{"$ref":"TouchPoint"}},{"name":"modifiers","description":"Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8\\n(default: 0).","optional":true,"type":"integer"},{"name":"timestamp","description":"Time at which the event occurred.","optional":true,"$ref":"TimeSinceEpoch"}]},{"name":"emulateTouchFromMouseEvent","description":"Emulates touch event from the mouse event parameters.","experimental":true,"parameters":[{"name":"type","description":"Type of the mouse event.","type":"string","enum":["mousePressed","mouseReleased","mouseMoved","mouseWheel"]},{"name":"x","description":"X coordinate of the mouse pointer in DIP.","type":"integer"},{"name":"y","description":"Y coordinate of the mouse pointer in DIP.","type":"integer"},{"name":"button","description":"Mouse button.","type":"string","enum":["none","left","middle","right"]},{"name":"timestamp","description":"Time at which the event occurred (default: current time).","optional":true,"$ref":"TimeSinceEpoch"},{"name":"deltaX","description":"X delta in DIP for mouse wheel event (default: 0).","optional":true,"type":"number"},{"name":"deltaY","description":"Y delta in DIP for mouse wheel event (default: 0).","optional":true,"type":"number"},{"name":"modifiers","description":"Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8\\n(default: 0).","optional":true,"type":"integer"},{"name":"clickCount","description":"Number of times the mouse button was clicked (default: 0).","optional":true,"type":"integer"}]},{"name":"setIgnoreInputEvents","description":"Ignores input events (useful while auditing page).","parameters":[{"name":"ignore","description":"Ignores input events processing when set to true.","type":"boolean"}]},{"name":"synthesizePinchGesture","description":"Synthesizes a pinch gesture over a time period by issuing appropriate touch events.","experimental":true,"parameters":[{"name":"x","description":"X coordinate of the start of the gesture in CSS pixels.","type":"number"},{"name":"y","description":"Y coordinate of the start of the gesture in CSS pixels.","type":"number"},{"name":"scaleFactor","description":"Relative scale factor after zooming (>1.0 zooms in, <1.0 zooms out).","type":"number"},{"name":"relativeSpeed","description":"Relative pointer speed in pixels per second (default: 800).","optional":true,"type":"integer"},{"name":"gestureSourceType","description":"Which type of input events to be generated (default: \'default\', which queries the platform\\nfor the preferred input type).","optional":true,"$ref":"GestureSourceType"}]},{"name":"synthesizeScrollGesture","description":"Synthesizes a scroll gesture over a time period by issuing appropriate touch events.","experimental":true,"parameters":[{"name":"x","description":"X coordinate of the start of the gesture in CSS pixels.","type":"number"},{"name":"y","description":"Y coordinate of the start of the gesture in CSS pixels.","type":"number"},{"name":"xDistance","description":"The distance to scroll along the X axis (positive to scroll left).","optional":true,"type":"number"},{"name":"yDistance","description":"The distance to scroll along the Y axis (positive to scroll up).","optional":true,"type":"number"},{"name":"xOverscroll","description":"The number of additional pixels to scroll back along the X axis, in addition to the given\\ndistance.","optional":true,"type":"number"},{"name":"yOverscroll","description":"The number of additional pixels to scroll back along the Y axis, in addition to the given\\ndistance.","optional":true,"type":"number"},{"name":"preventFling","description":"Prevent fling (default: true).","optional":true,"type":"boolean"},{"name":"speed","description":"Swipe speed in pixels per second (default: 800).","optional":true,"type":"integer"},{"name":"gestureSourceType","description":"Which type of input events to be generated (default: \'default\', which queries the platform\\nfor the preferred input type).","optional":true,"$ref":"GestureSourceType"},{"name":"repeatCount","description":"The number of times to repeat the gesture (default: 0).","optional":true,"type":"integer"},{"name":"repeatDelayMs","description":"The number of milliseconds delay between each repeat. (default: 250).","optional":true,"type":"integer"},{"name":"interactionMarkerName","description":"The name of the interaction markers to generate, if not empty (default: \\"\\").","optional":true,"type":"string"}]},{"name":"synthesizeTapGesture","description":"Synthesizes a tap gesture over a time period by issuing appropriate touch events.","experimental":true,"parameters":[{"name":"x","description":"X coordinate of the start of the gesture in CSS pixels.","type":"number"},{"name":"y","description":"Y coordinate of the start of the gesture in CSS pixels.","type":"number"},{"name":"duration","description":"Duration between touchdown and touchup events in ms (default: 50).","optional":true,"type":"integer"},{"name":"tapCount","description":"Number of times to perform the tap (e.g. 2 for double tap, default: 1).","optional":true,"type":"integer"},{"name":"gestureSourceType","description":"Which type of input events to be generated (default: \'default\', which queries the platform\\nfor the preferred input type).","optional":true,"$ref":"GestureSourceType"}]}]},{"domain":"Inspector","experimental":true,"commands":[{"name":"disable","description":"Disables inspector domain notifications."},{"name":"enable","description":"Enables inspector domain notifications."}],"events":[{"name":"detached","description":"Fired when remote debugging connection is about to be terminated. Contains detach reason.","parameters":[{"name":"reason","description":"The reason why connection has been terminated.","type":"string"}]},{"name":"targetCrashed","description":"Fired when debugging target has crashed"},{"name":"targetReloadedAfterCrash","description":"Fired when debugging target has reloaded after crash"}]},{"domain":"LayerTree","experimental":true,"dependencies":["DOM"],"types":[{"id":"LayerId","description":"Unique Layer identifier.","type":"string"},{"id":"SnapshotId","description":"Unique snapshot identifier.","type":"string"},{"id":"ScrollRect","description":"Rectangle where scrolling happens on the main thread.","type":"object","properties":[{"name":"rect","description":"Rectangle itself.","$ref":"DOM.Rect"},{"name":"type","description":"Reason for rectangle to force scrolling on the main thread","type":"string","enum":["RepaintsOnScroll","TouchEventHandler","WheelEventHandler"]}]},{"id":"StickyPositionConstraint","description":"Sticky position constraints.","type":"object","properties":[{"name":"stickyBoxRect","description":"Layout rectangle of the sticky element before being shifted","$ref":"DOM.Rect"},{"name":"containingBlockRect","description":"Layout rectangle of the containing block of the sticky element","$ref":"DOM.Rect"},{"name":"nearestLayerShiftingStickyBox","description":"The nearest sticky layer that shifts the sticky box","optional":true,"$ref":"LayerId"},{"name":"nearestLayerShiftingContainingBlock","description":"The nearest sticky layer that shifts the containing block","optional":true,"$ref":"LayerId"}]},{"id":"PictureTile","description":"Serialized fragment of layer picture along with its offset within the layer.","type":"object","properties":[{"name":"x","description":"Offset from owning layer left boundary","type":"number"},{"name":"y","description":"Offset from owning layer top boundary","type":"number"},{"name":"picture","description":"Base64-encoded snapshot data.","type":"string"}]},{"id":"Layer","description":"Information about a compositing layer.","type":"object","properties":[{"name":"layerId","description":"The unique id for this layer.","$ref":"LayerId"},{"name":"parentLayerId","description":"The id of parent (not present for root).","optional":true,"$ref":"LayerId"},{"name":"backendNodeId","description":"The backend id for the node associated with this layer.","optional":true,"$ref":"DOM.BackendNodeId"},{"name":"offsetX","description":"Offset from parent layer, X coordinate.","type":"number"},{"name":"offsetY","description":"Offset from parent layer, Y coordinate.","type":"number"},{"name":"width","description":"Layer width.","type":"number"},{"name":"height","description":"Layer height.","type":"number"},{"name":"transform","description":"Transformation matrix for layer, default is identity matrix","optional":true,"type":"array","items":{"type":"number"}},{"name":"anchorX","description":"Transform anchor point X, absent if no transform specified","optional":true,"type":"number"},{"name":"anchorY","description":"Transform anchor point Y, absent if no transform specified","optional":true,"type":"number"},{"name":"anchorZ","description":"Transform anchor point Z, absent if no transform specified","optional":true,"type":"number"},{"name":"paintCount","description":"Indicates how many time this layer has painted.","type":"integer"},{"name":"drawsContent","description":"Indicates whether this layer hosts any content, rather than being used for\\ntransform/scrolling purposes only.","type":"boolean"},{"name":"invisible","description":"Set if layer is not visible.","optional":true,"type":"boolean"},{"name":"scrollRects","description":"Rectangles scrolling on main thread only.","optional":true,"type":"array","items":{"$ref":"ScrollRect"}},{"name":"stickyPositionConstraint","description":"Sticky position constraint information","optional":true,"$ref":"StickyPositionConstraint"}]},{"id":"PaintProfile","description":"Array of timings, one per paint step.","type":"array","items":{"type":"number"}}],"commands":[{"name":"compositingReasons","description":"Provides the reasons why the given layer was composited.","parameters":[{"name":"layerId","description":"The id of the layer for which we want to get the reasons it was composited.","$ref":"LayerId"}],"returns":[{"name":"compositingReasons","description":"A list of strings specifying reasons for the given layer to become composited.","type":"array","items":{"type":"string"}}]},{"name":"disable","description":"Disables compositing tree inspection."},{"name":"enable","description":"Enables compositing tree inspection."},{"name":"loadSnapshot","description":"Returns the snapshot identifier.","parameters":[{"name":"tiles","description":"An array of tiles composing the snapshot.","type":"array","items":{"$ref":"PictureTile"}}],"returns":[{"name":"snapshotId","description":"The id of the snapshot.","$ref":"SnapshotId"}]},{"name":"makeSnapshot","description":"Returns the layer snapshot identifier.","parameters":[{"name":"layerId","description":"The id of the layer.","$ref":"LayerId"}],"returns":[{"name":"snapshotId","description":"The id of the layer snapshot.","$ref":"SnapshotId"}]},{"name":"profileSnapshot","parameters":[{"name":"snapshotId","description":"The id of the layer snapshot.","$ref":"SnapshotId"},{"name":"minRepeatCount","description":"The maximum number of times to replay the snapshot (1, if not specified).","optional":true,"type":"integer"},{"name":"minDuration","description":"The minimum duration (in seconds) to replay the snapshot.","optional":true,"type":"number"},{"name":"clipRect","description":"The clip rectangle to apply when replaying the snapshot.","optional":true,"$ref":"DOM.Rect"}],"returns":[{"name":"timings","description":"The array of paint profiles, one per run.","type":"array","items":{"$ref":"PaintProfile"}}]},{"name":"releaseSnapshot","description":"Releases layer snapshot captured by the back-end.","parameters":[{"name":"snapshotId","description":"The id of the layer snapshot.","$ref":"SnapshotId"}]},{"name":"replaySnapshot","description":"Replays the layer snapshot and returns the resulting bitmap.","parameters":[{"name":"snapshotId","description":"The id of the layer snapshot.","$ref":"SnapshotId"},{"name":"fromStep","description":"The first step to replay from (replay from the very start if not specified).","optional":true,"type":"integer"},{"name":"toStep","description":"The last step to replay to (replay till the end if not specified).","optional":true,"type":"integer"},{"name":"scale","description":"The scale to apply while replaying (defaults to 1).","optional":true,"type":"number"}],"returns":[{"name":"dataURL","description":"A data: URL for resulting image.","type":"string"}]},{"name":"snapshotCommandLog","description":"Replays the layer snapshot and returns canvas log.","parameters":[{"name":"snapshotId","description":"The id of the layer snapshot.","$ref":"SnapshotId"}],"returns":[{"name":"commandLog","description":"The array of canvas function calls.","type":"array","items":{"type":"object"}}]}],"events":[{"name":"layerPainted","parameters":[{"name":"layerId","description":"The id of the painted layer.","$ref":"LayerId"},{"name":"clip","description":"Clip rectangle.","$ref":"DOM.Rect"}]},{"name":"layerTreeDidChange","parameters":[{"name":"layers","description":"Layer tree, absent if not in the comspositing mode.","optional":true,"type":"array","items":{"$ref":"Layer"}}]}]},{"domain":"Log","description":"Provides access to log entries.","dependencies":["Runtime","Network"],"types":[{"id":"LogEntry","description":"Log entry.","type":"object","properties":[{"name":"source","description":"Log entry source.","type":"string","enum":["xml","javascript","network","storage","appcache","rendering","security","deprecation","worker","violation","intervention","recommendation","other"]},{"name":"level","description":"Log entry severity.","type":"string","enum":["verbose","info","warning","error"]},{"name":"text","description":"Logged text.","type":"string"},{"name":"timestamp","description":"Timestamp when this entry was added.","$ref":"Runtime.Timestamp"},{"name":"url","description":"URL of the resource if known.","optional":true,"type":"string"},{"name":"lineNumber","description":"Line number in the resource.","optional":true,"type":"integer"},{"name":"stackTrace","description":"JavaScript stack trace.","optional":true,"$ref":"Runtime.StackTrace"},{"name":"networkRequestId","description":"Identifier of the network request associated with this entry.","optional":true,"$ref":"Network.RequestId"},{"name":"workerId","description":"Identifier of the worker associated with this entry.","optional":true,"type":"string"},{"name":"args","description":"Call arguments.","optional":true,"type":"array","items":{"$ref":"Runtime.RemoteObject"}}]},{"id":"ViolationSetting","description":"Violation configuration setting.","type":"object","properties":[{"name":"name","description":"Violation type.","type":"string","enum":["longTask","longLayout","blockedEvent","blockedParser","discouragedAPIUse","handler","recurringHandler"]},{"name":"threshold","description":"Time threshold to trigger upon.","type":"number"}]}],"commands":[{"name":"clear","description":"Clears the log."},{"name":"disable","description":"Disables log domain, prevents further log entries from being reported to the client."},{"name":"enable","description":"Enables log domain, sends the entries collected so far to the client by means of the\\n`entryAdded` notification."},{"name":"startViolationsReport","description":"start violation reporting.","parameters":[{"name":"config","description":"Configuration for violations.","type":"array","items":{"$ref":"ViolationSetting"}}]},{"name":"stopViolationsReport","description":"Stop violation reporting."}],"events":[{"name":"entryAdded","description":"Issued when new message was logged.","parameters":[{"name":"entry","description":"The entry.","$ref":"LogEntry"}]}]},{"domain":"Memory","experimental":true,"types":[{"id":"PressureLevel","description":"Memory pressure level.","type":"string","enum":["moderate","critical"]},{"id":"SamplingProfileNode","description":"Heap profile sample.","type":"object","properties":[{"name":"size","description":"Size of the sampled allocation.","type":"number"},{"name":"total","description":"Total bytes attributed to this sample.","type":"number"},{"name":"stack","description":"Execution stack at the point of allocation.","type":"array","items":{"type":"string"}}]},{"id":"SamplingProfile","description":"Array of heap profile samples.","type":"object","properties":[{"name":"samples","type":"array","items":{"$ref":"SamplingProfileNode"}},{"name":"modules","type":"array","items":{"$ref":"Module"}}]},{"id":"Module","description":"Executable module information","type":"object","properties":[{"name":"name","description":"Name of the module.","type":"string"},{"name":"uuid","description":"UUID of the module.","type":"string"},{"name":"baseAddress","description":"Base address where the module is loaded into memory. Encoded as a decimal\\nor hexadecimal (0x prefixed) string.","type":"string"},{"name":"size","description":"Size of the module in bytes.","type":"number"}]}],"commands":[{"name":"getDOMCounters","returns":[{"name":"documents","type":"integer"},{"name":"nodes","type":"integer"},{"name":"jsEventListeners","type":"integer"}]},{"name":"prepareForLeakDetection"},{"name":"forciblyPurgeJavaScriptMemory","description":"Simulate OomIntervention by purging V8 memory."},{"name":"setPressureNotificationsSuppressed","description":"Enable/disable suppressing memory pressure notifications in all processes.","parameters":[{"name":"suppressed","description":"If true, memory pressure notifications will be suppressed.","type":"boolean"}]},{"name":"simulatePressureNotification","description":"Simulate a memory pressure notification in all processes.","parameters":[{"name":"level","description":"Memory pressure level of the notification.","$ref":"PressureLevel"}]},{"name":"startSampling","description":"Start collecting native memory profile.","parameters":[{"name":"samplingInterval","description":"Average number of bytes between samples.","optional":true,"type":"integer"},{"name":"suppressRandomness","description":"Do not randomize intervals between samples.","optional":true,"type":"boolean"}]},{"name":"stopSampling","description":"Stop collecting native memory profile."},{"name":"getAllTimeSamplingProfile","description":"Retrieve native memory allocations profile\\ncollected since renderer process startup.","returns":[{"name":"profile","$ref":"SamplingProfile"}]},{"name":"getBrowserSamplingProfile","description":"Retrieve native memory allocations profile\\ncollected since browser process startup.","returns":[{"name":"profile","$ref":"SamplingProfile"}]},{"name":"getSamplingProfile","description":"Retrieve native memory allocations profile collected since last\\n`startSampling` call.","returns":[{"name":"profile","$ref":"SamplingProfile"}]}]},{"domain":"Network","description":"Network domain allows tracking network activities of the page. It exposes information about http,\\nfile, data and other requests and responses, their headers, bodies, timing, etc.","dependencies":["Debugger","Runtime","Security"],"types":[{"id":"ResourceType","description":"Resource type as it was perceived by the rendering engine.","type":"string","enum":["Document","Stylesheet","Image","Media","Font","Script","TextTrack","XHR","Fetch","EventSource","WebSocket","Manifest","SignedExchange","Ping","CSPViolationReport","Other"]},{"id":"LoaderId","description":"Unique loader identifier.","type":"string"},{"id":"RequestId","description":"Unique request identifier.","type":"string"},{"id":"InterceptionId","description":"Unique intercepted request identifier.","type":"string"},{"id":"ErrorReason","description":"Network level fetch failure reason.","type":"string","enum":["Failed","Aborted","TimedOut","AccessDenied","ConnectionClosed","ConnectionReset","ConnectionRefused","ConnectionAborted","ConnectionFailed","NameNotResolved","InternetDisconnected","AddressUnreachable","BlockedByClient","BlockedByResponse"]},{"id":"TimeSinceEpoch","description":"UTC time in seconds, counted from January 1, 1970.","type":"number"},{"id":"MonotonicTime","description":"Monotonically increasing time in seconds since an arbitrary point in the past.","type":"number"},{"id":"Headers","description":"Request / response headers as keys / values of JSON object.","type":"object"},{"id":"ConnectionType","description":"The underlying connection technology that the browser is supposedly using.","type":"string","enum":["none","cellular2g","cellular3g","cellular4g","bluetooth","ethernet","wifi","wimax","other"]},{"id":"CookieSameSite","description":"Represents the cookie\'s \'SameSite\' status:\\nhttps://tools.ietf.org/html/draft-west-first-party-cookies","type":"string","enum":["Strict","Lax","Extended","None"]},{"id":"ResourceTiming","description":"Timing information for the request.","type":"object","properties":[{"name":"requestTime","description":"Timing\'s requestTime is a baseline in seconds, while the other numbers are ticks in\\nmilliseconds relatively to this requestTime.","type":"number"},{"name":"proxyStart","description":"Started resolving proxy.","type":"number"},{"name":"proxyEnd","description":"Finished resolving proxy.","type":"number"},{"name":"dnsStart","description":"Started DNS address resolve.","type":"number"},{"name":"dnsEnd","description":"Finished DNS address resolve.","type":"number"},{"name":"connectStart","description":"Started connecting to the remote host.","type":"number"},{"name":"connectEnd","description":"Connected to the remote host.","type":"number"},{"name":"sslStart","description":"Started SSL handshake.","type":"number"},{"name":"sslEnd","description":"Finished SSL handshake.","type":"number"},{"name":"workerStart","description":"Started running ServiceWorker.","experimental":true,"type":"number"},{"name":"workerReady","description":"Finished Starting ServiceWorker.","experimental":true,"type":"number"},{"name":"sendStart","description":"Started sending request.","type":"number"},{"name":"sendEnd","description":"Finished sending request.","type":"number"},{"name":"pushStart","description":"Time the server started pushing request.","experimental":true,"type":"number"},{"name":"pushEnd","description":"Time the server finished pushing request.","experimental":true,"type":"number"},{"name":"receiveHeadersEnd","description":"Finished receiving response headers.","type":"number"}]},{"id":"ResourcePriority","description":"Loading priority of a resource request.","type":"string","enum":["VeryLow","Low","Medium","High","VeryHigh"]},{"id":"Request","description":"HTTP request data.","type":"object","properties":[{"name":"url","description":"Request URL (without fragment).","type":"string"},{"name":"urlFragment","description":"Fragment of the requested URL starting with hash, if present.","optional":true,"type":"string"},{"name":"method","description":"HTTP request method.","type":"string"},{"name":"headers","description":"HTTP request headers.","$ref":"Headers"},{"name":"postData","description":"HTTP POST request data.","optional":true,"type":"string"},{"name":"hasPostData","description":"True when the request has POST data. Note that postData might still be omitted when this flag is true when the data is too long.","optional":true,"type":"boolean"},{"name":"mixedContentType","description":"The mixed content type of the request.","optional":true,"$ref":"Security.MixedContentType"},{"name":"initialPriority","description":"Priority of the resource request at the time request is sent.","$ref":"ResourcePriority"},{"name":"referrerPolicy","description":"The referrer policy of the request, as defined in https://www.w3.org/TR/referrer-policy/","type":"string","enum":["unsafe-url","no-referrer-when-downgrade","no-referrer","origin","origin-when-cross-origin","same-origin","strict-origin","strict-origin-when-cross-origin"]},{"name":"isLinkPreload","description":"Whether is loaded via link preload.","optional":true,"type":"boolean"}]},{"id":"SignedCertificateTimestamp","description":"Details of a signed certificate timestamp (SCT).","type":"object","properties":[{"name":"status","description":"Validation status.","type":"string"},{"name":"origin","description":"Origin.","type":"string"},{"name":"logDescription","description":"Log name / description.","type":"string"},{"name":"logId","description":"Log ID.","type":"string"},{"name":"timestamp","description":"Issuance date.","$ref":"TimeSinceEpoch"},{"name":"hashAlgorithm","description":"Hash algorithm.","type":"string"},{"name":"signatureAlgorithm","description":"Signature algorithm.","type":"string"},{"name":"signatureData","description":"Signature data.","type":"string"}]},{"id":"SecurityDetails","description":"Security details about a request.","type":"object","properties":[{"name":"protocol","description":"Protocol name (e.g. \\"TLS 1.2\\" or \\"QUIC\\").","type":"string"},{"name":"keyExchange","description":"Key Exchange used by the connection, or the empty string if not applicable.","type":"string"},{"name":"keyExchangeGroup","description":"(EC)DH group used by the connection, if applicable.","optional":true,"type":"string"},{"name":"cipher","description":"Cipher name.","type":"string"},{"name":"mac","description":"TLS MAC. Note that AEAD ciphers do not have separate MACs.","optional":true,"type":"string"},{"name":"certificateId","description":"Certificate ID value.","$ref":"Security.CertificateId"},{"name":"subjectName","description":"Certificate subject name.","type":"string"},{"name":"sanList","description":"Subject Alternative Name (SAN) DNS names and IP addresses.","type":"array","items":{"type":"string"}},{"name":"issuer","description":"Name of the issuing CA.","type":"string"},{"name":"validFrom","description":"Certificate valid from date.","$ref":"TimeSinceEpoch"},{"name":"validTo","description":"Certificate valid to (expiration) date","$ref":"TimeSinceEpoch"},{"name":"signedCertificateTimestampList","description":"List of signed certificate timestamps (SCTs).","type":"array","items":{"$ref":"SignedCertificateTimestamp"}},{"name":"certificateTransparencyCompliance","description":"Whether the request complied with Certificate Transparency policy","$ref":"CertificateTransparencyCompliance"}]},{"id":"CertificateTransparencyCompliance","description":"Whether the request complied with Certificate Transparency policy.","type":"string","enum":["unknown","not-compliant","compliant"]},{"id":"BlockedReason","description":"The reason why request was blocked.","type":"string","enum":["other","csp","mixed-content","origin","inspector","subresource-filter","content-type","collapsed-by-client"]},{"id":"Response","description":"HTTP response data.","type":"object","properties":[{"name":"url","description":"Response URL. This URL can be different from CachedResource.url in case of redirect.","type":"string"},{"name":"status","description":"HTTP response status code.","type":"integer"},{"name":"statusText","description":"HTTP response status text.","type":"string"},{"name":"headers","description":"HTTP response headers.","$ref":"Headers"},{"name":"headersText","description":"HTTP response headers text.","optional":true,"type":"string"},{"name":"mimeType","description":"Resource mimeType as determined by the browser.","type":"string"},{"name":"requestHeaders","description":"Refined HTTP request headers that were actually transmitted over the network.","optional":true,"$ref":"Headers"},{"name":"requestHeadersText","description":"HTTP request headers text.","optional":true,"type":"string"},{"name":"connectionReused","description":"Specifies whether physical connection was actually reused for this request.","type":"boolean"},{"name":"connectionId","description":"Physical connection id that was actually used for this request.","type":"number"},{"name":"remoteIPAddress","description":"Remote IP address.","optional":true,"type":"string"},{"name":"remotePort","description":"Remote port.","optional":true,"type":"integer"},{"name":"fromDiskCache","description":"Specifies that the request was served from the disk cache.","optional":true,"type":"boolean"},{"name":"fromServiceWorker","description":"Specifies that the request was served from the ServiceWorker.","optional":true,"type":"boolean"},{"name":"fromPrefetchCache","description":"Specifies that the request was served from the prefetch cache.","optional":true,"type":"boolean"},{"name":"encodedDataLength","description":"Total number of bytes received for this request so far.","type":"number"},{"name":"timing","description":"Timing information for the given request.","optional":true,"$ref":"ResourceTiming"},{"name":"protocol","description":"Protocol used to fetch this request.","optional":true,"type":"string"},{"name":"securityState","description":"Security state of the request resource.","$ref":"Security.SecurityState"},{"name":"securityDetails","description":"Security details for the request.","optional":true,"$ref":"SecurityDetails"}]},{"id":"WebSocketRequest","description":"WebSocket request data.","type":"object","properties":[{"name":"headers","description":"HTTP request headers.","$ref":"Headers"}]},{"id":"WebSocketResponse","description":"WebSocket response data.","type":"object","properties":[{"name":"status","description":"HTTP response status code.","type":"integer"},{"name":"statusText","description":"HTTP response status text.","type":"string"},{"name":"headers","description":"HTTP response headers.","$ref":"Headers"},{"name":"headersText","description":"HTTP response headers text.","optional":true,"type":"string"},{"name":"requestHeaders","description":"HTTP request headers.","optional":true,"$ref":"Headers"},{"name":"requestHeadersText","description":"HTTP request headers text.","optional":true,"type":"string"}]},{"id":"WebSocketFrame","description":"WebSocket message data. This represents an entire WebSocket message, not just a fragmented frame as the name suggests.","type":"object","properties":[{"name":"opcode","description":"WebSocket message opcode.","type":"number"},{"name":"mask","description":"WebSocket message mask.","type":"boolean"},{"name":"payloadData","description":"WebSocket message payload data.\\nIf the opcode is 1, this is a text message and payloadData is a UTF-8 string.\\nIf the opcode isn\'t 1, then payloadData is a base64 encoded string representing binary data.","type":"string"}]},{"id":"CachedResource","description":"Information about the cached resource.","type":"object","properties":[{"name":"url","description":"Resource URL. This is the url of the original network request.","type":"string"},{"name":"type","description":"Type of this resource.","$ref":"ResourceType"},{"name":"response","description":"Cached response data.","optional":true,"$ref":"Response"},{"name":"bodySize","description":"Cached response body size.","type":"number"}]},{"id":"Initiator","description":"Information about the request initiator.","type":"object","properties":[{"name":"type","description":"Type of this initiator.","type":"string","enum":["parser","script","preload","SignedExchange","other"]},{"name":"stack","description":"Initiator JavaScript stack trace, set for Script only.","optional":true,"$ref":"Runtime.StackTrace"},{"name":"url","description":"Initiator URL, set for Parser type or for Script type (when script is importing module) or for SignedExchange type.","optional":true,"type":"string"},{"name":"lineNumber","description":"Initiator line number, set for Parser type or for Script type (when script is importing\\nmodule) (0-based).","optional":true,"type":"number"}]},{"id":"Cookie","description":"Cookie object","type":"object","properties":[{"name":"name","description":"Cookie name.","type":"string"},{"name":"value","description":"Cookie value.","type":"string"},{"name":"domain","description":"Cookie domain.","type":"string"},{"name":"path","description":"Cookie path.","type":"string"},{"name":"expires","description":"Cookie expiration date as the number of seconds since the UNIX epoch.","type":"number"},{"name":"size","description":"Cookie size.","type":"integer"},{"name":"httpOnly","description":"True if cookie is http-only.","type":"boolean"},{"name":"secure","description":"True if cookie is secure.","type":"boolean"},{"name":"session","description":"True in case of session cookie.","type":"boolean"},{"name":"sameSite","description":"Cookie SameSite type.","optional":true,"$ref":"CookieSameSite"}]},{"id":"CookieParam","description":"Cookie parameter object","type":"object","properties":[{"name":"name","description":"Cookie name.","type":"string"},{"name":"value","description":"Cookie value.","type":"string"},{"name":"url","description":"The request-URI to associate with the setting of the cookie. This value can affect the\\ndefault domain and path values of the created cookie.","optional":true,"type":"string"},{"name":"domain","description":"Cookie domain.","optional":true,"type":"string"},{"name":"path","description":"Cookie path.","optional":true,"type":"string"},{"name":"secure","description":"True if cookie is secure.","optional":true,"type":"boolean"},{"name":"httpOnly","description":"True if cookie is http-only.","optional":true,"type":"boolean"},{"name":"sameSite","description":"Cookie SameSite type.","optional":true,"$ref":"CookieSameSite"},{"name":"expires","description":"Cookie expiration date, session cookie if not set","optional":true,"$ref":"TimeSinceEpoch"}]},{"id":"AuthChallenge","description":"Authorization challenge for HTTP status code 401 or 407.","experimental":true,"type":"object","properties":[{"name":"source","description":"Source of the authentication challenge.","optional":true,"type":"string","enum":["Server","Proxy"]},{"name":"origin","description":"Origin of the challenger.","type":"string"},{"name":"scheme","description":"The authentication scheme used, such as basic or digest","type":"string"},{"name":"realm","description":"The realm of the challenge. May be empty.","type":"string"}]},{"id":"AuthChallengeResponse","description":"Response to an AuthChallenge.","experimental":true,"type":"object","properties":[{"name":"response","description":"The decision on what to do in response to the authorization challenge. Default means\\ndeferring to the default behavior of the net stack, which will likely either the Cancel\\nauthentication or display a popup dialog box.","type":"string","enum":["Default","CancelAuth","ProvideCredentials"]},{"name":"username","description":"The username to provide, possibly empty. Should only be set if response is\\nProvideCredentials.","optional":true,"type":"string"},{"name":"password","description":"The password to provide, possibly empty. Should only be set if response is\\nProvideCredentials.","optional":true,"type":"string"}]},{"id":"InterceptionStage","description":"Stages of the interception to begin intercepting. Request will intercept before the request is\\nsent. Response will intercept after the response is received.","experimental":true,"type":"string","enum":["Request","HeadersReceived"]},{"id":"RequestPattern","description":"Request pattern for interception.","experimental":true,"type":"object","properties":[{"name":"urlPattern","description":"Wildcards (\'*\' -> zero or more, \'?\' -> exactly one) are allowed. Escape character is\\nbackslash. Omitting is equivalent to \\"*\\".","optional":true,"type":"string"},{"name":"resourceType","description":"If set, only requests for matching resource types will be intercepted.","optional":true,"$ref":"ResourceType"},{"name":"interceptionStage","description":"Stage at wich to begin intercepting requests. Default is Request.","optional":true,"$ref":"InterceptionStage"}]},{"id":"SignedExchangeSignature","description":"Information about a signed exchange signature.\\nhttps://wicg.github.io/webpackage/draft-yasskin-httpbis-origin-signed-exchanges-impl.html#rfc.section.3.1","experimental":true,"type":"object","properties":[{"name":"label","description":"Signed exchange signature label.","type":"string"},{"name":"signature","description":"The hex string of signed exchange signature.","type":"string"},{"name":"integrity","description":"Signed exchange signature integrity.","type":"string"},{"name":"certUrl","description":"Signed exchange signature cert Url.","optional":true,"type":"string"},{"name":"certSha256","description":"The hex string of signed exchange signature cert sha256.","optional":true,"type":"string"},{"name":"validityUrl","description":"Signed exchange signature validity Url.","type":"string"},{"name":"date","description":"Signed exchange signature date.","type":"integer"},{"name":"expires","description":"Signed exchange signature expires.","type":"integer"},{"name":"certificates","description":"The encoded certificates.","optional":true,"type":"array","items":{"type":"string"}}]},{"id":"SignedExchangeHeader","description":"Information about a signed exchange header.\\nhttps://wicg.github.io/webpackage/draft-yasskin-httpbis-origin-signed-exchanges-impl.html#cbor-representation","experimental":true,"type":"object","properties":[{"name":"requestUrl","description":"Signed exchange request URL.","type":"string"},{"name":"responseCode","description":"Signed exchange response code.","type":"integer"},{"name":"responseHeaders","description":"Signed exchange response headers.","$ref":"Headers"},{"name":"signatures","description":"Signed exchange response signature.","type":"array","items":{"$ref":"SignedExchangeSignature"}},{"name":"headerIntegrity","description":"Signed exchange header integrity hash in the form of \\"sha256-<base64-hash-value>\\".","type":"string"}]},{"id":"SignedExchangeErrorField","description":"Field type for a signed exchange related error.","experimental":true,"type":"string","enum":["signatureSig","signatureIntegrity","signatureCertUrl","signatureCertSha256","signatureValidityUrl","signatureTimestamps"]},{"id":"SignedExchangeError","description":"Information about a signed exchange response.","experimental":true,"type":"object","properties":[{"name":"message","description":"Error message.","type":"string"},{"name":"signatureIndex","description":"The index of the signature which caused the error.","optional":true,"type":"integer"},{"name":"errorField","description":"The field which caused the error.","optional":true,"$ref":"SignedExchangeErrorField"}]},{"id":"SignedExchangeInfo","description":"Information about a signed exchange response.","experimental":true,"type":"object","properties":[{"name":"outerResponse","description":"The outer response of signed HTTP exchange which was received from network.","$ref":"Response"},{"name":"header","description":"Information about the signed exchange header.","optional":true,"$ref":"SignedExchangeHeader"},{"name":"securityDetails","description":"Security details for the signed exchange header.","optional":true,"$ref":"SecurityDetails"},{"name":"errors","description":"Errors occurred while handling the signed exchagne.","optional":true,"type":"array","items":{"$ref":"SignedExchangeError"}}]}],"commands":[{"name":"canClearBrowserCache","description":"Tells whether clearing browser cache is supported.","deprecated":true,"returns":[{"name":"result","description":"True if browser cache can be cleared.","type":"boolean"}]},{"name":"canClearBrowserCookies","description":"Tells whether clearing browser cookies is supported.","deprecated":true,"returns":[{"name":"result","description":"True if browser cookies can be cleared.","type":"boolean"}]},{"name":"canEmulateNetworkConditions","description":"Tells whether emulation of network conditions is supported.","deprecated":true,"returns":[{"name":"result","description":"True if emulation of network conditions is supported.","type":"boolean"}]},{"name":"clearBrowserCache","description":"Clears browser cache."},{"name":"clearBrowserCookies","description":"Clears browser cookies."},{"name":"continueInterceptedRequest","description":"Response to Network.requestIntercepted which either modifies the request to continue with any\\nmodifications, or blocks it, or completes it with the provided response bytes. If a network\\nfetch occurs as a result which encounters a redirect an additional Network.requestIntercepted\\nevent will be sent with the same InterceptionId.\\nDeprecated, use Fetch.continueRequest, Fetch.fulfillRequest and Fetch.failRequest instead.","experimental":true,"deprecated":true,"parameters":[{"name":"interceptionId","$ref":"InterceptionId"},{"name":"errorReason","description":"If set this causes the request to fail with the given reason. Passing `Aborted` for requests\\nmarked with `isNavigationRequest` also cancels the navigation. Must not be set in response\\nto an authChallenge.","optional":true,"$ref":"ErrorReason"},{"name":"rawResponse","description":"If set the requests completes using with the provided base64 encoded raw response, including\\nHTTP status line and headers etc... Must not be set in response to an authChallenge.","optional":true,"type":"string"},{"name":"url","description":"If set the request url will be modified in a way that\'s not observable by page. Must not be\\nset in response to an authChallenge.","optional":true,"type":"string"},{"name":"method","description":"If set this allows the request method to be overridden. Must not be set in response to an\\nauthChallenge.","optional":true,"type":"string"},{"name":"postData","description":"If set this allows postData to be set. Must not be set in response to an authChallenge.","optional":true,"type":"string"},{"name":"headers","description":"If set this allows the request headers to be changed. Must not be set in response to an\\nauthChallenge.","optional":true,"$ref":"Headers"},{"name":"authChallengeResponse","description":"Response to a requestIntercepted with an authChallenge. Must not be set otherwise.","optional":true,"$ref":"AuthChallengeResponse"}]},{"name":"deleteCookies","description":"Deletes browser cookies with matching name and url or domain/path pair.","parameters":[{"name":"name","description":"Name of the cookies to remove.","type":"string"},{"name":"url","description":"If specified, deletes all the cookies with the given name where domain and path match\\nprovided URL.","optional":true,"type":"string"},{"name":"domain","description":"If specified, deletes only cookies with the exact domain.","optional":true,"type":"string"},{"name":"path","description":"If specified, deletes only cookies with the exact path.","optional":true,"type":"string"}]},{"name":"disable","description":"Disables network tracking, prevents network events from being sent to the client."},{"name":"emulateNetworkConditions","description":"Activates emulation of network conditions.","parameters":[{"name":"offline","description":"True to emulate internet disconnection.","type":"boolean"},{"name":"latency","description":"Minimum latency from request sent to response headers received (ms).","type":"number"},{"name":"downloadThroughput","description":"Maximal aggregated download throughput (bytes/sec). -1 disables download throttling.","type":"number"},{"name":"uploadThroughput","description":"Maximal aggregated upload throughput (bytes/sec). -1 disables upload throttling.","type":"number"},{"name":"connectionType","description":"Connection type if known.","optional":true,"$ref":"ConnectionType"}]},{"name":"enable","description":"Enables network tracking, network events will now be delivered to the client.","parameters":[{"name":"maxTotalBufferSize","description":"Buffer size in bytes to use when preserving network payloads (XHRs, etc).","experimental":true,"optional":true,"type":"integer"},{"name":"maxResourceBufferSize","description":"Per-resource buffer size in bytes to use when preserving network payloads (XHRs, etc).","experimental":true,"optional":true,"type":"integer"},{"name":"maxPostDataSize","description":"Longest post body size (in bytes) that would be included in requestWillBeSent notification","optional":true,"type":"integer"}]},{"name":"getAllCookies","description":"Returns all browser cookies. Depending on the backend support, will return detailed cookie\\ninformation in the `cookies` field.","returns":[{"name":"cookies","description":"Array of cookie objects.","type":"array","items":{"$ref":"Cookie"}}]},{"name":"getCertificate","description":"Returns the DER-encoded certificate.","experimental":true,"parameters":[{"name":"origin","description":"Origin to get certificate for.","type":"string"}],"returns":[{"name":"tableNames","type":"array","items":{"type":"string"}}]},{"name":"getCookies","description":"Returns all browser cookies for the current URL. Depending on the backend support, will return\\ndetailed cookie information in the `cookies` field.","parameters":[{"name":"urls","description":"The list of URLs for which applicable cookies will be fetched","optional":true,"type":"array","items":{"type":"string"}}],"returns":[{"name":"cookies","description":"Array of cookie objects.","type":"array","items":{"$ref":"Cookie"}}]},{"name":"getResponseBody","description":"Returns content served for the given request.","parameters":[{"name":"requestId","description":"Identifier of the network request to get content for.","$ref":"RequestId"}],"returns":[{"name":"body","description":"Response body.","type":"string"},{"name":"base64Encoded","description":"True, if content was sent as base64.","type":"boolean"}]},{"name":"getRequestPostData","description":"Returns post data sent with the request. Returns an error when no data was sent with the request.","parameters":[{"name":"requestId","description":"Identifier of the network request to get content for.","$ref":"RequestId"}],"returns":[{"name":"postData","description":"Request body string, omitting files from multipart requests","type":"string"}]},{"name":"getResponseBodyForInterception","description":"Returns content served for the given currently intercepted request.","experimental":true,"parameters":[{"name":"interceptionId","description":"Identifier for the intercepted request to get body for.","$ref":"InterceptionId"}],"returns":[{"name":"body","description":"Response body.","type":"string"},{"name":"base64Encoded","description":"True, if content was sent as base64.","type":"boolean"}]},{"name":"takeResponseBodyForInterceptionAsStream","description":"Returns a handle to the stream representing the response body. Note that after this command,\\nthe intercepted request can\'t be continued as is -- you either need to cancel it or to provide\\nthe response body. The stream only supports sequential read, IO.read will fail if the position\\nis specified.","experimental":true,"parameters":[{"name":"interceptionId","$ref":"InterceptionId"}],"returns":[{"name":"stream","$ref":"IO.StreamHandle"}]},{"name":"replayXHR","description":"This method sends a new XMLHttpRequest which is identical to the original one. The following\\nparameters should be identical: method, url, async, request body, extra headers, withCredentials\\nattribute, user, password.","experimental":true,"parameters":[{"name":"requestId","description":"Identifier of XHR to replay.","$ref":"RequestId"}]},{"name":"searchInResponseBody","description":"Searches for given string in response content.","experimental":true,"parameters":[{"name":"requestId","description":"Identifier of the network response to search.","$ref":"RequestId"},{"name":"query","description":"String to search for.","type":"string"},{"name":"caseSensitive","description":"If true, search is case sensitive.","optional":true,"type":"boolean"},{"name":"isRegex","description":"If true, treats string parameter as regex.","optional":true,"type":"boolean"}],"returns":[{"name":"result","description":"List of search matches.","type":"array","items":{"$ref":"Debugger.SearchMatch"}}]},{"name":"setBlockedURLs","description":"Blocks URLs from loading.","experimental":true,"parameters":[{"name":"urls","description":"URL patterns to block. Wildcards (\'*\') are allowed.","type":"array","items":{"type":"string"}}]},{"name":"setBypassServiceWorker","description":"Toggles ignoring of service worker for each request.","experimental":true,"parameters":[{"name":"bypass","description":"Bypass service worker and load from network.","type":"boolean"}]},{"name":"setCacheDisabled","description":"Toggles ignoring cache for each request. If `true`, cache will not be used.","parameters":[{"name":"cacheDisabled","description":"Cache disabled state.","type":"boolean"}]},{"name":"setCookie","description":"Sets a cookie with the given cookie data; may overwrite equivalent cookies if they exist.","parameters":[{"name":"name","description":"Cookie name.","type":"string"},{"name":"value","description":"Cookie value.","type":"string"},{"name":"url","description":"The request-URI to associate with the setting of the cookie. This value can affect the\\ndefault domain and path values of the created cookie.","optional":true,"type":"string"},{"name":"domain","description":"Cookie domain.","optional":true,"type":"string"},{"name":"path","description":"Cookie path.","optional":true,"type":"string"},{"name":"secure","description":"True if cookie is secure.","optional":true,"type":"boolean"},{"name":"httpOnly","description":"True if cookie is http-only.","optional":true,"type":"boolean"},{"name":"sameSite","description":"Cookie SameSite type.","optional":true,"$ref":"CookieSameSite"},{"name":"expires","description":"Cookie expiration date, session cookie if not set","optional":true,"$ref":"TimeSinceEpoch"}],"returns":[{"name":"success","description":"True if successfully set cookie.","type":"boolean"}]},{"name":"setCookies","description":"Sets given cookies.","parameters":[{"name":"cookies","description":"Cookies to be set.","type":"array","items":{"$ref":"CookieParam"}}]},{"name":"setDataSizeLimitsForTest","description":"For testing.","experimental":true,"parameters":[{"name":"maxTotalSize","description":"Maximum total buffer size.","type":"integer"},{"name":"maxResourceSize","description":"Maximum per-resource size.","type":"integer"}]},{"name":"setExtraHTTPHeaders","description":"Specifies whether to always send extra HTTP headers with the requests from this page.","parameters":[{"name":"headers","description":"Map with extra HTTP headers.","$ref":"Headers"}]},{"name":"setRequestInterception","description":"Sets the requests to intercept that match the provided patterns and optionally resource types.\\nDeprecated, please use Fetch.enable instead.","experimental":true,"deprecated":true,"parameters":[{"name":"patterns","description":"Requests matching any of these patterns will be forwarded and wait for the corresponding\\ncontinueInterceptedRequest call.","type":"array","items":{"$ref":"RequestPattern"}}]},{"name":"setUserAgentOverride","description":"Allows overriding user agent with the given string.","redirect":"Emulation","parameters":[{"name":"userAgent","description":"User agent to use.","type":"string"},{"name":"acceptLanguage","description":"Browser langugage to emulate.","optional":true,"type":"string"},{"name":"platform","description":"The platform navigator.platform should return.","optional":true,"type":"string"}]}],"events":[{"name":"dataReceived","description":"Fired when data chunk was received over the network.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"dataLength","description":"Data chunk length.","type":"integer"},{"name":"encodedDataLength","description":"Actual bytes received (might be less than dataLength for compressed encodings).","type":"integer"}]},{"name":"eventSourceMessageReceived","description":"Fired when EventSource message is received.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"eventName","description":"Message type.","type":"string"},{"name":"eventId","description":"Message identifier.","type":"string"},{"name":"data","description":"Message content.","type":"string"}]},{"name":"loadingFailed","description":"Fired when HTTP request has failed to load.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"type","description":"Resource type.","$ref":"ResourceType"},{"name":"errorText","description":"User friendly error message.","type":"string"},{"name":"canceled","description":"True if loading was canceled.","optional":true,"type":"boolean"},{"name":"blockedReason","description":"The reason why loading was blocked, if any.","optional":true,"$ref":"BlockedReason"}]},{"name":"loadingFinished","description":"Fired when HTTP request has finished loading.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"encodedDataLength","description":"Total number of bytes received for this request.","type":"number"},{"name":"shouldReportCorbBlocking","description":"Set when 1) response was blocked by Cross-Origin Read Blocking and also\\n2) this needs to be reported to the DevTools console.","optional":true,"type":"boolean"}]},{"name":"requestIntercepted","description":"Details of an intercepted HTTP request, which must be either allowed, blocked, modified or\\nmocked.\\nDeprecated, use Fetch.requestPaused instead.","experimental":true,"deprecated":true,"parameters":[{"name":"interceptionId","description":"Each request the page makes will have a unique id, however if any redirects are encountered\\nwhile processing that fetch, they will be reported with the same id as the original fetch.\\nLikewise if HTTP authentication is needed then the same fetch id will be used.","$ref":"InterceptionId"},{"name":"request","$ref":"Request"},{"name":"frameId","description":"The id of the frame that initiated the request.","$ref":"Page.FrameId"},{"name":"resourceType","description":"How the requested resource will be used.","$ref":"ResourceType"},{"name":"isNavigationRequest","description":"Whether this is a navigation request, which can abort the navigation completely.","type":"boolean"},{"name":"isDownload","description":"Set if the request is a navigation that will result in a download.\\nOnly present after response is received from the server (i.e. HeadersReceived stage).","optional":true,"type":"boolean"},{"name":"redirectUrl","description":"Redirect location, only sent if a redirect was intercepted.","optional":true,"type":"string"},{"name":"authChallenge","description":"Details of the Authorization Challenge encountered. If this is set then\\ncontinueInterceptedRequest must contain an authChallengeResponse.","optional":true,"$ref":"AuthChallenge"},{"name":"responseErrorReason","description":"Response error if intercepted at response stage or if redirect occurred while intercepting\\nrequest.","optional":true,"$ref":"ErrorReason"},{"name":"responseStatusCode","description":"Response code if intercepted at response stage or if redirect occurred while intercepting\\nrequest or auth retry occurred.","optional":true,"type":"integer"},{"name":"responseHeaders","description":"Response headers if intercepted at the response stage or if redirect occurred while\\nintercepting request or auth retry occurred.","optional":true,"$ref":"Headers"},{"name":"requestId","description":"If the intercepted request had a corresponding requestWillBeSent event fired for it, then\\nthis requestId will be the same as the requestId present in the requestWillBeSent event.","optional":true,"$ref":"RequestId"}]},{"name":"requestServedFromCache","description":"Fired if request ended up loading from cache.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"}]},{"name":"requestWillBeSent","description":"Fired when page is about to send HTTP request.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"loaderId","description":"Loader identifier. Empty string if the request is fetched from worker.","$ref":"LoaderId"},{"name":"documentURL","description":"URL of the document this request is loaded for.","type":"string"},{"name":"request","description":"Request data.","$ref":"Request"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"wallTime","description":"Timestamp.","$ref":"TimeSinceEpoch"},{"name":"initiator","description":"Request initiator.","$ref":"Initiator"},{"name":"redirectResponse","description":"Redirect response data.","optional":true,"$ref":"Response"},{"name":"type","description":"Type of this resource.","optional":true,"$ref":"ResourceType"},{"name":"frameId","description":"Frame identifier.","optional":true,"$ref":"Page.FrameId"},{"name":"hasUserGesture","description":"Whether the request is initiated by a user gesture. Defaults to false.","optional":true,"type":"boolean"}]},{"name":"resourceChangedPriority","description":"Fired when resource loading priority is changed","experimental":true,"parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"newPriority","description":"New priority","$ref":"ResourcePriority"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"}]},{"name":"signedExchangeReceived","description":"Fired when a signed exchange was received over the network","experimental":true,"parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"info","description":"Information about the signed exchange response.","$ref":"SignedExchangeInfo"}]},{"name":"responseReceived","description":"Fired when HTTP response is available.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"loaderId","description":"Loader identifier. Empty string if the request is fetched from worker.","$ref":"LoaderId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"type","description":"Resource type.","$ref":"ResourceType"},{"name":"response","description":"Response data.","$ref":"Response"},{"name":"frameId","description":"Frame identifier.","optional":true,"$ref":"Page.FrameId"}]},{"name":"webSocketClosed","description":"Fired when WebSocket is closed.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"}]},{"name":"webSocketCreated","description":"Fired upon WebSocket creation.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"url","description":"WebSocket request URL.","type":"string"},{"name":"initiator","description":"Request initiator.","optional":true,"$ref":"Initiator"}]},{"name":"webSocketFrameError","description":"Fired when WebSocket message error occurs.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"errorMessage","description":"WebSocket error message.","type":"string"}]},{"name":"webSocketFrameReceived","description":"Fired when WebSocket message is received.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"response","description":"WebSocket response data.","$ref":"WebSocketFrame"}]},{"name":"webSocketFrameSent","description":"Fired when WebSocket message is sent.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"response","description":"WebSocket response data.","$ref":"WebSocketFrame"}]},{"name":"webSocketHandshakeResponseReceived","description":"Fired when WebSocket handshake response becomes available.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"response","description":"WebSocket response data.","$ref":"WebSocketResponse"}]},{"name":"webSocketWillSendHandshakeRequest","description":"Fired when WebSocket is about to initiate handshake.","parameters":[{"name":"requestId","description":"Request identifier.","$ref":"RequestId"},{"name":"timestamp","description":"Timestamp.","$ref":"MonotonicTime"},{"name":"wallTime","description":"UTC Timestamp.","$ref":"TimeSinceEpoch"},{"name":"request","description":"WebSocket request data.","$ref":"WebSocketRequest"}]}]},{"domain":"Overlay","description":"This domain provides various functionality related to drawing atop the inspected page.","experimental":true,"dependencies":["DOM","Page","Runtime"],"types":[{"id":"HighlightConfig","description":"Configuration data for the highlighting of page elements.","type":"object","properties":[{"name":"showInfo","description":"Whether the node info tooltip should be shown (default: false).","optional":true,"type":"boolean"},{"name":"showStyles","description":"Whether the node styles in the tooltip (default: false).","optional":true,"type":"boolean"},{"name":"showRulers","description":"Whether the rulers should be shown (default: false).","optional":true,"type":"boolean"},{"name":"showExtensionLines","description":"Whether the extension lines from node to the rulers should be shown (default: false).","optional":true,"type":"boolean"},{"name":"contentColor","description":"The content box highlight fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"paddingColor","description":"The padding highlight fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"borderColor","description":"The border highlight fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"marginColor","description":"The margin highlight fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"eventTargetColor","description":"The event target element highlight fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"shapeColor","description":"The shape outside fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"shapeMarginColor","description":"The shape margin fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"cssGridColor","description":"The grid layout color (default: transparent).","optional":true,"$ref":"DOM.RGBA"}]},{"id":"InspectMode","type":"string","enum":["searchForNode","searchForUAShadowDOM","captureAreaScreenshot","showDistances","none"]}],"commands":[{"name":"disable","description":"Disables domain notifications."},{"name":"enable","description":"Enables domain notifications."},{"name":"getHighlightObjectForTest","description":"For testing.","parameters":[{"name":"nodeId","description":"Id of the node to get highlight object for.","$ref":"DOM.NodeId"},{"name":"includeDistance","description":"Whether to include distance info.","optional":true,"type":"boolean"},{"name":"includeStyle","description":"Whether to include style info.","optional":true,"type":"boolean"}],"returns":[{"name":"highlight","description":"Highlight data for the node.","type":"object"}]},{"name":"hideHighlight","description":"Hides any highlight."},{"name":"highlightFrame","description":"Highlights owner element of the frame with given id.","parameters":[{"name":"frameId","description":"Identifier of the frame to highlight.","$ref":"Page.FrameId"},{"name":"contentColor","description":"The content box highlight fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"contentOutlineColor","description":"The content box highlight outline color (default: transparent).","optional":true,"$ref":"DOM.RGBA"}]},{"name":"highlightNode","description":"Highlights DOM node with given id or with the given JavaScript object wrapper. Either nodeId or\\nobjectId must be specified.","parameters":[{"name":"highlightConfig","description":"A descriptor for the highlight appearance.","$ref":"HighlightConfig"},{"name":"nodeId","description":"Identifier of the node to highlight.","optional":true,"$ref":"DOM.NodeId"},{"name":"backendNodeId","description":"Identifier of the backend node to highlight.","optional":true,"$ref":"DOM.BackendNodeId"},{"name":"objectId","description":"JavaScript object id of the node to be highlighted.","optional":true,"$ref":"Runtime.RemoteObjectId"},{"name":"selector","description":"Selectors to highlight relevant nodes.","optional":true,"type":"string"}]},{"name":"highlightQuad","description":"Highlights given quad. Coordinates are absolute with respect to the main frame viewport.","parameters":[{"name":"quad","description":"Quad to highlight","$ref":"DOM.Quad"},{"name":"color","description":"The highlight fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"outlineColor","description":"The highlight outline color (default: transparent).","optional":true,"$ref":"DOM.RGBA"}]},{"name":"highlightRect","description":"Highlights given rectangle. Coordinates are absolute with respect to the main frame viewport.","parameters":[{"name":"x","description":"X coordinate","type":"integer"},{"name":"y","description":"Y coordinate","type":"integer"},{"name":"width","description":"Rectangle width","type":"integer"},{"name":"height","description":"Rectangle height","type":"integer"},{"name":"color","description":"The highlight fill color (default: transparent).","optional":true,"$ref":"DOM.RGBA"},{"name":"outlineColor","description":"The highlight outline color (default: transparent).","optional":true,"$ref":"DOM.RGBA"}]},{"name":"setInspectMode","description":"Enters the \'inspect\' mode. In this mode, elements that user is hovering over are highlighted.\\nBackend then generates \'inspectNodeRequested\' event upon element selection.","parameters":[{"name":"mode","description":"Set an inspection mode.","$ref":"InspectMode"},{"name":"highlightConfig","description":"A descriptor for the highlight appearance of hovered-over nodes. May be omitted if `enabled\\n== false`.","optional":true,"$ref":"HighlightConfig"}]},{"name":"setShowAdHighlights","description":"Highlights owner element of all frames detected to be ads.","parameters":[{"name":"show","description":"True for showing ad highlights","type":"boolean"}]},{"name":"setPausedInDebuggerMessage","parameters":[{"name":"message","description":"The message to display, also triggers resume and step over controls.","optional":true,"type":"string"}]},{"name":"setShowDebugBorders","description":"Requests that backend shows debug borders on layers","parameters":[{"name":"show","description":"True for showing debug borders","type":"boolean"}]},{"name":"setShowFPSCounter","description":"Requests that backend shows the FPS counter","parameters":[{"name":"show","description":"True for showing the FPS counter","type":"boolean"}]},{"name":"setShowPaintRects","description":"Requests that backend shows paint rectangles","parameters":[{"name":"result","description":"True for showing paint rectangles","type":"boolean"}]},{"name":"setShowLayoutShiftRegions","description":"Requests that backend shows layout shift regions","parameters":[{"name":"result","description":"True for showing layout shift regions","type":"boolean"}]},{"name":"setShowScrollBottleneckRects","description":"Requests that backend shows scroll bottleneck rects","parameters":[{"name":"show","description":"True for showing scroll bottleneck rects","type":"boolean"}]},{"name":"setShowHitTestBorders","description":"Requests that backend shows hit-test borders on layers","parameters":[{"name":"show","description":"True for showing hit-test borders","type":"boolean"}]},{"name":"setShowViewportSizeOnResize","description":"Paints viewport size upon main frame resize.","parameters":[{"name":"show","description":"Whether to paint size or not.","type":"boolean"}]}],"events":[{"name":"inspectNodeRequested","description":"Fired when the node should be inspected. This happens after call to `setInspectMode` or when\\nuser manually inspects an element.","parameters":[{"name":"backendNodeId","description":"Id of the node to inspect.","$ref":"DOM.BackendNodeId"}]},{"name":"nodeHighlightRequested","description":"Fired when the node should be highlighted. This happens after call to `setInspectMode`.","parameters":[{"name":"nodeId","$ref":"DOM.NodeId"}]},{"name":"screenshotRequested","description":"Fired when user asks to capture screenshot of some area on the page.","parameters":[{"name":"viewport","description":"Viewport to capture, in device independent pixels (dip).","$ref":"Page.Viewport"}]},{"name":"inspectModeCanceled","description":"Fired when user cancels the inspect mode."}]},{"domain":"Page","description":"Actions and events related to the inspected page belong to the page domain.","dependencies":["Debugger","DOM","IO","Network","Runtime"],"types":[{"id":"FrameId","description":"Unique frame identifier.","type":"string"},{"id":"Frame","description":"Information about the Frame on the page.","type":"object","properties":[{"name":"id","description":"Frame unique identifier.","type":"string"},{"name":"parentId","description":"Parent frame identifier.","optional":true,"type":"string"},{"name":"loaderId","description":"Identifier of the loader associated with this frame.","$ref":"Network.LoaderId"},{"name":"name","description":"Frame\'s name as specified in the tag.","optional":true,"type":"string"},{"name":"url","description":"Frame document\'s URL without fragment.","type":"string"},{"name":"urlFragment","description":"Frame document\'s URL fragment including the \'#\'.","experimental":true,"optional":true,"type":"string"},{"name":"securityOrigin","description":"Frame document\'s security origin.","type":"string"},{"name":"mimeType","description":"Frame document\'s mimeType as determined by the browser.","type":"string"},{"name":"unreachableUrl","description":"If the frame failed to load, this contains the URL that could not be loaded. Note that unlike url above, this URL may contain a fragment.","experimental":true,"optional":true,"type":"string"}]},{"id":"FrameResource","description":"Information about the Resource on the page.","experimental":true,"type":"object","properties":[{"name":"url","description":"Resource URL.","type":"string"},{"name":"type","description":"Type of this resource.","$ref":"Network.ResourceType"},{"name":"mimeType","description":"Resource mimeType as determined by the browser.","type":"string"},{"name":"lastModified","description":"last-modified timestamp as reported by server.","optional":true,"$ref":"Network.TimeSinceEpoch"},{"name":"contentSize","description":"Resource content size.","optional":true,"type":"number"},{"name":"failed","description":"True if the resource failed to load.","optional":true,"type":"boolean"},{"name":"canceled","description":"True if the resource was canceled during loading.","optional":true,"type":"boolean"}]},{"id":"FrameResourceTree","description":"Information about the Frame hierarchy along with their cached resources.","experimental":true,"type":"object","properties":[{"name":"frame","description":"Frame information for this tree item.","$ref":"Frame"},{"name":"childFrames","description":"Child frames.","optional":true,"type":"array","items":{"$ref":"FrameResourceTree"}},{"name":"resources","description":"Information about frame resources.","type":"array","items":{"$ref":"FrameResource"}}]},{"id":"FrameTree","description":"Information about the Frame hierarchy.","type":"object","properties":[{"name":"frame","description":"Frame information for this tree item.","$ref":"Frame"},{"name":"childFrames","description":"Child frames.","optional":true,"type":"array","items":{"$ref":"FrameTree"}}]},{"id":"ScriptIdentifier","description":"Unique script identifier.","type":"string"},{"id":"TransitionType","description":"Transition type.","type":"string","enum":["link","typed","address_bar","auto_bookmark","auto_subframe","manual_subframe","generated","auto_toplevel","form_submit","reload","keyword","keyword_generated","other"]},{"id":"NavigationEntry","description":"Navigation history entry.","type":"object","properties":[{"name":"id","description":"Unique id of the navigation history entry.","type":"integer"},{"name":"url","description":"URL of the navigation history entry.","type":"string"},{"name":"userTypedURL","description":"URL that the user typed in the url bar.","type":"string"},{"name":"title","description":"Title of the navigation history entry.","type":"string"},{"name":"transitionType","description":"Transition type.","$ref":"TransitionType"}]},{"id":"ScreencastFrameMetadata","description":"Screencast frame metadata.","experimental":true,"type":"object","properties":[{"name":"offsetTop","description":"Top offset in DIP.","type":"number"},{"name":"pageScaleFactor","description":"Page scale factor.","type":"number"},{"name":"deviceWidth","description":"Device screen width in DIP.","type":"number"},{"name":"deviceHeight","description":"Device screen height in DIP.","type":"number"},{"name":"scrollOffsetX","description":"Position of horizontal scroll in CSS pixels.","type":"number"},{"name":"scrollOffsetY","description":"Position of vertical scroll in CSS pixels.","type":"number"},{"name":"timestamp","description":"Frame swap timestamp.","optional":true,"$ref":"Network.TimeSinceEpoch"}]},{"id":"DialogType","description":"Javascript dialog type.","type":"string","enum":["alert","confirm","prompt","beforeunload"]},{"id":"AppManifestError","description":"Error while paring app manifest.","type":"object","properties":[{"name":"message","description":"Error message.","type":"string"},{"name":"critical","description":"If criticial, this is a non-recoverable parse error.","type":"integer"},{"name":"line","description":"Error line.","type":"integer"},{"name":"column","description":"Error column.","type":"integer"}]},{"id":"LayoutViewport","description":"Layout viewport position and dimensions.","type":"object","properties":[{"name":"pageX","description":"Horizontal offset relative to the document (CSS pixels).","type":"integer"},{"name":"pageY","description":"Vertical offset relative to the document (CSS pixels).","type":"integer"},{"name":"clientWidth","description":"Width (CSS pixels), excludes scrollbar if present.","type":"integer"},{"name":"clientHeight","description":"Height (CSS pixels), excludes scrollbar if present.","type":"integer"}]},{"id":"VisualViewport","description":"Visual viewport position, dimensions, and scale.","type":"object","properties":[{"name":"offsetX","description":"Horizontal offset relative to the layout viewport (CSS pixels).","type":"number"},{"name":"offsetY","description":"Vertical offset relative to the layout viewport (CSS pixels).","type":"number"},{"name":"pageX","description":"Horizontal offset relative to the document (CSS pixels).","type":"number"},{"name":"pageY","description":"Vertical offset relative to the document (CSS pixels).","type":"number"},{"name":"clientWidth","description":"Width (CSS pixels), excludes scrollbar if present.","type":"number"},{"name":"clientHeight","description":"Height (CSS pixels), excludes scrollbar if present.","type":"number"},{"name":"scale","description":"Scale relative to the ideal viewport (size at width=device-width).","type":"number"},{"name":"zoom","description":"Page zoom factor (CSS to device independent pixels ratio).","optional":true,"type":"number"}]},{"id":"Viewport","description":"Viewport for capturing screenshot.","type":"object","properties":[{"name":"x","description":"X offset in device independent pixels (dip).","type":"number"},{"name":"y","description":"Y offset in device independent pixels (dip).","type":"number"},{"name":"width","description":"Rectangle width in device independent pixels (dip).","type":"number"},{"name":"height","description":"Rectangle height in device independent pixels (dip).","type":"number"},{"name":"scale","description":"Page scale factor.","type":"number"}]},{"id":"FontFamilies","description":"Generic font families collection.","experimental":true,"type":"object","properties":[{"name":"standard","description":"The standard font-family.","optional":true,"type":"string"},{"name":"fixed","description":"The fixed font-family.","optional":true,"type":"string"},{"name":"serif","description":"The serif font-family.","optional":true,"type":"string"},{"name":"sansSerif","description":"The sansSerif font-family.","optional":true,"type":"string"},{"name":"cursive","description":"The cursive font-family.","optional":true,"type":"string"},{"name":"fantasy","description":"The fantasy font-family.","optional":true,"type":"string"},{"name":"pictograph","description":"The pictograph font-family.","optional":true,"type":"string"}]},{"id":"FontSizes","description":"Default font sizes.","experimental":true,"type":"object","properties":[{"name":"standard","description":"Default standard font size.","optional":true,"type":"integer"},{"name":"fixed","description":"Default fixed font size.","optional":true,"type":"integer"}]},{"id":"ClientNavigationReason","experimental":true,"type":"string","enum":["formSubmissionGet","formSubmissionPost","httpHeaderRefresh","scriptInitiated","metaTagRefresh","pageBlockInterstitial","reload"]}],"commands":[{"name":"addScriptToEvaluateOnLoad","description":"Deprecated, please use addScriptToEvaluateOnNewDocument instead.","experimental":true,"deprecated":true,"parameters":[{"name":"scriptSource","type":"string"}],"returns":[{"name":"identifier","description":"Identifier of the added script.","$ref":"ScriptIdentifier"}]},{"name":"addScriptToEvaluateOnNewDocument","description":"Evaluates given script in every frame upon creation (before loading frame\'s scripts).","parameters":[{"name":"source","type":"string"},{"name":"worldName","description":"If specified, creates an isolated world with the given name and evaluates given script in it.\\nThis world name will be used as the ExecutionContextDescription::name when the corresponding\\nevent is emitted.","experimental":true,"optional":true,"type":"string"}],"returns":[{"name":"identifier","description":"Identifier of the added script.","$ref":"ScriptIdentifier"}]},{"name":"bringToFront","description":"Brings page to front (activates tab)."},{"name":"captureScreenshot","description":"Capture page screenshot.","parameters":[{"name":"format","description":"Image compression format (defaults to png).","optional":true,"type":"string","enum":["jpeg","png"]},{"name":"quality","description":"Compression quality from range [0..100] (jpeg only).","optional":true,"type":"integer"},{"name":"clip","description":"Capture the screenshot of a given region only.","optional":true,"$ref":"Viewport"},{"name":"fromSurface","description":"Capture the screenshot from the surface, rather than the view. Defaults to true.","experimental":true,"optional":true,"type":"boolean"}],"returns":[{"name":"data","description":"Base64-encoded image data.","type":"string"}]},{"name":"captureSnapshot","description":"Returns a snapshot of the page as a string. For MHTML format, the serialization includes\\niframes, shadow DOM, external resources, and element-inline styles.","experimental":true,"parameters":[{"name":"format","description":"Format (defaults to mhtml).","optional":true,"type":"string","enum":["mhtml"]}],"returns":[{"name":"data","description":"Serialized page data.","type":"string"}]},{"name":"clearDeviceMetricsOverride","description":"Clears the overriden device metrics.","experimental":true,"deprecated":true,"redirect":"Emulation"},{"name":"clearDeviceOrientationOverride","description":"Clears the overridden Device Orientation.","experimental":true,"deprecated":true,"redirect":"DeviceOrientation"},{"name":"clearGeolocationOverride","description":"Clears the overriden Geolocation Position and Error.","deprecated":true,"redirect":"Emulation"},{"name":"createIsolatedWorld","description":"Creates an isolated world for the given frame.","parameters":[{"name":"frameId","description":"Id of the frame in which the isolated world should be created.","$ref":"FrameId"},{"name":"worldName","description":"An optional name which is reported in the Execution Context.","optional":true,"type":"string"},{"name":"grantUniveralAccess","description":"Whether or not universal access should be granted to the isolated world. This is a powerful\\noption, use with caution.","optional":true,"type":"boolean"}],"returns":[{"name":"executionContextId","description":"Execution context of the isolated world.","$ref":"Runtime.ExecutionContextId"}]},{"name":"deleteCookie","description":"Deletes browser cookie with given name, domain and path.","experimental":true,"deprecated":true,"redirect":"Network","parameters":[{"name":"cookieName","description":"Name of the cookie to remove.","type":"string"},{"name":"url","description":"URL to match cooke domain and path.","type":"string"}]},{"name":"disable","description":"Disables page domain notifications."},{"name":"enable","description":"Enables page domain notifications."},{"name":"getAppManifest","returns":[{"name":"url","description":"Manifest location.","type":"string"},{"name":"errors","type":"array","items":{"$ref":"AppManifestError"}},{"name":"data","description":"Manifest content.","optional":true,"type":"string"}]},{"name":"getInstallabilityErrors","experimental":true,"returns":[{"name":"errors","type":"array","items":{"type":"string"}}]},{"name":"getCookies","description":"Returns all browser cookies. Depending on the backend support, will return detailed cookie\\ninformation in the `cookies` field.","experimental":true,"deprecated":true,"redirect":"Network","returns":[{"name":"cookies","description":"Array of cookie objects.","type":"array","items":{"$ref":"Network.Cookie"}}]},{"name":"getFrameTree","description":"Returns present frame tree structure.","returns":[{"name":"frameTree","description":"Present frame tree structure.","$ref":"FrameTree"}]},{"name":"getLayoutMetrics","description":"Returns metrics relating to the layouting of the page, such as viewport bounds/scale.","returns":[{"name":"layoutViewport","description":"Metrics relating to the layout viewport.","$ref":"LayoutViewport"},{"name":"visualViewport","description":"Metrics relating to the visual viewport.","$ref":"VisualViewport"},{"name":"contentSize","description":"Size of scrollable area.","$ref":"DOM.Rect"}]},{"name":"getNavigationHistory","description":"Returns navigation history for the current page.","returns":[{"name":"currentIndex","description":"Index of the current navigation history entry.","type":"integer"},{"name":"entries","description":"Array of navigation history entries.","type":"array","items":{"$ref":"NavigationEntry"}}]},{"name":"resetNavigationHistory","description":"Resets navigation history for the current page."},{"name":"getResourceContent","description":"Returns content of the given resource.","experimental":true,"parameters":[{"name":"frameId","description":"Frame id to get resource for.","$ref":"FrameId"},{"name":"url","description":"URL of the resource to get content for.","type":"string"}],"returns":[{"name":"content","description":"Resource content.","type":"string"},{"name":"base64Encoded","description":"True, if content was served as base64.","type":"boolean"}]},{"name":"getResourceTree","description":"Returns present frame / resource tree structure.","experimental":true,"returns":[{"name":"frameTree","description":"Present frame / resource tree structure.","$ref":"FrameResourceTree"}]},{"name":"handleJavaScriptDialog","description":"Accepts or dismisses a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload).","parameters":[{"name":"accept","description":"Whether to accept or dismiss the dialog.","type":"boolean"},{"name":"promptText","description":"The text to enter into the dialog prompt before accepting. Used only if this is a prompt\\ndialog.","optional":true,"type":"string"}]},{"name":"navigate","description":"Navigates current page to the given URL.","parameters":[{"name":"url","description":"URL to navigate the page to.","type":"string"},{"name":"referrer","description":"Referrer URL.","optional":true,"type":"string"},{"name":"transitionType","description":"Intended transition type.","optional":true,"$ref":"TransitionType"},{"name":"frameId","description":"Frame id to navigate, if not specified navigates the top frame.","optional":true,"$ref":"FrameId"}],"returns":[{"name":"frameId","description":"Frame id that has navigated (or failed to navigate)","$ref":"FrameId"},{"name":"loaderId","description":"Loader identifier.","optional":true,"$ref":"Network.LoaderId"},{"name":"errorText","description":"User friendly error message, present if and only if navigation has failed.","optional":true,"type":"string"}]},{"name":"navigateToHistoryEntry","description":"Navigates current page to the given history entry.","parameters":[{"name":"entryId","description":"Unique id of the entry to navigate to.","type":"integer"}]},{"name":"printToPDF","description":"Print page as PDF.","parameters":[{"name":"landscape","description":"Paper orientation. Defaults to false.","optional":true,"type":"boolean"},{"name":"displayHeaderFooter","description":"Display header and footer. Defaults to false.","optional":true,"type":"boolean"},{"name":"printBackground","description":"Print background graphics. Defaults to false.","optional":true,"type":"boolean"},{"name":"scale","description":"Scale of the webpage rendering. Defaults to 1.","optional":true,"type":"number"},{"name":"paperWidth","description":"Paper width in inches. Defaults to 8.5 inches.","optional":true,"type":"number"},{"name":"paperHeight","description":"Paper height in inches. Defaults to 11 inches.","optional":true,"type":"number"},{"name":"marginTop","description":"Top margin in inches. Defaults to 1cm (~0.4 inches).","optional":true,"type":"number"},{"name":"marginBottom","description":"Bottom margin in inches. Defaults to 1cm (~0.4 inches).","optional":true,"type":"number"},{"name":"marginLeft","description":"Left margin in inches. Defaults to 1cm (~0.4 inches).","optional":true,"type":"number"},{"name":"marginRight","description":"Right margin in inches. Defaults to 1cm (~0.4 inches).","optional":true,"type":"number"},{"name":"pageRanges","description":"Paper ranges to print, e.g., \'1-5, 8, 11-13\'. Defaults to the empty string, which means\\nprint all pages.","optional":true,"type":"string"},{"name":"ignoreInvalidPageRanges","description":"Whether to silently ignore invalid but successfully parsed page ranges, such as \'3-2\'.\\nDefaults to false.","optional":true,"type":"boolean"},{"name":"headerTemplate","description":"HTML template for the print header. Should be valid HTML markup with following\\nclasses used to inject printing values into them:\\n- `date`: formatted print date\\n- `title`: document title\\n- `url`: document location\\n- `pageNumber`: current page number\\n- `totalPages`: total pages in the document\\n\\nFor example, `<span class=title></span>` would generate span containing the title.","optional":true,"type":"string"},{"name":"footerTemplate","description":"HTML template for the print footer. Should use the same format as the `headerTemplate`.","optional":true,"type":"string"},{"name":"preferCSSPageSize","description":"Whether or not to prefer page size as defined by css. Defaults to false,\\nin which case the content will be scaled to fit the paper size.","optional":true,"type":"boolean"},{"name":"transferMode","description":"return as stream","experimental":true,"optional":true,"type":"string","enum":["ReturnAsBase64","ReturnAsStream"]}],"returns":[{"name":"data","description":"Base64-encoded pdf data. Empty if |returnAsStream| is specified.","type":"string"},{"name":"stream","description":"A handle of the stream that holds resulting PDF data.","experimental":true,"optional":true,"$ref":"IO.StreamHandle"}]},{"name":"reload","description":"Reloads given page optionally ignoring the cache.","parameters":[{"name":"ignoreCache","description":"If true, browser cache is ignored (as if the user pressed Shift+refresh).","optional":true,"type":"boolean"},{"name":"scriptToEvaluateOnLoad","description":"If set, the script will be injected into all frames of the inspected page after reload.\\nArgument will be ignored if reloading dataURL origin.","optional":true,"type":"string"}]},{"name":"removeScriptToEvaluateOnLoad","description":"Deprecated, please use removeScriptToEvaluateOnNewDocument instead.","experimental":true,"deprecated":true,"parameters":[{"name":"identifier","$ref":"ScriptIdentifier"}]},{"name":"removeScriptToEvaluateOnNewDocument","description":"Removes given script from the list.","parameters":[{"name":"identifier","$ref":"ScriptIdentifier"}]},{"name":"screencastFrameAck","description":"Acknowledges that a screencast frame has been received by the frontend.","experimental":true,"parameters":[{"name":"sessionId","description":"Frame number.","type":"integer"}]},{"name":"searchInResource","description":"Searches for given string in resource content.","experimental":true,"parameters":[{"name":"frameId","description":"Frame id for resource to search in.","$ref":"FrameId"},{"name":"url","description":"URL of the resource to search in.","type":"string"},{"name":"query","description":"String to search for.","type":"string"},{"name":"caseSensitive","description":"If true, search is case sensitive.","optional":true,"type":"boolean"},{"name":"isRegex","description":"If true, treats string parameter as regex.","optional":true,"type":"boolean"}],"returns":[{"name":"result","description":"List of search matches.","type":"array","items":{"$ref":"Debugger.SearchMatch"}}]},{"name":"setAdBlockingEnabled","description":"Enable Chrome\'s experimental ad filter on all sites.","experimental":true,"parameters":[{"name":"enabled","description":"Whether to block ads.","type":"boolean"}]},{"name":"setBypassCSP","description":"Enable page Content Security Policy by-passing.","experimental":true,"parameters":[{"name":"enabled","description":"Whether to bypass page CSP.","type":"boolean"}]},{"name":"setDeviceMetricsOverride","description":"Overrides the values of device screen dimensions (window.screen.width, window.screen.height,\\nwindow.innerWidth, window.innerHeight, and \\"device-width\\"/\\"device-height\\"-related CSS media\\nquery results).","experimental":true,"deprecated":true,"redirect":"Emulation","parameters":[{"name":"width","description":"Overriding width value in pixels (minimum 0, maximum 10000000). 0 disables the override.","type":"integer"},{"name":"height","description":"Overriding height value in pixels (minimum 0, maximum 10000000). 0 disables the override.","type":"integer"},{"name":"deviceScaleFactor","description":"Overriding device scale factor value. 0 disables the override.","type":"number"},{"name":"mobile","description":"Whether to emulate mobile device. This includes viewport meta tag, overlay scrollbars, text\\nautosizing and more.","type":"boolean"},{"name":"scale","description":"Scale to apply to resulting view image.","optional":true,"type":"number"},{"name":"screenWidth","description":"Overriding screen width value in pixels (minimum 0, maximum 10000000).","optional":true,"type":"integer"},{"name":"screenHeight","description":"Overriding screen height value in pixels (minimum 0, maximum 10000000).","optional":true,"type":"integer"},{"name":"positionX","description":"Overriding view X position on screen in pixels (minimum 0, maximum 10000000).","optional":true,"type":"integer"},{"name":"positionY","description":"Overriding view Y position on screen in pixels (minimum 0, maximum 10000000).","optional":true,"type":"integer"},{"name":"dontSetVisibleSize","description":"Do not set visible view size, rely upon explicit setVisibleSize call.","optional":true,"type":"boolean"},{"name":"screenOrientation","description":"Screen orientation override.","optional":true,"$ref":"Emulation.ScreenOrientation"},{"name":"viewport","description":"The viewport dimensions and scale. If not set, the override is cleared.","optional":true,"$ref":"Viewport"}]},{"name":"setDeviceOrientationOverride","description":"Overrides the Device Orientation.","experimental":true,"deprecated":true,"redirect":"DeviceOrientation","parameters":[{"name":"alpha","description":"Mock alpha","type":"number"},{"name":"beta","description":"Mock beta","type":"number"},{"name":"gamma","description":"Mock gamma","type":"number"}]},{"name":"setFontFamilies","description":"Set generic font families.","experimental":true,"parameters":[{"name":"fontFamilies","description":"Specifies font families to set. If a font family is not specified, it won\'t be changed.","$ref":"FontFamilies"}]},{"name":"setFontSizes","description":"Set default font sizes.","experimental":true,"parameters":[{"name":"fontSizes","description":"Specifies font sizes to set. If a font size is not specified, it won\'t be changed.","$ref":"FontSizes"}]},{"name":"setDocumentContent","description":"Sets given markup as the document\'s HTML.","parameters":[{"name":"frameId","description":"Frame id to set HTML for.","$ref":"FrameId"},{"name":"html","description":"HTML content to set.","type":"string"}]},{"name":"setDownloadBehavior","description":"Set the behavior when downloading a file.","experimental":true,"parameters":[{"name":"behavior","description":"Whether to allow all or deny all download requests, or use default Chrome behavior if\\navailable (otherwise deny).","type":"string","enum":["deny","allow","default"]},{"name":"downloadPath","description":"The default path to save downloaded files to. This is requred if behavior is set to \'allow\'","optional":true,"type":"string"}]},{"name":"setGeolocationOverride","description":"Overrides the Geolocation Position or Error. Omitting any of the parameters emulates position\\nunavailable.","deprecated":true,"redirect":"Emulation","parameters":[{"name":"latitude","description":"Mock latitude","optional":true,"type":"number"},{"name":"longitude","description":"Mock longitude","optional":true,"type":"number"},{"name":"accuracy","description":"Mock accuracy","optional":true,"type":"number"}]},{"name":"setLifecycleEventsEnabled","description":"Controls whether page will emit lifecycle events.","experimental":true,"parameters":[{"name":"enabled","description":"If true, starts emitting lifecycle events.","type":"boolean"}]},{"name":"setTouchEmulationEnabled","description":"Toggles mouse event-based touch event emulation.","experimental":true,"deprecated":true,"redirect":"Emulation","parameters":[{"name":"enabled","description":"Whether the touch event emulation should be enabled.","type":"boolean"},{"name":"configuration","description":"Touch/gesture events configuration. Default: current platform.","optional":true,"type":"string","enum":["mobile","desktop"]}]},{"name":"startScreencast","description":"Starts sending each frame using the `screencastFrame` event.","experimental":true,"parameters":[{"name":"format","description":"Image compression format.","optional":true,"type":"string","enum":["jpeg","png"]},{"name":"quality","description":"Compression quality from range [0..100].","optional":true,"type":"integer"},{"name":"maxWidth","description":"Maximum screenshot width.","optional":true,"type":"integer"},{"name":"maxHeight","description":"Maximum screenshot height.","optional":true,"type":"integer"},{"name":"everyNthFrame","description":"Send every n-th frame.","optional":true,"type":"integer"}]},{"name":"stopLoading","description":"Force the page stop all navigations and pending resource fetches."},{"name":"crash","description":"Crashes renderer on the IO thread, generates minidumps.","experimental":true},{"name":"close","description":"Tries to close page, running its beforeunload hooks, if any.","experimental":true},{"name":"setWebLifecycleState","description":"Tries to update the web lifecycle state of the page.\\nIt will transition the page to the given state according to:\\nhttps://github.com/WICG/web-lifecycle/","experimental":true,"parameters":[{"name":"state","description":"Target lifecycle state","type":"string","enum":["frozen","active"]}]},{"name":"stopScreencast","description":"Stops sending each frame in the `screencastFrame`.","experimental":true},{"name":"setProduceCompilationCache","description":"Forces compilation cache to be generated for every subresource script.","experimental":true,"parameters":[{"name":"enabled","type":"boolean"}]},{"name":"addCompilationCache","description":"Seeds compilation cache for given url. Compilation cache does not survive\\ncross-process navigation.","experimental":true,"parameters":[{"name":"url","type":"string"},{"name":"data","description":"Base64-encoded data","type":"string"}]},{"name":"clearCompilationCache","description":"Clears seeded compilation cache.","experimental":true},{"name":"generateTestReport","description":"Generates a report for testing.","experimental":true,"parameters":[{"name":"message","description":"Message to be displayed in the report.","type":"string"},{"name":"group","description":"Specifies the endpoint group to deliver the report to.","optional":true,"type":"string"}]},{"name":"waitForDebugger","description":"Pauses page execution. Can be resumed using generic Runtime.runIfWaitingForDebugger.","experimental":true},{"name":"setInterceptFileChooserDialog","description":"Intercept file chooser requests and transfer control to protocol clients.\\nWhen file chooser interception is enabled, native file chooser dialog is not shown.\\nInstead, a protocol event `Page.fileChooserOpened` is emitted.\\nFile chooser can be handled with `page.handleFileChooser` command.","experimental":true,"parameters":[{"name":"enabled","type":"boolean"}]},{"name":"handleFileChooser","description":"Accepts or cancels an intercepted file chooser dialog.","experimental":true,"parameters":[{"name":"action","type":"string","enum":["accept","cancel","fallback"]},{"name":"files","description":"Array of absolute file paths to set, only respected with `accept` action.","optional":true,"type":"array","items":{"type":"string"}}]}],"events":[{"name":"domContentEventFired","parameters":[{"name":"timestamp","$ref":"Network.MonotonicTime"}]},{"name":"fileChooserOpened","description":"Emitted only when `page.interceptFileChooser` is enabled.","parameters":[{"name":"mode","type":"string","enum":["selectSingle","selectMultiple"]}]},{"name":"frameAttached","description":"Fired when frame has been attached to its parent.","parameters":[{"name":"frameId","description":"Id of the frame that has been attached.","$ref":"FrameId"},{"name":"parentFrameId","description":"Parent frame identifier.","$ref":"FrameId"},{"name":"stack","description":"JavaScript stack trace of when frame was attached, only set if frame initiated from script.","optional":true,"$ref":"Runtime.StackTrace"}]},{"name":"frameClearedScheduledNavigation","description":"Fired when frame no longer has a scheduled navigation.","deprecated":true,"parameters":[{"name":"frameId","description":"Id of the frame that has cleared its scheduled navigation.","$ref":"FrameId"}]},{"name":"frameDetached","description":"Fired when frame has been detached from its parent.","parameters":[{"name":"frameId","description":"Id of the frame that has been detached.","$ref":"FrameId"}]},{"name":"frameNavigated","description":"Fired once navigation of the frame has completed. Frame is now associated with the new loader.","parameters":[{"name":"frame","description":"Frame object.","$ref":"Frame"}]},{"name":"frameResized","experimental":true},{"name":"frameRequestedNavigation","description":"Fired when a renderer-initiated navigation is requested.\\nNavigation may still be cancelled after the event is issued.","experimental":true,"parameters":[{"name":"frameId","description":"Id of the frame that is being navigated.","$ref":"FrameId"},{"name":"reason","description":"The reason for the navigation.","$ref":"ClientNavigationReason"},{"name":"url","description":"The destination URL for the requested navigation.","type":"string"}]},{"name":"frameScheduledNavigation","description":"Fired when frame schedules a potential navigation.","deprecated":true,"parameters":[{"name":"frameId","description":"Id of the frame that has scheduled a navigation.","$ref":"FrameId"},{"name":"delay","description":"Delay (in seconds) until the navigation is scheduled to begin. The navigation is not\\nguaranteed to start.","type":"number"},{"name":"reason","description":"The reason for the navigation.","type":"string","enum":["formSubmissionGet","formSubmissionPost","httpHeaderRefresh","scriptInitiated","metaTagRefresh","pageBlockInterstitial","reload"]},{"name":"url","description":"The destination URL for the scheduled navigation.","type":"string"}]},{"name":"frameStartedLoading","description":"Fired when frame has started loading.","experimental":true,"parameters":[{"name":"frameId","description":"Id of the frame that has started loading.","$ref":"FrameId"}]},{"name":"frameStoppedLoading","description":"Fired when frame has stopped loading.","experimental":true,"parameters":[{"name":"frameId","description":"Id of the frame that has stopped loading.","$ref":"FrameId"}]},{"name":"downloadWillBegin","description":"Fired when page is about to start a download.","experimental":true,"parameters":[{"name":"frameId","description":"Id of the frame that caused download to begin.","$ref":"FrameId"},{"name":"url","description":"URL of the resource being downloaded.","type":"string"}]},{"name":"interstitialHidden","description":"Fired when interstitial page was hidden"},{"name":"interstitialShown","description":"Fired when interstitial page was shown"},{"name":"javascriptDialogClosed","description":"Fired when a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload) has been\\nclosed.","parameters":[{"name":"result","description":"Whether dialog was confirmed.","type":"boolean"},{"name":"userInput","description":"User input in case of prompt.","type":"string"}]},{"name":"javascriptDialogOpening","description":"Fired when a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload) is about to\\nopen.","parameters":[{"name":"url","description":"Frame url.","type":"string"},{"name":"message","description":"Message that will be displayed by the dialog.","type":"string"},{"name":"type","description":"Dialog type.","$ref":"DialogType"},{"name":"hasBrowserHandler","description":"True iff browser is capable showing or acting on the given dialog. When browser has no\\ndialog handler for given target, calling alert while Page domain is engaged will stall\\nthe page execution. Execution can be resumed via calling Page.handleJavaScriptDialog.","type":"boolean"},{"name":"defaultPrompt","description":"Default dialog prompt.","optional":true,"type":"string"}]},{"name":"lifecycleEvent","description":"Fired for top level page lifecycle events such as navigation, load, paint, etc.","parameters":[{"name":"frameId","description":"Id of the frame.","$ref":"FrameId"},{"name":"loaderId","description":"Loader identifier. Empty string if the request is fetched from worker.","$ref":"Network.LoaderId"},{"name":"name","type":"string"},{"name":"timestamp","$ref":"Network.MonotonicTime"}]},{"name":"loadEventFired","parameters":[{"name":"timestamp","$ref":"Network.MonotonicTime"}]},{"name":"navigatedWithinDocument","description":"Fired when same-document navigation happens, e.g. due to history API usage or anchor navigation.","experimental":true,"parameters":[{"name":"frameId","description":"Id of the frame.","$ref":"FrameId"},{"name":"url","description":"Frame\'s new url.","type":"string"}]},{"name":"screencastFrame","description":"Compressed image data requested by the `startScreencast`.","experimental":true,"parameters":[{"name":"data","description":"Base64-encoded compressed image.","type":"string"},{"name":"metadata","description":"Screencast frame metadata.","$ref":"ScreencastFrameMetadata"},{"name":"sessionId","description":"Frame number.","type":"integer"}]},{"name":"screencastVisibilityChanged","description":"Fired when the page with currently enabled screencast was shown or hidden `.","experimental":true,"parameters":[{"name":"visible","description":"True if the page is visible.","type":"boolean"}]},{"name":"windowOpen","description":"Fired when a new window is going to be opened, via window.open(), link click, form submission,\\netc.","parameters":[{"name":"url","description":"The URL for the new window.","type":"string"},{"name":"windowName","description":"Window name.","type":"string"},{"name":"windowFeatures","description":"An array of enabled window features.","type":"array","items":{"type":"string"}},{"name":"userGesture","description":"Whether or not it was triggered by user gesture.","type":"boolean"}]},{"name":"compilationCacheProduced","description":"Issued for every compilation cache generated. Is only available\\nif Page.setGenerateCompilationCache is enabled.","experimental":true,"parameters":[{"name":"url","type":"string"},{"name":"data","description":"Base64-encoded data","type":"string"}]}]},{"domain":"Performance","types":[{"id":"Metric","description":"Run-time execution metric.","type":"object","properties":[{"name":"name","description":"Metric name.","type":"string"},{"name":"value","description":"Metric value.","type":"number"}]}],"commands":[{"name":"disable","description":"Disable collecting and reporting metrics."},{"name":"enable","description":"Enable collecting and reporting metrics."},{"name":"setTimeDomain","description":"Sets time domain to use for collecting and reporting duration metrics.\\nNote that this must be called before enabling metrics collection. Calling\\nthis method while metrics collection is enabled returns an error.","experimental":true,"parameters":[{"name":"timeDomain","description":"Time domain","type":"string","enum":["timeTicks","threadTicks"]}]},{"name":"getMetrics","description":"Retrieve current values of run-time metrics.","returns":[{"name":"metrics","description":"Current values for run-time metrics.","type":"array","items":{"$ref":"Metric"}}]}],"events":[{"name":"metrics","description":"Current values of the metrics.","parameters":[{"name":"metrics","description":"Current values of the metrics.","type":"array","items":{"$ref":"Metric"}},{"name":"title","description":"Timestamp title.","type":"string"}]}]},{"domain":"Security","description":"Security","types":[{"id":"CertificateId","description":"An internal certificate ID value.","type":"integer"},{"id":"MixedContentType","description":"A description of mixed content (HTTP resources on HTTPS pages), as defined by\\nhttps://www.w3.org/TR/mixed-content/#categories","type":"string","enum":["blockable","optionally-blockable","none"]},{"id":"SecurityState","description":"The security level of a page or resource.","type":"string","enum":["unknown","neutral","insecure","secure","info"]},{"id":"SecurityStateExplanation","description":"An explanation of an factor contributing to the security state.","type":"object","properties":[{"name":"securityState","description":"Security state representing the severity of the factor being explained.","$ref":"SecurityState"},{"name":"title","description":"Title describing the type of factor.","type":"string"},{"name":"summary","description":"Short phrase describing the type of factor.","type":"string"},{"name":"description","description":"Full text explanation of the factor.","type":"string"},{"name":"mixedContentType","description":"The type of mixed content described by the explanation.","$ref":"MixedContentType"},{"name":"certificate","description":"Page certificate.","type":"array","items":{"type":"string"}},{"name":"recommendations","description":"Recommendations to fix any issues.","optional":true,"type":"array","items":{"type":"string"}}]},{"id":"InsecureContentStatus","description":"Information about insecure content on the page.","deprecated":true,"type":"object","properties":[{"name":"ranMixedContent","description":"Always false.","type":"boolean"},{"name":"displayedMixedContent","description":"Always false.","type":"boolean"},{"name":"containedMixedForm","description":"Always false.","type":"boolean"},{"name":"ranContentWithCertErrors","description":"Always false.","type":"boolean"},{"name":"displayedContentWithCertErrors","description":"Always false.","type":"boolean"},{"name":"ranInsecureContentStyle","description":"Always set to unknown.","$ref":"SecurityState"},{"name":"displayedInsecureContentStyle","description":"Always set to unknown.","$ref":"SecurityState"}]},{"id":"CertificateErrorAction","description":"The action to take when a certificate error occurs. continue will continue processing the\\nrequest and cancel will cancel the request.","type":"string","enum":["continue","cancel"]}],"commands":[{"name":"disable","description":"Disables tracking security state changes."},{"name":"enable","description":"Enables tracking security state changes."},{"name":"setIgnoreCertificateErrors","description":"Enable/disable whether all certificate errors should be ignored.","experimental":true,"parameters":[{"name":"ignore","description":"If true, all certificate errors will be ignored.","type":"boolean"}]},{"name":"handleCertificateError","description":"Handles a certificate error that fired a certificateError event.","deprecated":true,"parameters":[{"name":"eventId","description":"The ID of the event.","type":"integer"},{"name":"action","description":"The action to take on the certificate error.","$ref":"CertificateErrorAction"}]},{"name":"setOverrideCertificateErrors","description":"Enable/disable overriding certificate errors. If enabled, all certificate error events need to\\nbe handled by the DevTools client and should be answered with `handleCertificateError` commands.","deprecated":true,"parameters":[{"name":"override","description":"If true, certificate errors will be overridden.","type":"boolean"}]}],"events":[{"name":"certificateError","description":"There is a certificate error. If overriding certificate errors is enabled, then it should be\\nhandled with the `handleCertificateError` command. Note: this event does not fire if the\\ncertificate error has been allowed internally. Only one client per target should override\\ncertificate errors at the same time.","deprecated":true,"parameters":[{"name":"eventId","description":"The ID of the event.","type":"integer"},{"name":"errorType","description":"The type of the error.","type":"string"},{"name":"requestURL","description":"The url that was requested.","type":"string"}]},{"name":"securityStateChanged","description":"The security state of the page changed.","parameters":[{"name":"securityState","description":"Security state.","$ref":"SecurityState"},{"name":"schemeIsCryptographic","description":"True if the page was loaded over cryptographic transport such as HTTPS.","deprecated":true,"type":"boolean"},{"name":"explanations","description":"List of explanations for the security state. If the overall security state is `insecure` or\\n`warning`, at least one corresponding explanation should be included.","type":"array","items":{"$ref":"SecurityStateExplanation"}},{"name":"insecureContentStatus","description":"Information about insecure content on the page.","deprecated":true,"$ref":"InsecureContentStatus"},{"name":"summary","description":"Overrides user-visible description of the state.","optional":true,"type":"string"}]}]},{"domain":"ServiceWorker","experimental":true,"types":[{"id":"RegistrationID","type":"string"},{"id":"ServiceWorkerRegistration","description":"ServiceWorker registration.","type":"object","properties":[{"name":"registrationId","$ref":"RegistrationID"},{"name":"scopeURL","type":"string"},{"name":"isDeleted","type":"boolean"}]},{"id":"ServiceWorkerVersionRunningStatus","type":"string","enum":["stopped","starting","running","stopping"]},{"id":"ServiceWorkerVersionStatus","type":"string","enum":["new","installing","installed","activating","activated","redundant"]},{"id":"ServiceWorkerVersion","description":"ServiceWorker version.","type":"object","properties":[{"name":"versionId","type":"string"},{"name":"registrationId","$ref":"RegistrationID"},{"name":"scriptURL","type":"string"},{"name":"runningStatus","$ref":"ServiceWorkerVersionRunningStatus"},{"name":"status","$ref":"ServiceWorkerVersionStatus"},{"name":"scriptLastModified","description":"The Last-Modified header value of the main script.","optional":true,"type":"number"},{"name":"scriptResponseTime","description":"The time at which the response headers of the main script were received from the server.\\nFor cached script it is the last time the cache entry was validated.","optional":true,"type":"number"},{"name":"controlledClients","optional":true,"type":"array","items":{"$ref":"Target.TargetID"}},{"name":"targetId","optional":true,"$ref":"Target.TargetID"}]},{"id":"ServiceWorkerErrorMessage","description":"ServiceWorker error message.","type":"object","properties":[{"name":"errorMessage","type":"string"},{"name":"registrationId","$ref":"RegistrationID"},{"name":"versionId","type":"string"},{"name":"sourceURL","type":"string"},{"name":"lineNumber","type":"integer"},{"name":"columnNumber","type":"integer"}]}],"commands":[{"name":"deliverPushMessage","parameters":[{"name":"origin","type":"string"},{"name":"registrationId","$ref":"RegistrationID"},{"name":"data","type":"string"}]},{"name":"disable"},{"name":"dispatchSyncEvent","parameters":[{"name":"origin","type":"string"},{"name":"registrationId","$ref":"RegistrationID"},{"name":"tag","type":"string"},{"name":"lastChance","type":"boolean"}]},{"name":"enable"},{"name":"inspectWorker","parameters":[{"name":"versionId","type":"string"}]},{"name":"setForceUpdateOnPageLoad","parameters":[{"name":"forceUpdateOnPageLoad","type":"boolean"}]},{"name":"skipWaiting","parameters":[{"name":"scopeURL","type":"string"}]},{"name":"startWorker","parameters":[{"name":"scopeURL","type":"string"}]},{"name":"stopAllWorkers"},{"name":"stopWorker","parameters":[{"name":"versionId","type":"string"}]},{"name":"unregister","parameters":[{"name":"scopeURL","type":"string"}]},{"name":"updateRegistration","parameters":[{"name":"scopeURL","type":"string"}]}],"events":[{"name":"workerErrorReported","parameters":[{"name":"errorMessage","$ref":"ServiceWorkerErrorMessage"}]},{"name":"workerRegistrationUpdated","parameters":[{"name":"registrations","type":"array","items":{"$ref":"ServiceWorkerRegistration"}}]},{"name":"workerVersionUpdated","parameters":[{"name":"versions","type":"array","items":{"$ref":"ServiceWorkerVersion"}}]}]},{"domain":"Storage","experimental":true,"types":[{"id":"StorageType","description":"Enum of possible storage types.","type":"string","enum":["appcache","cookies","file_systems","indexeddb","local_storage","shader_cache","websql","service_workers","cache_storage","all","other"]},{"id":"UsageForType","description":"Usage for a storage type.","type":"object","properties":[{"name":"storageType","description":"Name of storage type.","$ref":"StorageType"},{"name":"usage","description":"Storage usage (bytes).","type":"number"}]}],"commands":[{"name":"clearDataForOrigin","description":"Clears storage for origin.","parameters":[{"name":"origin","description":"Security origin.","type":"string"},{"name":"storageTypes","description":"Comma separated list of StorageType to clear.","type":"string"}]},{"name":"getUsageAndQuota","description":"Returns usage and quota in bytes.","parameters":[{"name":"origin","description":"Security origin.","type":"string"}],"returns":[{"name":"usage","description":"Storage usage (bytes).","type":"number"},{"name":"quota","description":"Storage quota (bytes).","type":"number"},{"name":"usageBreakdown","description":"Storage usage per type (bytes).","type":"array","items":{"$ref":"UsageForType"}}]},{"name":"trackCacheStorageForOrigin","description":"Registers origin to be notified when an update occurs to its cache storage list.","parameters":[{"name":"origin","description":"Security origin.","type":"string"}]},{"name":"trackIndexedDBForOrigin","description":"Registers origin to be notified when an update occurs to its IndexedDB.","parameters":[{"name":"origin","description":"Security origin.","type":"string"}]},{"name":"untrackCacheStorageForOrigin","description":"Unregisters origin from receiving notifications for cache storage.","parameters":[{"name":"origin","description":"Security origin.","type":"string"}]},{"name":"untrackIndexedDBForOrigin","description":"Unregisters origin from receiving notifications for IndexedDB.","parameters":[{"name":"origin","description":"Security origin.","type":"string"}]}],"events":[{"name":"cacheStorageContentUpdated","description":"A cache\'s contents have been modified.","parameters":[{"name":"origin","description":"Origin to update.","type":"string"},{"name":"cacheName","description":"Name of cache in origin.","type":"string"}]},{"name":"cacheStorageListUpdated","description":"A cache has been added/deleted.","parameters":[{"name":"origin","description":"Origin to update.","type":"string"}]},{"name":"indexedDBContentUpdated","description":"The origin\'s IndexedDB object store has been modified.","parameters":[{"name":"origin","description":"Origin to update.","type":"string"},{"name":"databaseName","description":"Database to update.","type":"string"},{"name":"objectStoreName","description":"ObjectStore to update.","type":"string"}]},{"name":"indexedDBListUpdated","description":"The origin\'s IndexedDB database list has been modified.","parameters":[{"name":"origin","description":"Origin to update.","type":"string"}]}]},{"domain":"SystemInfo","description":"The SystemInfo domain defines methods and events for querying low-level system information.","experimental":true,"types":[{"id":"GPUDevice","description":"Describes a single graphics processor (GPU).","type":"object","properties":[{"name":"vendorId","description":"PCI ID of the GPU vendor, if available; 0 otherwise.","type":"number"},{"name":"deviceId","description":"PCI ID of the GPU device, if available; 0 otherwise.","type":"number"},{"name":"vendorString","description":"String description of the GPU vendor, if the PCI ID is not available.","type":"string"},{"name":"deviceString","description":"String description of the GPU device, if the PCI ID is not available.","type":"string"},{"name":"driverVendor","description":"String description of the GPU driver vendor.","type":"string"},{"name":"driverVersion","description":"String description of the GPU driver version.","type":"string"}]},{"id":"Size","description":"Describes the width and height dimensions of an entity.","type":"object","properties":[{"name":"width","description":"Width in pixels.","type":"integer"},{"name":"height","description":"Height in pixels.","type":"integer"}]},{"id":"VideoDecodeAcceleratorCapability","description":"Describes a supported video decoding profile with its associated minimum and\\nmaximum resolutions.","type":"object","properties":[{"name":"profile","description":"Video codec profile that is supported, e.g. VP9 Profile 2.","type":"string"},{"name":"maxResolution","description":"Maximum video dimensions in pixels supported for this |profile|.","$ref":"Size"},{"name":"minResolution","description":"Minimum video dimensions in pixels supported for this |profile|.","$ref":"Size"}]},{"id":"VideoEncodeAcceleratorCapability","description":"Describes a supported video encoding profile with its associated maximum\\nresolution and maximum framerate.","type":"object","properties":[{"name":"profile","description":"Video codec profile that is supported, e.g H264 Main.","type":"string"},{"name":"maxResolution","description":"Maximum video dimensions in pixels supported for this |profile|.","$ref":"Size"},{"name":"maxFramerateNumerator","description":"Maximum encoding framerate in frames per second supported for this\\n|profile|, as fraction\'s numerator and denominator, e.g. 24/1 fps,\\n24000/1001 fps, etc.","type":"integer"},{"name":"maxFramerateDenominator","type":"integer"}]},{"id":"SubsamplingFormat","description":"YUV subsampling type of the pixels of a given image.","type":"string","enum":["yuv420","yuv422","yuv444"]},{"id":"ImageDecodeAcceleratorCapability","description":"Describes a supported image decoding profile with its associated minimum and\\nmaximum resolutions and subsampling.","type":"object","properties":[{"name":"imageType","description":"Image coded, e.g. Jpeg.","type":"string"},{"name":"maxDimensions","description":"Maximum supported dimensions of the image in pixels.","$ref":"Size"},{"name":"minDimensions","description":"Minimum supported dimensions of the image in pixels.","$ref":"Size"},{"name":"subsamplings","description":"Optional array of supported subsampling formats, e.g. 4:2:0, if known.","type":"array","items":{"$ref":"SubsamplingFormat"}}]},{"id":"GPUInfo","description":"Provides information about the GPU(s) on the system.","type":"object","properties":[{"name":"devices","description":"The graphics devices on the system. Element 0 is the primary GPU.","type":"array","items":{"$ref":"GPUDevice"}},{"name":"auxAttributes","description":"An optional dictionary of additional GPU related attributes.","optional":true,"type":"object"},{"name":"featureStatus","description":"An optional dictionary of graphics features and their status.","optional":true,"type":"object"},{"name":"driverBugWorkarounds","description":"An optional array of GPU driver bug workarounds.","type":"array","items":{"type":"string"}},{"name":"videoDecoding","description":"Supported accelerated video decoding capabilities.","type":"array","items":{"$ref":"VideoDecodeAcceleratorCapability"}},{"name":"videoEncoding","description":"Supported accelerated video encoding capabilities.","type":"array","items":{"$ref":"VideoEncodeAcceleratorCapability"}},{"name":"imageDecoding","description":"Supported accelerated image decoding capabilities.","type":"array","items":{"$ref":"ImageDecodeAcceleratorCapability"}}]},{"id":"ProcessInfo","description":"Represents process info.","type":"object","properties":[{"name":"type","description":"Specifies process type.","type":"string"},{"name":"id","description":"Specifies process id.","type":"integer"},{"name":"cpuTime","description":"Specifies cumulative CPU usage in seconds across all threads of the\\nprocess since the process start.","type":"number"}]}],"commands":[{"name":"getInfo","description":"Returns information about the system.","returns":[{"name":"gpu","description":"Information about the GPUs on the system.","$ref":"GPUInfo"},{"name":"modelName","description":"A platform-dependent description of the model of the machine. On Mac OS, this is, for\\nexample, \'MacBookPro\'. Will be the empty string if not supported.","type":"string"},{"name":"modelVersion","description":"A platform-dependent description of the version of the machine. On Mac OS, this is, for\\nexample, \'10.1\'. Will be the empty string if not supported.","type":"string"},{"name":"commandLine","description":"The command line string used to launch the browser. Will be the empty string if not\\nsupported.","type":"string"}]},{"name":"getProcessInfo","description":"Returns information about all running processes.","returns":[{"name":"processInfo","description":"An array of process info blocks.","type":"array","items":{"$ref":"ProcessInfo"}}]}]},{"domain":"Target","description":"Supports additional targets discovery and allows to attach to them.","types":[{"id":"TargetID","type":"string"},{"id":"SessionID","description":"Unique identifier of attached debugging session.","type":"string"},{"id":"BrowserContextID","experimental":true,"type":"string"},{"id":"TargetInfo","type":"object","properties":[{"name":"targetId","$ref":"TargetID"},{"name":"type","type":"string"},{"name":"title","type":"string"},{"name":"url","type":"string"},{"name":"attached","description":"Whether the target has an attached client.","type":"boolean"},{"name":"openerId","description":"Opener target Id","optional":true,"$ref":"TargetID"},{"name":"browserContextId","experimental":true,"optional":true,"$ref":"BrowserContextID"}]},{"id":"RemoteLocation","experimental":true,"type":"object","properties":[{"name":"host","type":"string"},{"name":"port","type":"integer"}]}],"commands":[{"name":"activateTarget","description":"Activates (focuses) the target.","parameters":[{"name":"targetId","$ref":"TargetID"}]},{"name":"attachToTarget","description":"Attaches to the target with given id.","parameters":[{"name":"targetId","$ref":"TargetID"},{"name":"flatten","description":"Enables \\"flat\\" access to the session via specifying sessionId attribute in the commands.","experimental":true,"optional":true,"type":"boolean"}],"returns":[{"name":"sessionId","description":"Id assigned to the session.","$ref":"SessionID"}]},{"name":"attachToBrowserTarget","description":"Attaches to the browser target, only uses flat sessionId mode.","experimental":true,"returns":[{"name":"sessionId","description":"Id assigned to the session.","$ref":"SessionID"}]},{"name":"closeTarget","description":"Closes the target. If the target is a page that gets closed too.","parameters":[{"name":"targetId","$ref":"TargetID"}],"returns":[{"name":"success","type":"boolean"}]},{"name":"exposeDevToolsProtocol","description":"Inject object to the target\'s main frame that provides a communication\\nchannel with browser target.\\n\\nInjected object will be available as `window[bindingName]`.\\n\\nThe object has the follwing API:\\n- `binding.send(json)` - a method to send messages over the remote debugging protocol\\n- `binding.onmessage = json => handleMessage(json)` - a callback that will be called for the protocol notifications and command responses.","experimental":true,"parameters":[{"name":"targetId","$ref":"TargetID"},{"name":"bindingName","description":"Binding name, \'cdp\' if not specified.","optional":true,"type":"string"}]},{"name":"createBrowserContext","description":"Creates a new empty BrowserContext. Similar to an incognito profile but you can have more than\\none.","experimental":true,"returns":[{"name":"browserContextId","description":"The id of the context created.","$ref":"BrowserContextID"}]},{"name":"getBrowserContexts","description":"Returns all browser contexts created with `Target.createBrowserContext` method.","experimental":true,"returns":[{"name":"browserContextIds","description":"An array of browser context ids.","type":"array","items":{"$ref":"BrowserContextID"}}]},{"name":"createTarget","description":"Creates a new page.","parameters":[{"name":"url","description":"The initial URL the page will be navigated to.","type":"string"},{"name":"width","description":"Frame width in DIP (headless chrome only).","optional":true,"type":"integer"},{"name":"height","description":"Frame height in DIP (headless chrome only).","optional":true,"type":"integer"},{"name":"browserContextId","description":"The browser context to create the page in.","optional":true,"$ref":"BrowserContextID"},{"name":"enableBeginFrameControl","description":"Whether BeginFrames for this target will be controlled via DevTools (headless chrome only,\\nnot supported on MacOS yet, false by default).","experimental":true,"optional":true,"type":"boolean"},{"name":"newWindow","description":"Whether to create a new Window or Tab (chrome-only, false by default).","optional":true,"type":"boolean"},{"name":"background","description":"Whether to create the target in background or foreground (chrome-only,\\nfalse by default).","optional":true,"type":"boolean"}],"returns":[{"name":"targetId","description":"The id of the page opened.","$ref":"TargetID"}]},{"name":"detachFromTarget","description":"Detaches session with given id.","parameters":[{"name":"sessionId","description":"Session to detach.","optional":true,"$ref":"SessionID"},{"name":"targetId","description":"Deprecated.","deprecated":true,"optional":true,"$ref":"TargetID"}]},{"name":"disposeBrowserContext","description":"Deletes a BrowserContext. All the belonging pages will be closed without calling their\\nbeforeunload hooks.","experimental":true,"parameters":[{"name":"browserContextId","$ref":"BrowserContextID"}]},{"name":"getTargetInfo","description":"Returns information about a target.","experimental":true,"parameters":[{"name":"targetId","optional":true,"$ref":"TargetID"}],"returns":[{"name":"targetInfo","$ref":"TargetInfo"}]},{"name":"getTargets","description":"Retrieves a list of available targets.","returns":[{"name":"targetInfos","description":"The list of targets.","type":"array","items":{"$ref":"TargetInfo"}}]},{"name":"sendMessageToTarget","description":"Sends protocol message over session with given id.","parameters":[{"name":"message","type":"string"},{"name":"sessionId","description":"Identifier of the session.","optional":true,"$ref":"SessionID"},{"name":"targetId","description":"Deprecated.","deprecated":true,"optional":true,"$ref":"TargetID"}]},{"name":"setAutoAttach","description":"Controls whether to automatically attach to new targets which are considered to be related to\\nthis one. When turned on, attaches to all existing related targets as well. When turned off,\\nautomatically detaches from all currently attached targets.","experimental":true,"parameters":[{"name":"autoAttach","description":"Whether to auto-attach to related targets.","type":"boolean"},{"name":"waitForDebuggerOnStart","description":"Whether to pause new targets when attaching to them. Use `Runtime.runIfWaitingForDebugger`\\nto run paused targets.","type":"boolean"},{"name":"flatten","description":"Enables \\"flat\\" access to the session via specifying sessionId attribute in the commands.","experimental":true,"optional":true,"type":"boolean"}]},{"name":"setDiscoverTargets","description":"Controls whether to discover available targets and notify via\\n`targetCreated/targetInfoChanged/targetDestroyed` events.","parameters":[{"name":"discover","description":"Whether to discover available targets.","type":"boolean"}]},{"name":"setRemoteLocations","description":"Enables target discovery for the specified locations, when `setDiscoverTargets` was set to\\n`true`.","experimental":true,"parameters":[{"name":"locations","description":"List of remote locations.","type":"array","items":{"$ref":"RemoteLocation"}}]}],"events":[{"name":"attachedToTarget","description":"Issued when attached to target because of auto-attach or `attachToTarget` command.","experimental":true,"parameters":[{"name":"sessionId","description":"Identifier assigned to the session used to send/receive messages.","$ref":"SessionID"},{"name":"targetInfo","$ref":"TargetInfo"},{"name":"waitingForDebugger","type":"boolean"}]},{"name":"detachedFromTarget","description":"Issued when detached from target for any reason (including `detachFromTarget` command). Can be\\nissued multiple times per target if multiple sessions have been attached to it.","experimental":true,"parameters":[{"name":"sessionId","description":"Detached session identifier.","$ref":"SessionID"},{"name":"targetId","description":"Deprecated.","deprecated":true,"optional":true,"$ref":"TargetID"}]},{"name":"receivedMessageFromTarget","description":"Notifies about a new protocol message received from the session (as reported in\\n`attachedToTarget` event).","parameters":[{"name":"sessionId","description":"Identifier of a session which sends a message.","$ref":"SessionID"},{"name":"message","type":"string"},{"name":"targetId","description":"Deprecated.","deprecated":true,"optional":true,"$ref":"TargetID"}]},{"name":"targetCreated","description":"Issued when a possible inspection target is created.","parameters":[{"name":"targetInfo","$ref":"TargetInfo"}]},{"name":"targetDestroyed","description":"Issued when a target is destroyed.","parameters":[{"name":"targetId","$ref":"TargetID"}]},{"name":"targetCrashed","description":"Issued when a target has crashed.","parameters":[{"name":"targetId","$ref":"TargetID"},{"name":"status","description":"Termination status type.","type":"string"},{"name":"errorCode","description":"Termination error code.","type":"integer"}]},{"name":"targetInfoChanged","description":"Issued when some information about a target has changed. This only happens between\\n`targetCreated` and `targetDestroyed`.","parameters":[{"name":"targetInfo","$ref":"TargetInfo"}]}]},{"domain":"Tethering","description":"The Tethering domain defines methods and events for browser port binding.","experimental":true,"commands":[{"name":"bind","description":"Request browser port binding.","parameters":[{"name":"port","description":"Port number to bind.","type":"integer"}]},{"name":"unbind","description":"Request browser port unbinding.","parameters":[{"name":"port","description":"Port number to unbind.","type":"integer"}]}],"events":[{"name":"accepted","description":"Informs that port was successfully bound and got a specified connection id.","parameters":[{"name":"port","description":"Port number that was successfully bound.","type":"integer"},{"name":"connectionId","description":"Connection id to be used.","type":"string"}]}]},{"domain":"Tracing","experimental":true,"dependencies":["IO"],"types":[{"id":"MemoryDumpConfig","description":"Configuration for memory dump. Used only when \\"memory-infra\\" category is enabled.","type":"object"},{"id":"TraceConfig","type":"object","properties":[{"name":"recordMode","description":"Controls how the trace buffer stores data.","optional":true,"type":"string","enum":["recordUntilFull","recordContinuously","recordAsMuchAsPossible","echoToConsole"]},{"name":"enableSampling","description":"Turns on JavaScript stack sampling.","optional":true,"type":"boolean"},{"name":"enableSystrace","description":"Turns on system tracing.","optional":true,"type":"boolean"},{"name":"enableArgumentFilter","description":"Turns on argument filter.","optional":true,"type":"boolean"},{"name":"includedCategories","description":"Included category filters.","optional":true,"type":"array","items":{"type":"string"}},{"name":"excludedCategories","description":"Excluded category filters.","optional":true,"type":"array","items":{"type":"string"}},{"name":"syntheticDelays","description":"Configuration to synthesize the delays in tracing.","optional":true,"type":"array","items":{"type":"string"}},{"name":"memoryDumpConfig","description":"Configuration for memory dump triggers. Used only when \\"memory-infra\\" category is enabled.","optional":true,"$ref":"MemoryDumpConfig"}]},{"id":"StreamFormat","description":"Data format of a trace. Can be either the legacy JSON format or the\\nprotocol buffer format. Note that the JSON format will be deprecated soon.","type":"string","enum":["json","proto"]},{"id":"StreamCompression","description":"Compression type to use for traces returned via streams.","type":"string","enum":["none","gzip"]}],"commands":[{"name":"end","description":"Stop trace events collection."},{"name":"getCategories","description":"Gets supported tracing categories.","returns":[{"name":"categories","description":"A list of supported tracing categories.","type":"array","items":{"type":"string"}}]},{"name":"recordClockSyncMarker","description":"Record a clock sync marker in the trace.","parameters":[{"name":"syncId","description":"The ID of this clock sync marker","type":"string"}]},{"name":"requestMemoryDump","description":"Request a global memory dump.","returns":[{"name":"dumpGuid","description":"GUID of the resulting global memory dump.","type":"string"},{"name":"success","description":"True iff the global memory dump succeeded.","type":"boolean"}]},{"name":"start","description":"Start trace events collection.","parameters":[{"name":"categories","description":"Category/tag filter","deprecated":true,"optional":true,"type":"string"},{"name":"options","description":"Tracing options","deprecated":true,"optional":true,"type":"string"},{"name":"bufferUsageReportingInterval","description":"If set, the agent will issue bufferUsage events at this interval, specified in milliseconds","optional":true,"type":"number"},{"name":"transferMode","description":"Whether to report trace events as series of dataCollected events or to save trace to a\\nstream (defaults to `ReportEvents`).","optional":true,"type":"string","enum":["ReportEvents","ReturnAsStream"]},{"name":"streamFormat","description":"Trace data format to use. This only applies when using `ReturnAsStream`\\ntransfer mode (defaults to `json`).","optional":true,"$ref":"StreamFormat"},{"name":"streamCompression","description":"Compression format to use. This only applies when using `ReturnAsStream`\\ntransfer mode (defaults to `none`)","optional":true,"$ref":"StreamCompression"},{"name":"traceConfig","optional":true,"$ref":"TraceConfig"}]}],"events":[{"name":"bufferUsage","parameters":[{"name":"percentFull","description":"A number in range [0..1] that indicates the used size of event buffer as a fraction of its\\ntotal size.","optional":true,"type":"number"},{"name":"eventCount","description":"An approximate number of events in the trace log.","optional":true,"type":"number"},{"name":"value","description":"A number in range [0..1] that indicates the used size of event buffer as a fraction of its\\ntotal size.","optional":true,"type":"number"}]},{"name":"dataCollected","description":"Contains an bucket of collected trace events. When tracing is stopped collected events will be\\nsend as a sequence of dataCollected events followed by tracingComplete event.","parameters":[{"name":"value","type":"array","items":{"type":"object"}}]},{"name":"tracingComplete","description":"Signals that tracing is stopped and there is no trace buffers pending flush, all data were\\ndelivered via dataCollected events.","parameters":[{"name":"dataLossOccurred","description":"Indicates whether some trace data is known to have been lost, e.g. because the trace ring\\nbuffer wrapped around.","type":"boolean"},{"name":"stream","description":"A handle of the stream that holds resulting trace data.","optional":true,"$ref":"IO.StreamHandle"},{"name":"traceFormat","description":"Trace data format of returned stream.","optional":true,"$ref":"StreamFormat"},{"name":"streamCompression","description":"Compression format of returned stream.","optional":true,"$ref":"StreamCompression"}]}]},{"domain":"Fetch","description":"A domain for letting clients substitute browser\'s network layer with client code.","experimental":true,"dependencies":["Network","IO","Page"],"types":[{"id":"RequestId","description":"Unique request identifier.","type":"string"},{"id":"RequestStage","description":"Stages of the request to handle. Request will intercept before the request is\\nsent. Response will intercept after the response is received (but before response\\nbody is received.","experimental":true,"type":"string","enum":["Request","Response"]},{"id":"RequestPattern","experimental":true,"type":"object","properties":[{"name":"urlPattern","description":"Wildcards (\'*\' -> zero or more, \'?\' -> exactly one) are allowed. Escape character is\\nbackslash. Omitting is equivalent to \\"*\\".","optional":true,"type":"string"},{"name":"resourceType","description":"If set, only requests for matching resource types will be intercepted.","optional":true,"$ref":"Network.ResourceType"},{"name":"requestStage","description":"Stage at wich to begin intercepting requests. Default is Request.","optional":true,"$ref":"RequestStage"}]},{"id":"HeaderEntry","description":"Response HTTP header entry","type":"object","properties":[{"name":"name","type":"string"},{"name":"value","type":"string"}]},{"id":"AuthChallenge","description":"Authorization challenge for HTTP status code 401 or 407.","experimental":true,"type":"object","properties":[{"name":"source","description":"Source of the authentication challenge.","optional":true,"type":"string","enum":["Server","Proxy"]},{"name":"origin","description":"Origin of the challenger.","type":"string"},{"name":"scheme","description":"The authentication scheme used, such as basic or digest","type":"string"},{"name":"realm","description":"The realm of the challenge. May be empty.","type":"string"}]},{"id":"AuthChallengeResponse","description":"Response to an AuthChallenge.","experimental":true,"type":"object","properties":[{"name":"response","description":"The decision on what to do in response to the authorization challenge. Default means\\ndeferring to the default behavior of the net stack, which will likely either the Cancel\\nauthentication or display a popup dialog box.","type":"string","enum":["Default","CancelAuth","ProvideCredentials"]},{"name":"username","description":"The username to provide, possibly empty. Should only be set if response is\\nProvideCredentials.","optional":true,"type":"string"},{"name":"password","description":"The password to provide, possibly empty. Should only be set if response is\\nProvideCredentials.","optional":true,"type":"string"}]}],"commands":[{"name":"disable","description":"Disables the fetch domain."},{"name":"enable","description":"Enables issuing of requestPaused events. A request will be paused until client\\ncalls one of failRequest, fulfillRequest or continueRequest/continueWithAuth.","parameters":[{"name":"patterns","description":"If specified, only requests matching any of these patterns will produce\\nfetchRequested event and will be paused until clients response. If not set,\\nall requests will be affected.","optional":true,"type":"array","items":{"$ref":"RequestPattern"}},{"name":"handleAuthRequests","description":"If true, authRequired events will be issued and requests will be paused\\nexpecting a call to continueWithAuth.","optional":true,"type":"boolean"}]},{"name":"failRequest","description":"Causes the request to fail with specified reason.","parameters":[{"name":"requestId","description":"An id the client received in requestPaused event.","$ref":"RequestId"},{"name":"errorReason","description":"Causes the request to fail with the given reason.","$ref":"Network.ErrorReason"}]},{"name":"fulfillRequest","description":"Provides response to the request.","parameters":[{"name":"requestId","description":"An id the client received in requestPaused event.","$ref":"RequestId"},{"name":"responseCode","description":"An HTTP response code.","type":"integer"},{"name":"responseHeaders","description":"Response headers.","type":"array","items":{"$ref":"HeaderEntry"}},{"name":"body","description":"A response body.","optional":true,"type":"string"},{"name":"responsePhrase","description":"A textual representation of responseCode.\\nIf absent, a standard phrase mathcing responseCode is used.","optional":true,"type":"string"}]},{"name":"continueRequest","description":"Continues the request, optionally modifying some of its parameters.","parameters":[{"name":"requestId","description":"An id the client received in requestPaused event.","$ref":"RequestId"},{"name":"url","description":"If set, the request url will be modified in a way that\'s not observable by page.","optional":true,"type":"string"},{"name":"method","description":"If set, the request method is overridden.","optional":true,"type":"string"},{"name":"postData","description":"If set, overrides the post data in the request.","optional":true,"type":"string"},{"name":"headers","description":"If set, overrides the request headrts.","optional":true,"type":"array","items":{"$ref":"HeaderEntry"}}]},{"name":"continueWithAuth","description":"Continues a request supplying authChallengeResponse following authRequired event.","parameters":[{"name":"requestId","description":"An id the client received in authRequired event.","$ref":"RequestId"},{"name":"authChallengeResponse","description":"Response to with an authChallenge.","$ref":"AuthChallengeResponse"}]},{"name":"getResponseBody","description":"Causes the body of the response to be received from the server and\\nreturned as a single string. May only be issued for a request that\\nis paused in the Response stage and is mutually exclusive with\\ntakeResponseBodyForInterceptionAsStream. Calling other methods that\\naffect the request or disabling fetch domain before body is received\\nresults in an undefined behavior.","parameters":[{"name":"requestId","description":"Identifier for the intercepted request to get body for.","$ref":"RequestId"}],"returns":[{"name":"body","description":"Response body.","type":"string"},{"name":"base64Encoded","description":"True, if content was sent as base64.","type":"boolean"}]},{"name":"takeResponseBodyAsStream","description":"Returns a handle to the stream representing the response body.\\nThe request must be paused in the HeadersReceived stage.\\nNote that after this command the request can\'t be continued\\nas is -- client either needs to cancel it or to provide the\\nresponse body.\\nThe stream only supports sequential read, IO.read will fail if the position\\nis specified.\\nThis method is mutually exclusive with getResponseBody.\\nCalling other methods that affect the request or disabling fetch\\ndomain before body is received results in an undefined behavior.","parameters":[{"name":"requestId","$ref":"RequestId"}],"returns":[{"name":"stream","$ref":"IO.StreamHandle"}]}],"events":[{"name":"requestPaused","description":"Issued when the domain is enabled and the request URL matches the\\nspecified filter. The request is paused until the client responds\\nwith one of continueRequest, failRequest or fulfillRequest.\\nThe stage of the request can be determined by presence of responseErrorReason\\nand responseStatusCode -- the request is at the response stage if either\\nof these fields is present and in the request stage otherwise.","parameters":[{"name":"requestId","description":"Each request the page makes will have a unique id.","$ref":"RequestId"},{"name":"request","description":"The details of the request.","$ref":"Network.Request"},{"name":"frameId","description":"The id of the frame that initiated the request.","$ref":"Page.FrameId"},{"name":"resourceType","description":"How the requested resource will be used.","$ref":"Network.ResourceType"},{"name":"responseErrorReason","description":"Response error if intercepted at response stage.","optional":true,"$ref":"Network.ErrorReason"},{"name":"responseStatusCode","description":"Response code if intercepted at response stage.","optional":true,"type":"integer"},{"name":"responseHeaders","description":"Response headers if intercepted at the response stage.","optional":true,"type":"array","items":{"$ref":"HeaderEntry"}},{"name":"networkId","description":"If the intercepted request had a corresponding Network.requestWillBeSent event fired for it,\\nthen this networkId will be the same as the requestId present in the requestWillBeSent event.","optional":true,"$ref":"RequestId"}]},{"name":"authRequired","description":"Issued when the domain is enabled with handleAuthRequests set to true.\\nThe request is paused until client responds with continueWithAuth.","parameters":[{"name":"requestId","description":"Each request the page makes will have a unique id.","$ref":"RequestId"},{"name":"request","description":"The details of the request.","$ref":"Network.Request"},{"name":"frameId","description":"The id of the frame that initiated the request.","$ref":"Page.FrameId"},{"name":"resourceType","description":"How the requested resource will be used.","$ref":"Network.ResourceType"},{"name":"authChallenge","description":"Details of the Authorization Challenge encountered.\\nIf this is set, client should respond with continueRequest that\\ncontains AuthChallengeResponse.","$ref":"AuthChallenge"}]}]},{"domain":"WebAudio","description":"This domain allows inspection of Web Audio API.\\nhttps://webaudio.github.io/web-audio-api/","experimental":true,"types":[{"id":"ContextId","description":"Context\'s UUID in string","type":"string"},{"id":"ContextType","description":"Enum of BaseAudioContext types","type":"string","enum":["realtime","offline"]},{"id":"ContextState","description":"Enum of AudioContextState from the spec","type":"string","enum":["suspended","running","closed"]},{"id":"ContextRealtimeData","description":"Fields in AudioContext that change in real-time.","type":"object","properties":[{"name":"currentTime","description":"The current context time in second in BaseAudioContext.","type":"number"},{"name":"renderCapacity","description":"The time spent on rendering graph divided by render qunatum duration,\\nand multiplied by 100. 100 means the audio renderer reached the full\\ncapacity and glitch may occur.","type":"number"},{"name":"callbackIntervalMean","description":"A running mean of callback interval.","type":"number"},{"name":"callbackIntervalVariance","description":"A running variance of callback interval.","type":"number"}]},{"id":"BaseAudioContext","description":"Protocol object for BaseAudioContext","type":"object","properties":[{"name":"contextId","$ref":"ContextId"},{"name":"contextType","$ref":"ContextType"},{"name":"contextState","$ref":"ContextState"},{"name":"realtimeData","optional":true,"$ref":"ContextRealtimeData"},{"name":"callbackBufferSize","description":"Platform-dependent callback buffer size.","type":"number"},{"name":"maxOutputChannelCount","description":"Number of output channels supported by audio hardware in use.","type":"number"},{"name":"sampleRate","description":"Context sample rate.","type":"number"}]}],"commands":[{"name":"enable","description":"Enables the WebAudio domain and starts sending context lifetime events."},{"name":"disable","description":"Disables the WebAudio domain."},{"name":"getRealtimeData","description":"Fetch the realtime data from the registered contexts.","parameters":[{"name":"contextId","$ref":"ContextId"}],"returns":[{"name":"realtimeData","$ref":"ContextRealtimeData"}]}],"events":[{"name":"contextCreated","description":"Notifies that a new BaseAudioContext has been created.","parameters":[{"name":"context","$ref":"BaseAudioContext"}]},{"name":"contextDestroyed","description":"Notifies that existing BaseAudioContext has been destroyed.","parameters":[{"name":"contextId","$ref":"ContextId"}]},{"name":"contextChanged","description":"Notifies that existing BaseAudioContext has changed some properties (id stays the same)..","parameters":[{"name":"context","$ref":"BaseAudioContext"}]}]},{"domain":"WebAuthn","description":"This domain allows configuring virtual authenticators to test the WebAuthn\\nAPI.","experimental":true,"types":[{"id":"AuthenticatorId","type":"string"},{"id":"AuthenticatorProtocol","type":"string","enum":["u2f","ctap2"]},{"id":"AuthenticatorTransport","type":"string","enum":["usb","nfc","ble","cable","internal"]},{"id":"VirtualAuthenticatorOptions","type":"object","properties":[{"name":"protocol","$ref":"AuthenticatorProtocol"},{"name":"transport","$ref":"AuthenticatorTransport"},{"name":"hasResidentKey","type":"boolean"},{"name":"hasUserVerification","type":"boolean"},{"name":"automaticPresenceSimulation","description":"If set to true, tests of user presence will succeed immediately.\\nOtherwise, they will not be resolved. Defaults to true.","optional":true,"type":"boolean"}]},{"id":"Credential","type":"object","properties":[{"name":"credentialId","type":"string"},{"name":"rpIdHash","description":"SHA-256 hash of the Relying Party ID the credential is scoped to. Must\\nbe 32 bytes long.\\nSee https://w3c.github.io/webauthn/#rpidhash","type":"string"},{"name":"privateKey","description":"The private key in PKCS#8 format.","type":"string"},{"name":"signCount","description":"Signature counter. This is incremented by one for each successful\\nassertion.\\nSee https://w3c.github.io/webauthn/#signature-counter","type":"integer"}]}],"commands":[{"name":"enable","description":"Enable the WebAuthn domain and start intercepting credential storage and\\nretrieval with a virtual authenticator."},{"name":"disable","description":"Disable the WebAuthn domain."},{"name":"addVirtualAuthenticator","description":"Creates and adds a virtual authenticator.","parameters":[{"name":"options","$ref":"VirtualAuthenticatorOptions"}],"returns":[{"name":"authenticatorId","$ref":"AuthenticatorId"}]},{"name":"removeVirtualAuthenticator","description":"Removes the given authenticator.","parameters":[{"name":"authenticatorId","$ref":"AuthenticatorId"}]},{"name":"addCredential","description":"Adds the credential to the specified authenticator.","parameters":[{"name":"authenticatorId","$ref":"AuthenticatorId"},{"name":"credential","$ref":"Credential"}]},{"name":"getCredentials","description":"Returns all the credentials stored in the given virtual authenticator.","parameters":[{"name":"authenticatorId","$ref":"AuthenticatorId"}],"returns":[{"name":"credentials","type":"array","items":{"$ref":"Credential"}}]},{"name":"clearCredentials","description":"Clears all the credentials from the specified device.","parameters":[{"name":"authenticatorId","$ref":"AuthenticatorId"}]},{"name":"setUserVerified","description":"Sets whether User Verification succeeds or fails for an authenticator.\\nThe default is true.","parameters":[{"name":"authenticatorId","$ref":"AuthenticatorId"},{"name":"isUserVerified","type":"boolean"}]}]},{"domain":"Console","description":"This domain is deprecated - use Runtime or Log instead.","deprecated":true,"dependencies":["Runtime"],"types":[{"id":"ConsoleMessage","description":"Console message.","type":"object","properties":[{"name":"source","description":"Message source.","type":"string","enum":["xml","javascript","network","console-api","storage","appcache","rendering","security","other","deprecation","worker"]},{"name":"level","description":"Message severity.","type":"string","enum":["log","warning","error","debug","info"]},{"name":"text","description":"Message text.","type":"string"},{"name":"url","description":"URL of the message origin.","optional":true,"type":"string"},{"name":"line","description":"Line number in the resource that generated this message (1-based).","optional":true,"type":"integer"},{"name":"column","description":"Column number in the resource that generated this message (1-based).","optional":true,"type":"integer"}]}],"commands":[{"name":"clearMessages","description":"Does nothing."},{"name":"disable","description":"Disables console domain, prevents further console messages from being reported to the client."},{"name":"enable","description":"Enables console domain, sends the messages collected so far to the client by means of the\\n`messageAdded` notification."}],"events":[{"name":"messageAdded","description":"Issued when new console message is added.","parameters":[{"name":"message","description":"Console message that has been added.","$ref":"ConsoleMessage"}]}]},{"domain":"Debugger","description":"Debugger domain exposes JavaScript debugging capabilities. It allows setting and removing\\nbreakpoints, stepping through execution, exploring stack traces, etc.","dependencies":["Runtime"],"types":[{"id":"BreakpointId","description":"Breakpoint identifier.","type":"string"},{"id":"CallFrameId","description":"Call frame identifier.","type":"string"},{"id":"Location","description":"Location in the source code.","type":"object","properties":[{"name":"scriptId","description":"Script identifier as reported in the `Debugger.scriptParsed`.","$ref":"Runtime.ScriptId"},{"name":"lineNumber","description":"Line number in the script (0-based).","type":"integer"},{"name":"columnNumber","description":"Column number in the script (0-based).","optional":true,"type":"integer"}]},{"id":"ScriptPosition","description":"Location in the source code.","experimental":true,"type":"object","properties":[{"name":"lineNumber","type":"integer"},{"name":"columnNumber","type":"integer"}]},{"id":"CallFrame","description":"JavaScript call frame. Array of call frames form the call stack.","type":"object","properties":[{"name":"callFrameId","description":"Call frame identifier. This identifier is only valid while the virtual machine is paused.","$ref":"CallFrameId"},{"name":"functionName","description":"Name of the JavaScript function called on this call frame.","type":"string"},{"name":"functionLocation","description":"Location in the source code.","optional":true,"$ref":"Location"},{"name":"location","description":"Location in the source code.","$ref":"Location"},{"name":"url","description":"JavaScript script name or url.","type":"string"},{"name":"scopeChain","description":"Scope chain for this call frame.","type":"array","items":{"$ref":"Scope"}},{"name":"this","description":"`this` object for this call frame.","$ref":"Runtime.RemoteObject"},{"name":"returnValue","description":"The value being returned, if the function is at return point.","optional":true,"$ref":"Runtime.RemoteObject"}]},{"id":"Scope","description":"Scope description.","type":"object","properties":[{"name":"type","description":"Scope type.","type":"string","enum":["global","local","with","closure","catch","block","script","eval","module"]},{"name":"object","description":"Object representing the scope. For `global` and `with` scopes it represents the actual\\nobject; for the rest of the scopes, it is artificial transient object enumerating scope\\nvariables as its properties.","$ref":"Runtime.RemoteObject"},{"name":"name","optional":true,"type":"string"},{"name":"startLocation","description":"Location in the source code where scope starts","optional":true,"$ref":"Location"},{"name":"endLocation","description":"Location in the source code where scope ends","optional":true,"$ref":"Location"}]},{"id":"SearchMatch","description":"Search match for resource.","type":"object","properties":[{"name":"lineNumber","description":"Line number in resource content.","type":"number"},{"name":"lineContent","description":"Line with match content.","type":"string"}]},{"id":"BreakLocation","type":"object","properties":[{"name":"scriptId","description":"Script identifier as reported in the `Debugger.scriptParsed`.","$ref":"Runtime.ScriptId"},{"name":"lineNumber","description":"Line number in the script (0-based).","type":"integer"},{"name":"columnNumber","description":"Column number in the script (0-based).","optional":true,"type":"integer"},{"name":"type","optional":true,"type":"string","enum":["debuggerStatement","call","return"]}]}],"commands":[{"name":"continueToLocation","description":"Continues execution until specific location is reached.","parameters":[{"name":"location","description":"Location to continue to.","$ref":"Location"},{"name":"targetCallFrames","optional":true,"type":"string","enum":["any","current"]}]},{"name":"disable","description":"Disables debugger for given page."},{"name":"enable","description":"Enables debugger for the given page. Clients should not assume that the debugging has been\\nenabled until the result for this command is received.","parameters":[{"name":"maxScriptsCacheSize","description":"The maximum size in bytes of collected scripts (not referenced by other heap objects)\\nthe debugger can hold. Puts no limit if paramter is omitted.","experimental":true,"optional":true,"type":"number"}],"returns":[{"name":"debuggerId","description":"Unique identifier of the debugger.","experimental":true,"$ref":"Runtime.UniqueDebuggerId"}]},{"name":"evaluateOnCallFrame","description":"Evaluates expression on a given call frame.","parameters":[{"name":"callFrameId","description":"Call frame identifier to evaluate on.","$ref":"CallFrameId"},{"name":"expression","description":"Expression to evaluate.","type":"string"},{"name":"objectGroup","description":"String object group name to put result into (allows rapid releasing resulting object handles\\nusing `releaseObjectGroup`).","optional":true,"type":"string"},{"name":"includeCommandLineAPI","description":"Specifies whether command line API should be available to the evaluated expression, defaults\\nto false.","optional":true,"type":"boolean"},{"name":"silent","description":"In silent mode exceptions thrown during evaluation are not reported and do not pause\\nexecution. Overrides `setPauseOnException` state.","optional":true,"type":"boolean"},{"name":"returnByValue","description":"Whether the result is expected to be a JSON object that should be sent by value.","optional":true,"type":"boolean"},{"name":"generatePreview","description":"Whether preview should be generated for the result.","experimental":true,"optional":true,"type":"boolean"},{"name":"throwOnSideEffect","description":"Whether to throw an exception if side effect cannot be ruled out during evaluation.","optional":true,"type":"boolean"},{"name":"timeout","description":"Terminate execution after timing out (number of milliseconds).","experimental":true,"optional":true,"$ref":"Runtime.TimeDelta"}],"returns":[{"name":"result","description":"Object wrapper for the evaluation result.","$ref":"Runtime.RemoteObject"},{"name":"exceptionDetails","description":"Exception details.","optional":true,"$ref":"Runtime.ExceptionDetails"}]},{"name":"getPossibleBreakpoints","description":"Returns possible locations for breakpoint. scriptId in start and end range locations should be\\nthe same.","parameters":[{"name":"start","description":"Start of range to search possible breakpoint locations in.","$ref":"Location"},{"name":"end","description":"End of range to search possible breakpoint locations in (excluding). When not specified, end\\nof scripts is used as end of range.","optional":true,"$ref":"Location"},{"name":"restrictToFunction","description":"Only consider locations which are in the same (non-nested) function as start.","optional":true,"type":"boolean"}],"returns":[{"name":"locations","description":"List of the possible breakpoint locations.","type":"array","items":{"$ref":"BreakLocation"}}]},{"name":"getScriptSource","description":"Returns source for the script with given id.","parameters":[{"name":"scriptId","description":"Id of the script to get source for.","$ref":"Runtime.ScriptId"}],"returns":[{"name":"scriptSource","description":"Script source.","type":"string"}]},{"name":"getStackTrace","description":"Returns stack trace with given `stackTraceId`.","experimental":true,"parameters":[{"name":"stackTraceId","$ref":"Runtime.StackTraceId"}],"returns":[{"name":"stackTrace","$ref":"Runtime.StackTrace"}]},{"name":"pause","description":"Stops on the next JavaScript statement."},{"name":"pauseOnAsyncCall","experimental":true,"parameters":[{"name":"parentStackTraceId","description":"Debugger will pause when async call with given stack trace is started.","$ref":"Runtime.StackTraceId"}]},{"name":"removeBreakpoint","description":"Removes JavaScript breakpoint.","parameters":[{"name":"breakpointId","$ref":"BreakpointId"}]},{"name":"restartFrame","description":"Restarts particular call frame from the beginning.","parameters":[{"name":"callFrameId","description":"Call frame identifier to evaluate on.","$ref":"CallFrameId"}],"returns":[{"name":"callFrames","description":"New stack trace.","type":"array","items":{"$ref":"CallFrame"}},{"name":"asyncStackTrace","description":"Async stack trace, if any.","optional":true,"$ref":"Runtime.StackTrace"},{"name":"asyncStackTraceId","description":"Async stack trace, if any.","experimental":true,"optional":true,"$ref":"Runtime.StackTraceId"}]},{"name":"resume","description":"Resumes JavaScript execution."},{"name":"searchInContent","description":"Searches for given string in script content.","parameters":[{"name":"scriptId","description":"Id of the script to search in.","$ref":"Runtime.ScriptId"},{"name":"query","description":"String to search for.","type":"string"},{"name":"caseSensitive","description":"If true, search is case sensitive.","optional":true,"type":"boolean"},{"name":"isRegex","description":"If true, treats string parameter as regex.","optional":true,"type":"boolean"}],"returns":[{"name":"result","description":"List of search matches.","type":"array","items":{"$ref":"SearchMatch"}}]},{"name":"setAsyncCallStackDepth","description":"Enables or disables async call stacks tracking.","parameters":[{"name":"maxDepth","description":"Maximum depth of async call stacks. Setting to `0` will effectively disable collecting async\\ncall stacks (default).","type":"integer"}]},{"name":"setBlackboxPatterns","description":"Replace previous blackbox patterns with passed ones. Forces backend to skip stepping/pausing in\\nscripts with url matching one of the patterns. VM will try to leave blackboxed script by\\nperforming \'step in\' several times, finally resorting to \'step out\' if unsuccessful.","experimental":true,"parameters":[{"name":"patterns","description":"Array of regexps that will be used to check script url for blackbox state.","type":"array","items":{"type":"string"}}]},{"name":"setBlackboxedRanges","description":"Makes backend skip steps in the script in blackboxed ranges. VM will try leave blacklisted\\nscripts by performing \'step in\' several times, finally resorting to \'step out\' if unsuccessful.\\nPositions array contains positions where blackbox state is changed. First interval isn\'t\\nblackboxed. Array should be sorted.","experimental":true,"parameters":[{"name":"scriptId","description":"Id of the script.","$ref":"Runtime.ScriptId"},{"name":"positions","type":"array","items":{"$ref":"ScriptPosition"}}]},{"name":"setBreakpoint","description":"Sets JavaScript breakpoint at a given location.","parameters":[{"name":"location","description":"Location to set breakpoint in.","$ref":"Location"},{"name":"condition","description":"Expression to use as a breakpoint condition. When specified, debugger will only stop on the\\nbreakpoint if this expression evaluates to true.","optional":true,"type":"string"}],"returns":[{"name":"breakpointId","description":"Id of the created breakpoint for further reference.","$ref":"BreakpointId"},{"name":"actualLocation","description":"Location this breakpoint resolved into.","$ref":"Location"}]},{"name":"setInstrumentationBreakpoint","description":"Sets instrumentation breakpoint.","parameters":[{"name":"instrumentation","description":"Instrumentation name.","type":"string","enum":["beforeScriptExecution","beforeScriptWithSourceMapExecution"]}],"returns":[{"name":"breakpointId","description":"Id of the created breakpoint for further reference.","$ref":"BreakpointId"}]},{"name":"setBreakpointByUrl","description":"Sets JavaScript breakpoint at given location specified either by URL or URL regex. Once this\\ncommand is issued, all existing parsed scripts will have breakpoints resolved and returned in\\n`locations` property. Further matching script parsing will result in subsequent\\n`breakpointResolved` events issued. This logical breakpoint will survive page reloads.","parameters":[{"name":"lineNumber","description":"Line number to set breakpoint at.","type":"integer"},{"name":"url","description":"URL of the resources to set breakpoint on.","optional":true,"type":"string"},{"name":"urlRegex","description":"Regex pattern for the URLs of the resources to set breakpoints on. Either `url` or\\n`urlRegex` must be specified.","optional":true,"type":"string"},{"name":"scriptHash","description":"Script hash of the resources to set breakpoint on.","optional":true,"type":"string"},{"name":"columnNumber","description":"Offset in the line to set breakpoint at.","optional":true,"type":"integer"},{"name":"condition","description":"Expression to use as a breakpoint condition. When specified, debugger will only stop on the\\nbreakpoint if this expression evaluates to true.","optional":true,"type":"string"}],"returns":[{"name":"breakpointId","description":"Id of the created breakpoint for further reference.","$ref":"BreakpointId"},{"name":"locations","description":"List of the locations this breakpoint resolved into upon addition.","type":"array","items":{"$ref":"Location"}}]},{"name":"setBreakpointOnFunctionCall","description":"Sets JavaScript breakpoint before each call to the given function.\\nIf another function was created from the same source as a given one,\\ncalling it will also trigger the breakpoint.","experimental":true,"parameters":[{"name":"objectId","description":"Function object id.","$ref":"Runtime.RemoteObjectId"},{"name":"condition","description":"Expression to use as a breakpoint condition. When specified, debugger will\\nstop on the breakpoint if this expression evaluates to true.","optional":true,"type":"string"}],"returns":[{"name":"breakpointId","description":"Id of the created breakpoint for further reference.","$ref":"BreakpointId"}]},{"name":"setBreakpointsActive","description":"Activates / deactivates all breakpoints on the page.","parameters":[{"name":"active","description":"New value for breakpoints active state.","type":"boolean"}]},{"name":"setPauseOnExceptions","description":"Defines pause on exceptions state. Can be set to stop on all exceptions, uncaught exceptions or\\nno exceptions. Initial pause on exceptions state is `none`.","parameters":[{"name":"state","description":"Pause on exceptions mode.","type":"string","enum":["none","uncaught","all"]}]},{"name":"setReturnValue","description":"Changes return value in top frame. Available only at return break position.","experimental":true,"parameters":[{"name":"newValue","description":"New return value.","$ref":"Runtime.CallArgument"}]},{"name":"setScriptSource","description":"Edits JavaScript source live.","parameters":[{"name":"scriptId","description":"Id of the script to edit.","$ref":"Runtime.ScriptId"},{"name":"scriptSource","description":"New content of the script.","type":"string"},{"name":"dryRun","description":"If true the change will not actually be applied. Dry run may be used to get result\\ndescription without actually modifying the code.","optional":true,"type":"boolean"}],"returns":[{"name":"callFrames","description":"New stack trace in case editing has happened while VM was stopped.","optional":true,"type":"array","items":{"$ref":"CallFrame"}},{"name":"stackChanged","description":"Whether current call stack was modified after applying the changes.","optional":true,"type":"boolean"},{"name":"asyncStackTrace","description":"Async stack trace, if any.","optional":true,"$ref":"Runtime.StackTrace"},{"name":"asyncStackTraceId","description":"Async stack trace, if any.","experimental":true,"optional":true,"$ref":"Runtime.StackTraceId"},{"name":"exceptionDetails","description":"Exception details if any.","optional":true,"$ref":"Runtime.ExceptionDetails"}]},{"name":"setSkipAllPauses","description":"Makes page not interrupt on any pauses (breakpoint, exception, dom exception etc).","parameters":[{"name":"skip","description":"New value for skip pauses state.","type":"boolean"}]},{"name":"setVariableValue","description":"Changes value of variable in a callframe. Object-based scopes are not supported and must be\\nmutated manually.","parameters":[{"name":"scopeNumber","description":"0-based number of scope as was listed in scope chain. Only \'local\', \'closure\' and \'catch\'\\nscope types are allowed. Other scopes could be manipulated manually.","type":"integer"},{"name":"variableName","description":"Variable name.","type":"string"},{"name":"newValue","description":"New variable value.","$ref":"Runtime.CallArgument"},{"name":"callFrameId","description":"Id of callframe that holds variable.","$ref":"CallFrameId"}]},{"name":"stepInto","description":"Steps into the function call.","parameters":[{"name":"breakOnAsyncCall","description":"Debugger will issue additional Debugger.paused notification if any async task is scheduled\\nbefore next pause.","experimental":true,"optional":true,"type":"boolean"}]},{"name":"stepOut","description":"Steps out of the function call."},{"name":"stepOver","description":"Steps over the statement."}],"events":[{"name":"breakpointResolved","description":"Fired when breakpoint is resolved to an actual script and location.","parameters":[{"name":"breakpointId","description":"Breakpoint unique identifier.","$ref":"BreakpointId"},{"name":"location","description":"Actual breakpoint location.","$ref":"Location"}]},{"name":"paused","description":"Fired when the virtual machine stopped on breakpoint or exception or any other stop criteria.","parameters":[{"name":"callFrames","description":"Call stack the virtual machine stopped on.","type":"array","items":{"$ref":"CallFrame"}},{"name":"reason","description":"Pause reason.","type":"string","enum":["ambiguous","assert","debugCommand","DOM","EventListener","exception","instrumentation","OOM","other","promiseRejection","XHR"]},{"name":"data","description":"Object containing break-specific auxiliary properties.","optional":true,"type":"object"},{"name":"hitBreakpoints","description":"Hit breakpoints IDs","optional":true,"type":"array","items":{"type":"string"}},{"name":"asyncStackTrace","description":"Async stack trace, if any.","optional":true,"$ref":"Runtime.StackTrace"},{"name":"asyncStackTraceId","description":"Async stack trace, if any.","experimental":true,"optional":true,"$ref":"Runtime.StackTraceId"},{"name":"asyncCallStackTraceId","description":"Just scheduled async call will have this stack trace as parent stack during async execution.\\nThis field is available only after `Debugger.stepInto` call with `breakOnAsynCall` flag.","experimental":true,"optional":true,"$ref":"Runtime.StackTraceId"}]},{"name":"resumed","description":"Fired when the virtual machine resumed execution."},{"name":"scriptFailedToParse","description":"Fired when virtual machine fails to parse the script.","parameters":[{"name":"scriptId","description":"Identifier of the script parsed.","$ref":"Runtime.ScriptId"},{"name":"url","description":"URL or name of the script parsed (if any).","type":"string"},{"name":"startLine","description":"Line offset of the script within the resource with given URL (for script tags).","type":"integer"},{"name":"startColumn","description":"Column offset of the script within the resource with given URL.","type":"integer"},{"name":"endLine","description":"Last line of the script.","type":"integer"},{"name":"endColumn","description":"Length of the last line of the script.","type":"integer"},{"name":"executionContextId","description":"Specifies script creation context.","$ref":"Runtime.ExecutionContextId"},{"name":"hash","description":"Content hash of the script.","type":"string"},{"name":"executionContextAuxData","description":"Embedder-specific auxiliary data.","optional":true,"type":"object"},{"name":"sourceMapURL","description":"URL of source map associated with script (if any).","optional":true,"type":"string"},{"name":"hasSourceURL","description":"True, if this script has sourceURL.","optional":true,"type":"boolean"},{"name":"isModule","description":"True, if this script is ES6 module.","optional":true,"type":"boolean"},{"name":"length","description":"This script length.","optional":true,"type":"integer"},{"name":"stackTrace","description":"JavaScript top stack frame of where the script parsed event was triggered if available.","experimental":true,"optional":true,"$ref":"Runtime.StackTrace"}]},{"name":"scriptParsed","description":"Fired when virtual machine parses script. This event is also fired for all known and uncollected\\nscripts upon enabling debugger.","parameters":[{"name":"scriptId","description":"Identifier of the script parsed.","$ref":"Runtime.ScriptId"},{"name":"url","description":"URL or name of the script parsed (if any).","type":"string"},{"name":"startLine","description":"Line offset of the script within the resource with given URL (for script tags).","type":"integer"},{"name":"startColumn","description":"Column offset of the script within the resource with given URL.","type":"integer"},{"name":"endLine","description":"Last line of the script.","type":"integer"},{"name":"endColumn","description":"Length of the last line of the script.","type":"integer"},{"name":"executionContextId","description":"Specifies script creation context.","$ref":"Runtime.ExecutionContextId"},{"name":"hash","description":"Content hash of the script.","type":"string"},{"name":"executionContextAuxData","description":"Embedder-specific auxiliary data.","optional":true,"type":"object"},{"name":"isLiveEdit","description":"True, if this script is generated as a result of the live edit operation.","experimental":true,"optional":true,"type":"boolean"},{"name":"sourceMapURL","description":"URL of source map associated with script (if any).","optional":true,"type":"string"},{"name":"hasSourceURL","description":"True, if this script has sourceURL.","optional":true,"type":"boolean"},{"name":"isModule","description":"True, if this script is ES6 module.","optional":true,"type":"boolean"},{"name":"length","description":"This script length.","optional":true,"type":"integer"},{"name":"stackTrace","description":"JavaScript top stack frame of where the script parsed event was triggered if available.","experimental":true,"optional":true,"$ref":"Runtime.StackTrace"}]}]},{"domain":"HeapProfiler","experimental":true,"dependencies":["Runtime"],"types":[{"id":"HeapSnapshotObjectId","description":"Heap snapshot object id.","type":"string"},{"id":"SamplingHeapProfileNode","description":"Sampling Heap Profile node. Holds callsite information, allocation statistics and child nodes.","type":"object","properties":[{"name":"callFrame","description":"Function location.","$ref":"Runtime.CallFrame"},{"name":"selfSize","description":"Allocations size in bytes for the node excluding children.","type":"number"},{"name":"id","description":"Node id. Ids are unique across all profiles collected between startSampling and stopSampling.","type":"integer"},{"name":"children","description":"Child nodes.","type":"array","items":{"$ref":"SamplingHeapProfileNode"}}]},{"id":"SamplingHeapProfileSample","description":"A single sample from a sampling profile.","type":"object","properties":[{"name":"size","description":"Allocation size in bytes attributed to the sample.","type":"number"},{"name":"nodeId","description":"Id of the corresponding profile tree node.","type":"integer"},{"name":"ordinal","description":"Time-ordered sample ordinal number. It is unique across all profiles retrieved\\nbetween startSampling and stopSampling.","type":"number"}]},{"id":"SamplingHeapProfile","description":"Sampling profile.","type":"object","properties":[{"name":"head","$ref":"SamplingHeapProfileNode"},{"name":"samples","type":"array","items":{"$ref":"SamplingHeapProfileSample"}}]}],"commands":[{"name":"addInspectedHeapObject","description":"Enables console to refer to the node with given id via $x (see Command Line API for more details\\n$x functions).","parameters":[{"name":"heapObjectId","description":"Heap snapshot object id to be accessible by means of $x command line API.","$ref":"HeapSnapshotObjectId"}]},{"name":"collectGarbage"},{"name":"disable"},{"name":"enable"},{"name":"getHeapObjectId","parameters":[{"name":"objectId","description":"Identifier of the object to get heap object id for.","$ref":"Runtime.RemoteObjectId"}],"returns":[{"name":"heapSnapshotObjectId","description":"Id of the heap snapshot object corresponding to the passed remote object id.","$ref":"HeapSnapshotObjectId"}]},{"name":"getObjectByHeapObjectId","parameters":[{"name":"objectId","$ref":"HeapSnapshotObjectId"},{"name":"objectGroup","description":"Symbolic group name that can be used to release multiple objects.","optional":true,"type":"string"}],"returns":[{"name":"result","description":"Evaluation result.","$ref":"Runtime.RemoteObject"}]},{"name":"getSamplingProfile","returns":[{"name":"profile","description":"Return the sampling profile being collected.","$ref":"SamplingHeapProfile"}]},{"name":"startSampling","parameters":[{"name":"samplingInterval","description":"Average sample interval in bytes. Poisson distribution is used for the intervals. The\\ndefault value is 32768 bytes.","optional":true,"type":"number"}]},{"name":"startTrackingHeapObjects","parameters":[{"name":"trackAllocations","optional":true,"type":"boolean"}]},{"name":"stopSampling","returns":[{"name":"profile","description":"Recorded sampling heap profile.","$ref":"SamplingHeapProfile"}]},{"name":"stopTrackingHeapObjects","parameters":[{"name":"reportProgress","description":"If true \'reportHeapSnapshotProgress\' events will be generated while snapshot is being taken\\nwhen the tracking is stopped.","optional":true,"type":"boolean"}]},{"name":"takeHeapSnapshot","parameters":[{"name":"reportProgress","description":"If true \'reportHeapSnapshotProgress\' events will be generated while snapshot is being taken.","optional":true,"type":"boolean"}]}],"events":[{"name":"addHeapSnapshotChunk","parameters":[{"name":"chunk","type":"string"}]},{"name":"heapStatsUpdate","description":"If heap objects tracking has been started then backend may send update for one or more fragments","parameters":[{"name":"statsUpdate","description":"An array of triplets. Each triplet describes a fragment. The first integer is the fragment\\nindex, the second integer is a total count of objects for the fragment, the third integer is\\na total size of the objects for the fragment.","type":"array","items":{"type":"integer"}}]},{"name":"lastSeenObjectId","description":"If heap objects tracking has been started then backend regularly sends a current value for last\\nseen object id and corresponding timestamp. If the were changes in the heap since last event\\nthen one or more heapStatsUpdate events will be sent before a new lastSeenObjectId event.","parameters":[{"name":"lastSeenObjectId","type":"integer"},{"name":"timestamp","type":"number"}]},{"name":"reportHeapSnapshotProgress","parameters":[{"name":"done","type":"integer"},{"name":"total","type":"integer"},{"name":"finished","optional":true,"type":"boolean"}]},{"name":"resetProfiles"}]},{"domain":"Profiler","dependencies":["Runtime","Debugger"],"types":[{"id":"ProfileNode","description":"Profile node. Holds callsite information, execution statistics and child nodes.","type":"object","properties":[{"name":"id","description":"Unique id of the node.","type":"integer"},{"name":"callFrame","description":"Function location.","$ref":"Runtime.CallFrame"},{"name":"hitCount","description":"Number of samples where this node was on top of the call stack.","optional":true,"type":"integer"},{"name":"children","description":"Child node ids.","optional":true,"type":"array","items":{"type":"integer"}},{"name":"deoptReason","description":"The reason of being not optimized. The function may be deoptimized or marked as don\'t\\noptimize.","optional":true,"type":"string"},{"name":"positionTicks","description":"An array of source position ticks.","optional":true,"type":"array","items":{"$ref":"PositionTickInfo"}}]},{"id":"Profile","description":"Profile.","type":"object","properties":[{"name":"nodes","description":"The list of profile nodes. First item is the root node.","type":"array","items":{"$ref":"ProfileNode"}},{"name":"startTime","description":"Profiling start timestamp in microseconds.","type":"number"},{"name":"endTime","description":"Profiling end timestamp in microseconds.","type":"number"},{"name":"samples","description":"Ids of samples top nodes.","optional":true,"type":"array","items":{"type":"integer"}},{"name":"timeDeltas","description":"Time intervals between adjacent samples in microseconds. The first delta is relative to the\\nprofile startTime.","optional":true,"type":"array","items":{"type":"integer"}}]},{"id":"PositionTickInfo","description":"Specifies a number of samples attributed to a certain source position.","type":"object","properties":[{"name":"line","description":"Source line number (1-based).","type":"integer"},{"name":"ticks","description":"Number of samples attributed to the source line.","type":"integer"}]},{"id":"CoverageRange","description":"Coverage data for a source range.","type":"object","properties":[{"name":"startOffset","description":"JavaScript script source offset for the range start.","type":"integer"},{"name":"endOffset","description":"JavaScript script source offset for the range end.","type":"integer"},{"name":"count","description":"Collected execution count of the source range.","type":"integer"}]},{"id":"FunctionCoverage","description":"Coverage data for a JavaScript function.","type":"object","properties":[{"name":"functionName","description":"JavaScript function name.","type":"string"},{"name":"ranges","description":"Source ranges inside the function with coverage data.","type":"array","items":{"$ref":"CoverageRange"}},{"name":"isBlockCoverage","description":"Whether coverage data for this function has block granularity.","type":"boolean"}]},{"id":"ScriptCoverage","description":"Coverage data for a JavaScript script.","type":"object","properties":[{"name":"scriptId","description":"JavaScript script id.","$ref":"Runtime.ScriptId"},{"name":"url","description":"JavaScript script name or url.","type":"string"},{"name":"functions","description":"Functions contained in the script that has coverage data.","type":"array","items":{"$ref":"FunctionCoverage"}}]},{"id":"TypeObject","description":"Describes a type collected during runtime.","experimental":true,"type":"object","properties":[{"name":"name","description":"Name of a type collected with type profiling.","type":"string"}]},{"id":"TypeProfileEntry","description":"Source offset and types for a parameter or return value.","experimental":true,"type":"object","properties":[{"name":"offset","description":"Source offset of the parameter or end of function for return values.","type":"integer"},{"name":"types","description":"The types for this parameter or return value.","type":"array","items":{"$ref":"TypeObject"}}]},{"id":"ScriptTypeProfile","description":"Type profile data collected during runtime for a JavaScript script.","experimental":true,"type":"object","properties":[{"name":"scriptId","description":"JavaScript script id.","$ref":"Runtime.ScriptId"},{"name":"url","description":"JavaScript script name or url.","type":"string"},{"name":"entries","description":"Type profile entries for parameters and return values of the functions in the script.","type":"array","items":{"$ref":"TypeProfileEntry"}}]}],"commands":[{"name":"disable"},{"name":"enable"},{"name":"getBestEffortCoverage","description":"Collect coverage data for the current isolate. The coverage data may be incomplete due to\\ngarbage collection.","returns":[{"name":"result","description":"Coverage data for the current isolate.","type":"array","items":{"$ref":"ScriptCoverage"}}]},{"name":"setSamplingInterval","description":"Changes CPU profiler sampling interval. Must be called before CPU profiles recording started.","parameters":[{"name":"interval","description":"New sampling interval in microseconds.","type":"integer"}]},{"name":"start"},{"name":"startPreciseCoverage","description":"Enable precise code coverage. Coverage data for JavaScript executed before enabling precise code\\ncoverage may be incomplete. Enabling prevents running optimized code and resets execution\\ncounters.","parameters":[{"name":"callCount","description":"Collect accurate call counts beyond simple \'covered\' or \'not covered\'.","optional":true,"type":"boolean"},{"name":"detailed","description":"Collect block-based coverage.","optional":true,"type":"boolean"}]},{"name":"startTypeProfile","description":"Enable type profile.","experimental":true},{"name":"stop","returns":[{"name":"profile","description":"Recorded profile.","$ref":"Profile"}]},{"name":"stopPreciseCoverage","description":"Disable precise code coverage. Disabling releases unnecessary execution count records and allows\\nexecuting optimized code."},{"name":"stopTypeProfile","description":"Disable type profile. Disabling releases type profile data collected so far.","experimental":true},{"name":"takePreciseCoverage","description":"Collect coverage data for the current isolate, and resets execution counters. Precise code\\ncoverage needs to have started.","returns":[{"name":"result","description":"Coverage data for the current isolate.","type":"array","items":{"$ref":"ScriptCoverage"}}]},{"name":"takeTypeProfile","description":"Collect type profile.","experimental":true,"returns":[{"name":"result","description":"Type profile for all scripts since startTypeProfile() was turned on.","type":"array","items":{"$ref":"ScriptTypeProfile"}}]}],"events":[{"name":"consoleProfileFinished","parameters":[{"name":"id","type":"string"},{"name":"location","description":"Location of console.profileEnd().","$ref":"Debugger.Location"},{"name":"profile","$ref":"Profile"},{"name":"title","description":"Profile title passed as an argument to console.profile().","optional":true,"type":"string"}]},{"name":"consoleProfileStarted","description":"Sent when new profile recording is started using console.profile() call.","parameters":[{"name":"id","type":"string"},{"name":"location","description":"Location of console.profile().","$ref":"Debugger.Location"},{"name":"title","description":"Profile title passed as an argument to console.profile().","optional":true,"type":"string"}]}]},{"domain":"Runtime","description":"Runtime domain exposes JavaScript runtime by means of remote evaluation and mirror objects.\\nEvaluation results are returned as mirror object that expose object type, string representation\\nand unique identifier that can be used for further object reference. Original objects are\\nmaintained in memory unless they are either explicitly released or are released along with the\\nother objects in their object group.","types":[{"id":"ScriptId","description":"Unique script identifier.","type":"string"},{"id":"RemoteObjectId","description":"Unique object identifier.","type":"string"},{"id":"UnserializableValue","description":"Primitive value which cannot be JSON-stringified. Includes values `-0`, `NaN`, `Infinity`,\\n`-Infinity`, and bigint literals.","type":"string"},{"id":"RemoteObject","description":"Mirror object referencing original JavaScript object.","type":"object","properties":[{"name":"type","description":"Object type.","type":"string","enum":["object","function","undefined","string","number","boolean","symbol","bigint"]},{"name":"subtype","description":"Object subtype hint. Specified for `object` type values only.","optional":true,"type":"string","enum":["array","null","node","regexp","date","map","set","weakmap","weakset","iterator","generator","error","proxy","promise","typedarray","arraybuffer","dataview"]},{"name":"className","description":"Object class (constructor) name. Specified for `object` type values only.","optional":true,"type":"string"},{"name":"value","description":"Remote object value in case of primitive values or JSON values (if it was requested).","optional":true,"type":"any"},{"name":"unserializableValue","description":"Primitive value which can not be JSON-stringified does not have `value`, but gets this\\nproperty.","optional":true,"$ref":"UnserializableValue"},{"name":"description","description":"String representation of the object.","optional":true,"type":"string"},{"name":"objectId","description":"Unique object identifier (for non-primitive values).","optional":true,"$ref":"RemoteObjectId"},{"name":"preview","description":"Preview containing abbreviated property values. Specified for `object` type values only.","experimental":true,"optional":true,"$ref":"ObjectPreview"},{"name":"customPreview","experimental":true,"optional":true,"$ref":"CustomPreview"}]},{"id":"CustomPreview","experimental":true,"type":"object","properties":[{"name":"header","description":"The JSON-stringified result of formatter.header(object, config) call.\\nIt contains json ML array that represents RemoteObject.","type":"string"},{"name":"bodyGetterId","description":"If formatter returns true as a result of formatter.hasBody call then bodyGetterId will\\ncontain RemoteObjectId for the function that returns result of formatter.body(object, config) call.\\nThe result value is json ML array.","optional":true,"$ref":"RemoteObjectId"}]},{"id":"ObjectPreview","description":"Object containing abbreviated remote object value.","experimental":true,"type":"object","properties":[{"name":"type","description":"Object type.","type":"string","enum":["object","function","undefined","string","number","boolean","symbol","bigint"]},{"name":"subtype","description":"Object subtype hint. Specified for `object` type values only.","optional":true,"type":"string","enum":["array","null","node","regexp","date","map","set","weakmap","weakset","iterator","generator","error"]},{"name":"description","description":"String representation of the object.","optional":true,"type":"string"},{"name":"overflow","description":"True iff some of the properties or entries of the original object did not fit.","type":"boolean"},{"name":"properties","description":"List of the properties.","type":"array","items":{"$ref":"PropertyPreview"}},{"name":"entries","description":"List of the entries. Specified for `map` and `set` subtype values only.","optional":true,"type":"array","items":{"$ref":"EntryPreview"}}]},{"id":"PropertyPreview","experimental":true,"type":"object","properties":[{"name":"name","description":"Property name.","type":"string"},{"name":"type","description":"Object type. Accessor means that the property itself is an accessor property.","type":"string","enum":["object","function","undefined","string","number","boolean","symbol","accessor","bigint"]},{"name":"value","description":"User-friendly property value string.","optional":true,"type":"string"},{"name":"valuePreview","description":"Nested value preview.","optional":true,"$ref":"ObjectPreview"},{"name":"subtype","description":"Object subtype hint. Specified for `object` type values only.","optional":true,"type":"string","enum":["array","null","node","regexp","date","map","set","weakmap","weakset","iterator","generator","error"]}]},{"id":"EntryPreview","experimental":true,"type":"object","properties":[{"name":"key","description":"Preview of the key. Specified for map-like collection entries.","optional":true,"$ref":"ObjectPreview"},{"name":"value","description":"Preview of the value.","$ref":"ObjectPreview"}]},{"id":"PropertyDescriptor","description":"Object property descriptor.","type":"object","properties":[{"name":"name","description":"Property name or symbol description.","type":"string"},{"name":"value","description":"The value associated with the property.","optional":true,"$ref":"RemoteObject"},{"name":"writable","description":"True if the value associated with the property may be changed (data descriptors only).","optional":true,"type":"boolean"},{"name":"get","description":"A function which serves as a getter for the property, or `undefined` if there is no getter\\n(accessor descriptors only).","optional":true,"$ref":"RemoteObject"},{"name":"set","description":"A function which serves as a setter for the property, or `undefined` if there is no setter\\n(accessor descriptors only).","optional":true,"$ref":"RemoteObject"},{"name":"configurable","description":"True if the type of this property descriptor may be changed and if the property may be\\ndeleted from the corresponding object.","type":"boolean"},{"name":"enumerable","description":"True if this property shows up during enumeration of the properties on the corresponding\\nobject.","type":"boolean"},{"name":"wasThrown","description":"True if the result was thrown during the evaluation.","optional":true,"type":"boolean"},{"name":"isOwn","description":"True if the property is owned for the object.","optional":true,"type":"boolean"},{"name":"symbol","description":"Property symbol object, if the property is of the `symbol` type.","optional":true,"$ref":"RemoteObject"}]},{"id":"InternalPropertyDescriptor","description":"Object internal property descriptor. This property isn\'t normally visible in JavaScript code.","type":"object","properties":[{"name":"name","description":"Conventional property name.","type":"string"},{"name":"value","description":"The value associated with the property.","optional":true,"$ref":"RemoteObject"}]},{"id":"PrivatePropertyDescriptor","description":"Object private field descriptor.","experimental":true,"type":"object","properties":[{"name":"name","description":"Private property name.","type":"string"},{"name":"value","description":"The value associated with the private property.","$ref":"RemoteObject"}]},{"id":"CallArgument","description":"Represents function call argument. Either remote object id `objectId`, primitive `value`,\\nunserializable primitive value or neither of (for undefined) them should be specified.","type":"object","properties":[{"name":"value","description":"Primitive value or serializable javascript object.","optional":true,"type":"any"},{"name":"unserializableValue","description":"Primitive value which can not be JSON-stringified.","optional":true,"$ref":"UnserializableValue"},{"name":"objectId","description":"Remote object handle.","optional":true,"$ref":"RemoteObjectId"}]},{"id":"ExecutionContextId","description":"Id of an execution context.","type":"integer"},{"id":"ExecutionContextDescription","description":"Description of an isolated world.","type":"object","properties":[{"name":"id","description":"Unique id of the execution context. It can be used to specify in which execution context\\nscript evaluation should be performed.","$ref":"ExecutionContextId"},{"name":"origin","description":"Execution context origin.","type":"string"},{"name":"name","description":"Human readable name describing given context.","type":"string"},{"name":"auxData","description":"Embedder-specific auxiliary data.","optional":true,"type":"object"}]},{"id":"ExceptionDetails","description":"Detailed information about exception (or error) that was thrown during script compilation or\\nexecution.","type":"object","properties":[{"name":"exceptionId","description":"Exception id.","type":"integer"},{"name":"text","description":"Exception text, which should be used together with exception object when available.","type":"string"},{"name":"lineNumber","description":"Line number of the exception location (0-based).","type":"integer"},{"name":"columnNumber","description":"Column number of the exception location (0-based).","type":"integer"},{"name":"scriptId","description":"Script ID of the exception location.","optional":true,"$ref":"ScriptId"},{"name":"url","description":"URL of the exception location, to be used when the script was not reported.","optional":true,"type":"string"},{"name":"stackTrace","description":"JavaScript stack trace if available.","optional":true,"$ref":"StackTrace"},{"name":"exception","description":"Exception object if available.","optional":true,"$ref":"RemoteObject"},{"name":"executionContextId","description":"Identifier of the context where exception happened.","optional":true,"$ref":"ExecutionContextId"}]},{"id":"Timestamp","description":"Number of milliseconds since epoch.","type":"number"},{"id":"TimeDelta","description":"Number of milliseconds.","type":"number"},{"id":"CallFrame","description":"Stack entry for runtime errors and assertions.","type":"object","properties":[{"name":"functionName","description":"JavaScript function name.","type":"string"},{"name":"scriptId","description":"JavaScript script id.","$ref":"ScriptId"},{"name":"url","description":"JavaScript script name or url.","type":"string"},{"name":"lineNumber","description":"JavaScript script line number (0-based).","type":"integer"},{"name":"columnNumber","description":"JavaScript script column number (0-based).","type":"integer"}]},{"id":"StackTrace","description":"Call frames for assertions or error messages.","type":"object","properties":[{"name":"description","description":"String label of this stack trace. For async traces this may be a name of the function that\\ninitiated the async call.","optional":true,"type":"string"},{"name":"callFrames","description":"JavaScript function name.","type":"array","items":{"$ref":"CallFrame"}},{"name":"parent","description":"Asynchronous JavaScript stack trace that preceded this stack, if available.","optional":true,"$ref":"StackTrace"},{"name":"parentId","description":"Asynchronous JavaScript stack trace that preceded this stack, if available.","experimental":true,"optional":true,"$ref":"StackTraceId"}]},{"id":"UniqueDebuggerId","description":"Unique identifier of current debugger.","experimental":true,"type":"string"},{"id":"StackTraceId","description":"If `debuggerId` is set stack trace comes from another debugger and can be resolved there. This\\nallows to track cross-debugger calls. See `Runtime.StackTrace` and `Debugger.paused` for usages.","experimental":true,"type":"object","properties":[{"name":"id","type":"string"},{"name":"debuggerId","optional":true,"$ref":"UniqueDebuggerId"}]}],"commands":[{"name":"awaitPromise","description":"Add handler to promise with given promise object id.","parameters":[{"name":"promiseObjectId","description":"Identifier of the promise.","$ref":"RemoteObjectId"},{"name":"returnByValue","description":"Whether the result is expected to be a JSON object that should be sent by value.","optional":true,"type":"boolean"},{"name":"generatePreview","description":"Whether preview should be generated for the result.","optional":true,"type":"boolean"}],"returns":[{"name":"result","description":"Promise result. Will contain rejected value if promise was rejected.","$ref":"RemoteObject"},{"name":"exceptionDetails","description":"Exception details if stack strace is available.","optional":true,"$ref":"ExceptionDetails"}]},{"name":"callFunctionOn","description":"Calls function with given declaration on the given object. Object group of the result is\\ninherited from the target object.","parameters":[{"name":"functionDeclaration","description":"Declaration of the function to call.","type":"string"},{"name":"objectId","description":"Identifier of the object to call function on. Either objectId or executionContextId should\\nbe specified.","optional":true,"$ref":"RemoteObjectId"},{"name":"arguments","description":"Call arguments. All call arguments must belong to the same JavaScript world as the target\\nobject.","optional":true,"type":"array","items":{"$ref":"CallArgument"}},{"name":"silent","description":"In silent mode exceptions thrown during evaluation are not reported and do not pause\\nexecution. Overrides `setPauseOnException` state.","optional":true,"type":"boolean"},{"name":"returnByValue","description":"Whether the result is expected to be a JSON object which should be sent by value.","optional":true,"type":"boolean"},{"name":"generatePreview","description":"Whether preview should be generated for the result.","experimental":true,"optional":true,"type":"boolean"},{"name":"userGesture","description":"Whether execution should be treated as initiated by user in the UI.","optional":true,"type":"boolean"},{"name":"awaitPromise","description":"Whether execution should `await` for resulting value and return once awaited promise is\\nresolved.","optional":true,"type":"boolean"},{"name":"executionContextId","description":"Specifies execution context which global object will be used to call function on. Either\\nexecutionContextId or objectId should be specified.","optional":true,"$ref":"ExecutionContextId"},{"name":"objectGroup","description":"Symbolic group name that can be used to release multiple objects. If objectGroup is not\\nspecified and objectId is, objectGroup will be inherited from object.","optional":true,"type":"string"}],"returns":[{"name":"result","description":"Call result.","$ref":"RemoteObject"},{"name":"exceptionDetails","description":"Exception details.","optional":true,"$ref":"ExceptionDetails"}]},{"name":"compileScript","description":"Compiles expression.","parameters":[{"name":"expression","description":"Expression to compile.","type":"string"},{"name":"sourceURL","description":"Source url to be set for the script.","type":"string"},{"name":"persistScript","description":"Specifies whether the compiled script should be persisted.","type":"boolean"},{"name":"executionContextId","description":"Specifies in which execution context to perform script run. If the parameter is omitted the\\nevaluation will be performed in the context of the inspected page.","optional":true,"$ref":"ExecutionContextId"}],"returns":[{"name":"scriptId","description":"Id of the script.","optional":true,"$ref":"ScriptId"},{"name":"exceptionDetails","description":"Exception details.","optional":true,"$ref":"ExceptionDetails"}]},{"name":"disable","description":"Disables reporting of execution contexts creation."},{"name":"discardConsoleEntries","description":"Discards collected exceptions and console API calls."},{"name":"enable","description":"Enables reporting of execution contexts creation by means of `executionContextCreated` event.\\nWhen the reporting gets enabled the event will be sent immediately for each existing execution\\ncontext."},{"name":"evaluate","description":"Evaluates expression on global object.","parameters":[{"name":"expression","description":"Expression to evaluate.","type":"string"},{"name":"objectGroup","description":"Symbolic group name that can be used to release multiple objects.","optional":true,"type":"string"},{"name":"includeCommandLineAPI","description":"Determines whether Command Line API should be available during the evaluation.","optional":true,"type":"boolean"},{"name":"silent","description":"In silent mode exceptions thrown during evaluation are not reported and do not pause\\nexecution. Overrides `setPauseOnException` state.","optional":true,"type":"boolean"},{"name":"contextId","description":"Specifies in which execution context to perform evaluation. If the parameter is omitted the\\nevaluation will be performed in the context of the inspected page.","optional":true,"$ref":"ExecutionContextId"},{"name":"returnByValue","description":"Whether the result is expected to be a JSON object that should be sent by value.","optional":true,"type":"boolean"},{"name":"generatePreview","description":"Whether preview should be generated for the result.","experimental":true,"optional":true,"type":"boolean"},{"name":"userGesture","description":"Whether execution should be treated as initiated by user in the UI.","optional":true,"type":"boolean"},{"name":"awaitPromise","description":"Whether execution should `await` for resulting value and return once awaited promise is\\nresolved.","optional":true,"type":"boolean"},{"name":"throwOnSideEffect","description":"Whether to throw an exception if side effect cannot be ruled out during evaluation.","experimental":true,"optional":true,"type":"boolean"},{"name":"timeout","description":"Terminate execution after timing out (number of milliseconds).","experimental":true,"optional":true,"$ref":"TimeDelta"}],"returns":[{"name":"result","description":"Evaluation result.","$ref":"RemoteObject"},{"name":"exceptionDetails","description":"Exception details.","optional":true,"$ref":"ExceptionDetails"}]},{"name":"getIsolateId","description":"Returns the isolate id.","experimental":true,"returns":[{"name":"id","description":"The isolate id.","type":"string"}]},{"name":"getHeapUsage","description":"Returns the JavaScript heap usage.\\nIt is the total usage of the corresponding isolate not scoped to a particular Runtime.","experimental":true,"returns":[{"name":"usedSize","description":"Used heap size in bytes.","type":"number"},{"name":"totalSize","description":"Allocated heap size in bytes.","type":"number"}]},{"name":"getProperties","description":"Returns properties of a given object. Object group of the result is inherited from the target\\nobject.","parameters":[{"name":"objectId","description":"Identifier of the object to return properties for.","$ref":"RemoteObjectId"},{"name":"ownProperties","description":"If true, returns properties belonging only to the element itself, not to its prototype\\nchain.","optional":true,"type":"boolean"},{"name":"accessorPropertiesOnly","description":"If true, returns accessor properties (with getter/setter) only; internal properties are not\\nreturned either.","experimental":true,"optional":true,"type":"boolean"},{"name":"generatePreview","description":"Whether preview should be generated for the results.","experimental":true,"optional":true,"type":"boolean"}],"returns":[{"name":"result","description":"Object properties.","type":"array","items":{"$ref":"PropertyDescriptor"}},{"name":"internalProperties","description":"Internal object properties (only of the element itself).","optional":true,"type":"array","items":{"$ref":"InternalPropertyDescriptor"}},{"name":"privateProperties","description":"Object private properties.","experimental":true,"optional":true,"type":"array","items":{"$ref":"PrivatePropertyDescriptor"}},{"name":"exceptionDetails","description":"Exception details.","optional":true,"$ref":"ExceptionDetails"}]},{"name":"globalLexicalScopeNames","description":"Returns all let, const and class variables from global scope.","parameters":[{"name":"executionContextId","description":"Specifies in which execution context to lookup global scope variables.","optional":true,"$ref":"ExecutionContextId"}],"returns":[{"name":"names","type":"array","items":{"type":"string"}}]},{"name":"queryObjects","parameters":[{"name":"prototypeObjectId","description":"Identifier of the prototype to return objects for.","$ref":"RemoteObjectId"},{"name":"objectGroup","description":"Symbolic group name that can be used to release the results.","optional":true,"type":"string"}],"returns":[{"name":"objects","description":"Array with objects.","$ref":"RemoteObject"}]},{"name":"releaseObject","description":"Releases remote object with given id.","parameters":[{"name":"objectId","description":"Identifier of the object to release.","$ref":"RemoteObjectId"}]},{"name":"releaseObjectGroup","description":"Releases all remote objects that belong to a given group.","parameters":[{"name":"objectGroup","description":"Symbolic object group name.","type":"string"}]},{"name":"runIfWaitingForDebugger","description":"Tells inspected instance to run if it was waiting for debugger to attach."},{"name":"runScript","description":"Runs script with given id in a given context.","parameters":[{"name":"scriptId","description":"Id of the script to run.","$ref":"ScriptId"},{"name":"executionContextId","description":"Specifies in which execution context to perform script run. If the parameter is omitted the\\nevaluation will be performed in the context of the inspected page.","optional":true,"$ref":"ExecutionContextId"},{"name":"objectGroup","description":"Symbolic group name that can be used to release multiple objects.","optional":true,"type":"string"},{"name":"silent","description":"In silent mode exceptions thrown during evaluation are not reported and do not pause\\nexecution. Overrides `setPauseOnException` state.","optional":true,"type":"boolean"},{"name":"includeCommandLineAPI","description":"Determines whether Command Line API should be available during the evaluation.","optional":true,"type":"boolean"},{"name":"returnByValue","description":"Whether the result is expected to be a JSON object which should be sent by value.","optional":true,"type":"boolean"},{"name":"generatePreview","description":"Whether preview should be generated for the result.","optional":true,"type":"boolean"},{"name":"awaitPromise","description":"Whether execution should `await` for resulting value and return once awaited promise is\\nresolved.","optional":true,"type":"boolean"}],"returns":[{"name":"result","description":"Run result.","$ref":"RemoteObject"},{"name":"exceptionDetails","description":"Exception details.","optional":true,"$ref":"ExceptionDetails"}]},{"name":"setAsyncCallStackDepth","description":"Enables or disables async call stacks tracking.","redirect":"Debugger","parameters":[{"name":"maxDepth","description":"Maximum depth of async call stacks. Setting to `0` will effectively disable collecting async\\ncall stacks (default).","type":"integer"}]},{"name":"setCustomObjectFormatterEnabled","experimental":true,"parameters":[{"name":"enabled","type":"boolean"}]},{"name":"setMaxCallStackSizeToCapture","experimental":true,"parameters":[{"name":"size","type":"integer"}]},{"name":"terminateExecution","description":"Terminate current or next JavaScript execution.\\nWill cancel the termination when the outer-most script execution ends.","experimental":true},{"name":"addBinding","description":"If executionContextId is empty, adds binding with the given name on the\\nglobal objects of all inspected contexts, including those created later,\\nbindings survive reloads.\\nIf executionContextId is specified, adds binding only on global object of\\ngiven execution context.\\nBinding function takes exactly one argument, this argument should be string,\\nin case of any other input, function throws an exception.\\nEach binding function call produces Runtime.bindingCalled notification.","experimental":true,"parameters":[{"name":"name","type":"string"},{"name":"executionContextId","optional":true,"$ref":"ExecutionContextId"}]},{"name":"removeBinding","description":"This method does not remove binding function from global object but\\nunsubscribes current runtime agent from Runtime.bindingCalled notifications.","experimental":true,"parameters":[{"name":"name","type":"string"}]}],"events":[{"name":"bindingCalled","description":"Notification is issued every time when binding is called.","experimental":true,"parameters":[{"name":"name","type":"string"},{"name":"payload","type":"string"},{"name":"executionContextId","description":"Identifier of the context where the call was made.","$ref":"ExecutionContextId"}]},{"name":"consoleAPICalled","description":"Issued when console API was called.","parameters":[{"name":"type","description":"Type of the call.","type":"string","enum":["log","debug","info","error","warning","dir","dirxml","table","trace","clear","startGroup","startGroupCollapsed","endGroup","assert","profile","profileEnd","count","timeEnd"]},{"name":"args","description":"Call arguments.","type":"array","items":{"$ref":"RemoteObject"}},{"name":"executionContextId","description":"Identifier of the context where the call was made.","$ref":"ExecutionContextId"},{"name":"timestamp","description":"Call timestamp.","$ref":"Timestamp"},{"name":"stackTrace","description":"Stack trace captured when the call was made. The async stack chain is automatically reported for\\nthe following call types: `assert`, `error`, `trace`, `warning`. For other types the async call\\nchain can be retrieved using `Debugger.getStackTrace` and `stackTrace.parentId` field.","optional":true,"$ref":"StackTrace"},{"name":"context","description":"Console context descriptor for calls on non-default console context (not console.*):\\n\'anonymous#unique-logger-id\' for call on unnamed context, \'name#unique-logger-id\' for call\\non named context.","experimental":true,"optional":true,"type":"string"}]},{"name":"exceptionRevoked","description":"Issued when unhandled exception was revoked.","parameters":[{"name":"reason","description":"Reason describing why exception was revoked.","type":"string"},{"name":"exceptionId","description":"The id of revoked exception, as reported in `exceptionThrown`.","type":"integer"}]},{"name":"exceptionThrown","description":"Issued when exception was thrown and unhandled.","parameters":[{"name":"timestamp","description":"Timestamp of the exception.","$ref":"Timestamp"},{"name":"exceptionDetails","$ref":"ExceptionDetails"}]},{"name":"executionContextCreated","description":"Issued when new execution context is created.","parameters":[{"name":"context","description":"A newly created execution context.","$ref":"ExecutionContextDescription"}]},{"name":"executionContextDestroyed","description":"Issued when execution context is destroyed.","parameters":[{"name":"executionContextId","description":"Id of the destroyed context","$ref":"ExecutionContextId"}]},{"name":"executionContextsCleared","description":"Issued when all executionContexts were cleared in browser"},{"name":"inspectRequested","description":"Issued when object should be inspected (for example, as a result of inspect() command line API\\ncall).","parameters":[{"name":"object","$ref":"RemoteObject"},{"name":"hints","type":"object"}]}]},{"domain":"Schema","description":"This domain is deprecated.","deprecated":true,"types":[{"id":"Domain","description":"Description of the protocol domain.","type":"object","properties":[{"name":"name","description":"Domain name.","type":"string"},{"name":"version","description":"Domain version.","type":"string"}]}],"commands":[{"name":"getDomains","description":"Returns supported domains.","returns":[{"name":"domains","description":"List of supported domains.","type":"array","items":{"$ref":"Domain"}}]}]}]}')},function(e,t,n){"use strict";(function(t){const r=n(56),i=n(379),o=n(75).format,a=n(75).parse,s=n(381),c=n(382),p=n(151),d=n(139);class u extends Error{constructor(e,t){let{message:n}=t;t.data&&(n+=` (${t.data})`),super(n),this.request=e,this.response=t}}e.exports=class extends r{constructor(e,t){super(),e=e||{},this.host=e.host||p.HOST,this.port=e.port||p.PORT,this.secure=!!e.secure,this.useHostName=!!e.useHostName,this.alterPath=e.alterPath||(e=>e),this.protocol=e.protocol,this.local=!!e.local,this.target=e.target||(e=>{let t,n=e.find(e=>!!e.webSocketDebuggerUrl&&(t=t||e,"page"===e.type));if(n=n||t)return n;throw new Error("No inspectable targets")}),this._notifier=t,this._callbacks={},this._nextCommandId=1,this.webSocketUrl=void 0,this._start()}inspect(e,t){return t.customInspect=!1,i.inspect(this,t)}send(e,t,n){return"function"==typeof t&&(n=t,t=void 0),"function"==typeof n?void this._enqueueCommand(e,t,n):new Promise((n,r)=>{this._enqueueCommand(e,t,(i,o)=>{if(i){const n={method:e,params:t};r(i instanceof Error?i:new u(n,o))}else n(o)})})}close(e){const t=e=>{3===this._ws.readyState?e():(this._ws.removeAllListeners("close"),this._ws.once("close",()=>{this._ws.removeAllListeners(),e()}),this._ws.close())};return"function"==typeof e?void t(e):new Promise((e,n)=>{t(e)})}async _start(){const e={host:this.host,port:this.port,secure:this.secure,useHostName:this.useHostName,alterPath:this.alterPath};try{const n=await this._fetchDebuggerURL(e),r=a(n);r.pathname=e.alterPath(r.pathname),this.webSocketUrl=o(r),e.host=r.hostname,e.port=r.port||e.port;const i=await this._fetchProtocol(e);c.prepare(this,i),await this._connectToWebSocket(),t.nextTick(()=>{this._notifier.emit("connect",this)})}catch(e){this._notifier.emit("error",e)}}async _fetchDebuggerURL(e){const t=this.target;switch(typeof t){case"string":{let n=t;return n.startsWith("/")&&(n=`ws://${this.host}:${this.port}${n}`),n.match(/^wss?:/i)?n:(await d.List(e)).find(e=>e.id===n).webSocketDebuggerUrl}case"object":return t.webSocketDebuggerUrl;case"function":{const n=t,r=await d.List(e),i=n(r);return("number"==typeof i?r[i]:i).webSocketDebuggerUrl}default:throw new Error(`Invalid target argument "${this.target}"`)}}async _fetchProtocol(e){return this.protocol?this.protocol:(e.local=this.local,await d.Protocol(e))}_connectToWebSocket(){return new Promise((e,t)=>{try{this.secure&&(this.webSocketUrl=this.webSocketUrl.replace(/^ws:/i,"wss:")),this._ws=new s(this.webSocketUrl)}catch(e){return void t(e)}this._ws.on("open",()=>{e()}),this._ws.on("message",e=>{const t=JSON.parse(e);this._handleMessage(t)}),this._ws.on("close",e=>{this.emit("disconnect")}),this._ws.on("error",e=>{t(e)})})}_handleMessage(e){if(e.id){const t=this._callbacks[e.id];if(!t)return;e.error?t(!0,e.error):t(!1,e.result||{}),delete this._callbacks[e.id],0===Object.keys(this._callbacks).length&&this.emit("ready")}else e.method&&(this.emit("event",e),this.emit(e.method,e.params))}_enqueueCommand(e,t,n){const r=this._nextCommandId++,i={id:r,method:e,params:t||{}};this._ws.send(JSON.stringify(i),e=>{e?"function"==typeof n&&n(e):this._callbacks[r]=n})}}}).call(this,n(30))},function(e,t,n){(function(e){var r=Object.getOwnPropertyDescriptors||function(e){for(var t=Object.keys(e),n={},r=0;r<t.length;r++)n[t[r]]=Object.getOwnPropertyDescriptor(e,t[r]);return n},i=/%[sdj%]/g;t.format=function(e){if(!y(e)){for(var t=[],n=0;n<arguments.length;n++)t.push(s(arguments[n]));return t.join(" ")}n=1;for(var r=arguments,o=r.length,a=String(e).replace(i,function(e){if("%%"===e)return"%";if(n>=o)return e;switch(e){case"%s":return String(r[n++]);case"%d":return Number(r[n++]);case"%j":try{return JSON.stringify(r[n++])}catch(e){return"[Circular]"}default:return e}}),c=r[n];n<o;c=r[++n])h(c)||!w(c)?a+=" "+c:a+=" "+s(c);return a},t.deprecate=function(n,r){if(void 0!==e&&!0===e.noDeprecation)return n;if(void 0===e)return function(){return t.deprecate(n,r).apply(this,arguments)};var i=!1;return function(){if(!i){if(e.throwDeprecation)throw new Error(r);e.traceDeprecation?console.trace(r):console.error(r),i=!0}return n.apply(this,arguments)}};var o,a={};function s(e,n){var r={seen:[],stylize:p};return arguments.length>=3&&(r.depth=arguments[2]),arguments.length>=4&&(r.colors=arguments[3]),f(n)?r.showHidden=n:n&&t._extend(r,n),b(r.showHidden)&&(r.showHidden=!1),b(r.depth)&&(r.depth=2),b(r.colors)&&(r.colors=!1),b(r.customInspect)&&(r.customInspect=!0),r.colors&&(r.stylize=c),d(r,e,r.depth)}function c(e,t){var n=s.styles[t];return n?"["+s.colors[n][0]+"m"+e+"["+s.colors[n][1]+"m":e}function p(e,t){return e}function d(e,n,r){if(e.customInspect&&n&&I(n.inspect)&&n.inspect!==t.inspect&&(!n.constructor||n.constructor.prototype!==n)){var i=n.inspect(r,e);return y(i)||(i=d(e,i,r)),i}var o=function(e,t){if(b(t))return e.stylize("undefined","undefined");if(y(t)){var n="'"+JSON.stringify(t).replace(/^"|"$/g,"").replace(/'/g,"\\'").replace(/\\"/g,'"')+"'";return e.stylize(n,"string")}if(g(t))return e.stylize(""+t,"number");if(f(t))return e.stylize(""+t,"boolean");if(h(t))return e.stylize("null","null")}(e,n);if(o)return o;var a=Object.keys(n),s=function(e){var t={};return e.forEach(function(e,n){t[e]=!0}),t}(a);if(e.showHidden&&(a=Object.getOwnPropertyNames(n)),x(n)&&(a.indexOf("message")>=0||a.indexOf("description")>=0))return u(n);if(0===a.length){if(I(n)){var c=n.name?": "+n.name:"";return e.stylize("[Function"+c+"]","special")}if(v(n))return e.stylize(RegExp.prototype.toString.call(n),"regexp");if(S(n))return e.stylize(Date.prototype.toString.call(n),"date");if(x(n))return u(n)}var p,w="",T=!1,R=["{","}"];(m(n)&&(T=!0,R=["[","]"]),I(n))&&(w=" [Function"+(n.name?": "+n.name:"")+"]");return v(n)&&(w=" "+RegExp.prototype.toString.call(n)),S(n)&&(w=" "+Date.prototype.toUTCString.call(n)),x(n)&&(w=" "+u(n)),0!==a.length||T&&0!=n.length?r<0?v(n)?e.stylize(RegExp.prototype.toString.call(n),"regexp"):e.stylize("[Object]","special"):(e.seen.push(n),p=T?function(e,t,n,r,i){for(var o=[],a=0,s=t.length;a<s;++a)O(t,String(a))?o.push(l(e,t,n,r,String(a),!0)):o.push("");return i.forEach(function(i){i.match(/^\d+$/)||o.push(l(e,t,n,r,i,!0))}),o}(e,n,r,s,a):a.map(function(t){return l(e,n,r,s,t,T)}),e.seen.pop(),function(e,t,n){if(e.reduce(function(e,t){return 0,t.indexOf("\n")>=0&&0,e+t.replace(/\u001b\[\d\d?m/g,"").length+1},0)>60)return n[0]+(""===t?"":t+"\n ")+" "+e.join(",\n ")+" "+n[1];return n[0]+t+" "+e.join(", ")+" "+n[1]}(p,w,R)):R[0]+w+R[1]}function u(e){return"["+Error.prototype.toString.call(e)+"]"}function l(e,t,n,r,i,o){var a,s,c;if((c=Object.getOwnPropertyDescriptor(t,i)||{value:t[i]}).get?s=c.set?e.stylize("[Getter/Setter]","special"):e.stylize("[Getter]","special"):c.set&&(s=e.stylize("[Setter]","special")),O(r,i)||(a="["+i+"]"),s||(e.seen.indexOf(c.value)<0?(s=h(n)?d(e,c.value,null):d(e,c.value,n-1)).indexOf("\n")>-1&&(s=o?s.split("\n").map(function(e){return" "+e}).join("\n").substr(2):"\n"+s.split("\n").map(function(e){return" "+e}).join("\n")):s=e.stylize("[Circular]","special")),b(a)){if(o&&i.match(/^\d+$/))return s;(a=JSON.stringify(""+i)).match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)?(a=a.substr(1,a.length-2),a=e.stylize(a,"name")):(a=a.replace(/'/g,"\\'").replace(/\\"/g,'"').replace(/(^"|"$)/g,"'"),a=e.stylize(a,"string"))}return a+": "+s}function m(e){return Array.isArray(e)}function f(e){return"boolean"==typeof e}function h(e){return null===e}function g(e){return"number"==typeof e}function y(e){return"string"==typeof e}function b(e){return void 0===e}function v(e){return w(e)&&"[object RegExp]"===T(e)}function w(e){return"object"==typeof e&&null!==e}function S(e){return w(e)&&"[object Date]"===T(e)}function x(e){return w(e)&&("[object Error]"===T(e)||e instanceof Error)}function I(e){return"function"==typeof e}function T(e){return Object.prototype.toString.call(e)}function R(e){return e<10?"0"+e.toString(10):e.toString(10)}t.debuglog=function(n){if(b(o)&&(o=e.env.NODE_DEBUG||""),n=n.toUpperCase(),!a[n])if(new RegExp("\\b"+n+"\\b","i").test(o)){var r=e.pid;a[n]=function(){var e=t.format.apply(t,arguments);console.error("%s %d: %s",n,r,e)}}else a[n]=function(){};return a[n]},t.inspect=s,s.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]},s.styles={special:"cyan",number:"yellow",boolean:"yellow",undefined:"grey",null:"bold",string:"green",date:"magenta",regexp:"red"},t.isArray=m,t.isBoolean=f,t.isNull=h,t.isNullOrUndefined=function(e){return null==e},t.isNumber=g,t.isString=y,t.isSymbol=function(e){return"symbol"==typeof e},t.isUndefined=b,t.isRegExp=v,t.isObject=w,t.isDate=S,t.isError=x,t.isFunction=I,t.isPrimitive=function(e){return null===e||"boolean"==typeof e||"number"==typeof e||"string"==typeof e||"symbol"==typeof e||void 0===e},t.isBuffer=n(380);var k=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function C(){var e=new Date,t=[R(e.getHours()),R(e.getMinutes()),R(e.getSeconds())].join(":");return[e.getDate(),k[e.getMonth()],t].join(" ")}function O(e,t){return Object.prototype.hasOwnProperty.call(e,t)}t.log=function(){console.log("%s - %s",C(),t.format.apply(t,arguments))},t.inherits=n(34),t._extend=function(e,t){if(!t||!w(t))return e;for(var n=Object.keys(t),r=n.length;r--;)e[n[r]]=t[n[r]];return e};var $="undefined"!=typeof Symbol?Symbol("util.promisify.custom"):void 0;function E(e,t){if(!e){var n=new Error("Promise was rejected with a falsy value");n.reason=e,e=n}return t(e)}t.promisify=function(e){if("function"!=typeof e)throw new TypeError('The "original" argument must be of type Function');if($&&e[$]){var t;if("function"!=typeof(t=e[$]))throw new TypeError('The "util.promisify.custom" argument must be of type Function');return Object.defineProperty(t,$,{value:t,enumerable:!1,writable:!1,configurable:!0}),t}function t(){for(var t,n,r=new Promise(function(e,r){t=e,n=r}),i=[],o=0;o<arguments.length;o++)i.push(arguments[o]);i.push(function(e,r){e?n(e):t(r)});try{e.apply(this,i)}catch(e){n(e)}return r}return Object.setPrototypeOf(t,Object.getPrototypeOf(e)),$&&Object.defineProperty(t,$,{value:t,enumerable:!1,writable:!1,configurable:!0}),Object.defineProperties(t,r(e))},t.promisify.custom=$,t.callbackify=function(t){if("function"!=typeof t)throw new TypeError('The "original" argument must be of type Function');function n(){for(var n=[],r=0;r<arguments.length;r++)n.push(arguments[r]);var i=n.pop();if("function"!=typeof i)throw new TypeError("The last argument must be of type Function");var o=this,a=function(){return i.apply(o,arguments)};t.apply(this,n).then(function(t){e.nextTick(a,null,t)},function(t){e.nextTick(E,t,a)})}return Object.setPrototypeOf(n,Object.getPrototypeOf(t)),Object.defineProperties(n,r(t)),n}}).call(this,n(30))},function(e,t){e.exports=function(e){return e&&"object"==typeof e&&"function"==typeof e.copy&&"function"==typeof e.fill&&"function"==typeof e.readUInt8}},function(e,t,n){"use strict";const r=n(56);e.exports=class extends r{constructor(e){super(),this._ws=new WebSocket(e),this._ws.onopen=()=>{this.emit("open")},this._ws.onclose=()=>{this.emit("close")},this._ws.onmessage=e=>{this.emit("message",e.data)},this._ws.onerror=()=>{this.emit("error",new Error("WebSocket error"))}}close(){this._ws.close()}send(e,t){try{this._ws.send(e),t()}catch(e){t(e)}}}},function(e,t,n){"use strict";function r(e,t,n){e.category=t,Object.keys(n).forEach(r=>{"name"!==r&&(e[r]="type"===t&&"properties"===r||"parameters"===r?function(e){const t={};return e.forEach(e=>{const n=e.name;delete e.name,t[n]=e}),t}(n[r]):n[r])})}e.exports.prepare=function(e,t){e.protocol=t,t.domains.forEach(t=>{const n=t.domain;e[n]={},(t.commands||[]).forEach(t=>{!function(e,t,n){const i=(r,i)=>e.send(`${t}.${n.name}`,r,i);r(i,"command",n),e[t][n.name]=i}(e,n,t)}),(t.events||[]).forEach(t=>{!function(e,t,n){const i=`${t}.${n.name}`,o=t=>"function"==typeof t?(e.on(i,t),()=>e.removeListener(i,t)):new Promise((t,n)=>{e.once(i,t)});r(o,"event",n),e[t][n.name]=o}(e,n,t)}),(t.types||[]).forEach(t=>{!function(e,t,n){const i={};r(i,"type",n),e[t][n.id]=i}(e,n,t)})})}}]);
\ No newline at end of file diff --git a/remote/cdp/test/browser/dom/browser.toml b/remote/cdp/test/browser/dom/browser.toml new file mode 100644 index 0000000000..0815ae681f --- /dev/null +++ b/remote/cdp/test/browser/dom/browser.toml @@ -0,0 +1,23 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", +] + +["browser_describeNode.js"] + +["browser_resolveNode.js"] diff --git a/remote/cdp/test/browser/dom/browser_describeNode.js b/remote/cdp/test/browser/dom/browser_describeNode.js new file mode 100644 index 0000000000..8db9a85685 --- /dev/null +++ b/remote/cdp/test/browser/dom/browser_describeNode.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const DOC = toDataURL("<div id='content'><p>foo</p><p>bar</p></div>"); +const DOC_FRAME = toDataURL(`<iframe src="${DOC}"></iframe>`); + +add_task(async function objectIdInvalidTypes({ client }) { + const { DOM } = client; + + for (const objectId of [null, true, 1, [], {}]) { + await Assert.rejects( + DOM.describeNode({ objectId }), + /objectId: string value expected/, + `Fails with invalid type: ${objectId}` + ); + } +}); + +add_task(async function objectIdUnknownValue({ client }) { + const { DOM } = client; + + await Assert.rejects( + DOM.describeNode({ objectId: "foo" }), + /Could not find object with given id/, + `Fails with unknown objectId` + ); +}); + +add_task(async function objectIdIsNotANode({ client }) { + const { DOM, Runtime } = client; + + await Runtime.enable(); + const { result } = await Runtime.evaluate({ + expression: "[42]", + }); + + await Assert.rejects( + DOM.describeNode({ objectId: result.objectId }), + /Object id doesn't reference a Node/, + `Fails if objectId doesn't reference a DOM node` + ); +}); + +add_task(async function objectIdAllProperties({ client }) { + const { DOM, Page, Runtime } = client; + + await Page.enable(); + const { frameId } = await Page.navigate({ url: DOC }); + await Page.loadEventFired(); + + await Runtime.enable(); + const { result } = await Runtime.evaluate({ + expression: `document.getElementById('content')`, + }); + const { node } = await DOM.describeNode({ + objectId: result.objectId, + }); + + ok(!!node.nodeId, "The node has a node id"); + ok(!!node.backendNodeId, "The node has a backend node id"); + is(node.nodeName, "DIV", "Found expected node name"); + is(node.localName, "div", "Found expected local name"); + is(node.nodeType, 1, "Found expected node type"); + is(node.nodeValue, "", "Found expected node value"); + is(node.childNodeCount, 2, "Expected number of child nodes found"); + is(node.attributes.length, 2, "Found expected attribute's name and value"); + is(node.attributes[0], "id", "Found expected attribute name"); + is(node.attributes[1], "content", "Found expected attribute value"); + is(node.frameId, frameId, "Found expected frame id"); +}); + +add_task(async function objectIdNoAttributes({ client }) { + const { DOM, Runtime } = client; + + await Runtime.enable(); + const { result } = await Runtime.evaluate({ + expression: "document", + }); + const { node } = await DOM.describeNode({ + objectId: result.objectId, + }); + + is(node.attributes, undefined, "No attributes returned"); +}); + +add_task(async function objectIdDiffersForDifferentNodes({ client }) { + const { DOM, Runtime } = client; + + await loadURL(DOC); + + await Runtime.enable(); + const { result: doc } = await Runtime.evaluate({ + expression: "document", + }); + const { node: node1 } = await DOM.describeNode({ + objectId: doc.objectId, + }); + + const { result: body } = await Runtime.evaluate({ + expression: `document.getElementById('content')`, + }); + const { node: node2 } = await DOM.describeNode({ + objectId: body.objectId, + }); + + for (const prop in node1) { + if (["nodeValue", "frameId"].includes(prop)) { + is(node1[prop], node2[prop], `Values of ${prop} are equal`); + } else { + isnot(node1[prop], node2[prop], `Values of ${prop} are different`); + } + } +}); + +add_task(async function objectIdDoesNotChangeForTheSameNode({ client }) { + const { DOM, Runtime } = client; + + await Runtime.enable(); + const { result } = await Runtime.evaluate({ + expression: "document", + }); + const { node: node1 } = await DOM.describeNode({ + objectId: result.objectId, + }); + const { node: node2 } = await DOM.describeNode({ + objectId: result.objectId, + }); + + for (const prop in node1) { + is(node1[prop], node2[prop], `Values of ${prop} are equal`); + } +}); + +add_task(async function frameIdForFrameElement({ client }) { + const { DOM, Page, Runtime } = client; + + await Page.enable(); + + const frameAttached = Page.frameAttached(); + await loadURL(DOC_FRAME); + const { frameId, parentFrameId } = await frameAttached; + + await Runtime.enable(); + + const { result: frameObj } = await Runtime.evaluate({ + expression: "document.getElementsByTagName('iframe')[0]", + }); + const { node: frame } = await DOM.describeNode({ + objectId: frameObj.objectId, + }); + + is(frame.frameId, frameId, "Reported frameId is from the frame itself"); + isnot( + frame.frameId, + parentFrameId, + "Reported frameId is not the parentFrameId" + ); +}); diff --git a/remote/cdp/test/browser/dom/browser_resolveNode.js b/remote/cdp/test/browser/dom/browser_resolveNode.js new file mode 100644 index 0000000000..759c4ee76c --- /dev/null +++ b/remote/cdp/test/browser/dom/browser_resolveNode.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function backendNodeIdInvalidTypes({ client }) { + const { DOM } = client; + + for (const backendNodeId of [null, true, "foo", [], {}]) { + await Assert.rejects( + DOM.resolveNode({ backendNodeId }), + /backendNodeId: number value expected/, + `Fails for invalid type: ${backendNodeId}` + ); + } +}); + +add_task(async function backendNodeIdInvalidValue({ client }) { + const { DOM } = client; + + await Assert.rejects( + DOM.resolveNode({ backendNodeId: -1 }), + /No node with given id found/, + "Fails for unknown backendNodeId" + ); +}); + +add_task(async function backendNodeIdResultProperties({ client }) { + const { DOM, Runtime } = client; + + await Runtime.enable(); + const { result } = await Runtime.evaluate({ expression: "document" }); + const { node } = await DOM.describeNode({ objectId: result.objectId }); + + const { object } = await DOM.resolveNode({ + backendNodeId: node.backendNodeId, + }); + + ok(!!object, "Javascript node object returned"); + is(object.type, result.type, "Expected type returned"); + is(object.subtype, result.subtype, "Expected subtype returned"); + isnot(object.objectId, result.objectId, "Object has been duplicated"); +}); + +add_task(async function executionContextIdInvalidTypes({ client }) { + const { DOM, Runtime } = client; + + await Runtime.enable(); + const { result } = await Runtime.evaluate({ expression: "document" }); + const { node } = await DOM.describeNode({ objectId: result.objectId }); + + for (const executionContextId of [null, true, "foo", [], {}]) { + await Assert.rejects( + DOM.resolveNode({ + backendNodeId: node.backendNodeId, + executionContextId, + }), + /executionContextId: integer value expected/, + `Fails for invalid type: ${executionContextId}` + ); + } +}); + +add_task(async function executionContextIdInvalidValue({ client }) { + const { DOM, Runtime } = client; + + await Runtime.enable(); + const { result } = await Runtime.evaluate({ expression: "document" }); + const { node } = await DOM.describeNode({ objectId: result.objectId }); + + await Assert.rejects( + DOM.resolveNode({ + backendNodeId: node.backendNodeId, + executionContextId: -1, + }), + /Node with given id does not belong to the document/, + "Fails for unknown executionContextId" + ); +}); diff --git a/remote/cdp/test/browser/dom/head.js b/remote/cdp/test/browser/dom/head.js new file mode 100644 index 0000000000..1a1c90fbf6 --- /dev/null +++ b/remote/cdp/test/browser/dom/head.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); diff --git a/remote/cdp/test/browser/emulation/browser.toml b/remote/cdp/test/browser/emulation/browser.toml new file mode 100644 index 0000000000..e5051abace --- /dev/null +++ b/remote/cdp/test/browser/emulation/browser.toml @@ -0,0 +1,25 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", +] + +["browser_setDeviceMetricsOverride.js"] + +["browser_setTouchEmulationEnabled.js"] + +["browser_setUserAgentOverride.js"] diff --git a/remote/cdp/test/browser/emulation/browser_setDeviceMetricsOverride.js b/remote/cdp/test/browser/emulation/browser_setDeviceMetricsOverride.js new file mode 100644 index 0000000000..eb8c314161 --- /dev/null +++ b/remote/cdp/test/browser/emulation/browser_setDeviceMetricsOverride.js @@ -0,0 +1,403 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const DOC_SMALL = toDataURL("<div>Hello world"); +const DOC_LARGE = toDataURL("<div style='margin: 150vh 0 0 150vw'>Hello world"); + +const MAX_WINDOW_SIZE = 10000000; + +function getContentDPR() { + info(`Retrieve device pixel ratio in content`); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + _ => content.browsingContext.overrideDPPX || content.devicePixelRatio + ); +} + +add_task(async function dimensionsSmallerThanWindow({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + + const overrideSettings = { + width: Math.floor(layoutViewport.clientWidth / 2), + height: Math.floor(layoutViewport.clientHeight / 3), + deviceScaleFactor: 1.0, + }; + + await Emulation.setDeviceMetricsOverride(overrideSettings); + await loadURL(DOC_SMALL); + + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + overrideSettings.width, + "Expected layout viewport width set" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + overrideSettings.height, + "Expected layout viewport height set" + ); + is( + updatedLayoutMetrics.contentSize.width, + overrideSettings.width, + "Expected content size width set" + ); + is( + updatedLayoutMetrics.contentSize.height, + overrideSettings.height, + "Expected content size height set" + ); +}); + +add_task(async function dimensionsLargerThanWindow({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_LARGE); + const { layoutViewport } = await Page.getLayoutMetrics(); + + const overrideSettings = { + width: layoutViewport.clientWidth * 2, + height: layoutViewport.clientHeight * 2, + deviceScaleFactor: 1.0, + }; + + await Emulation.setDeviceMetricsOverride(overrideSettings); + await loadURL(DOC_LARGE); + + const scrollbarSize = await getScrollbarSize(); + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + overrideSettings.width - scrollbarSize.width, + "Expected layout viewport width set" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + overrideSettings.height - scrollbarSize.height, + "Expected layout viewport height set" + ); +}); + +add_task(async function noSizeChangeForSameWidthAndHeight({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + + const overrideSettings = { + width: layoutViewport.clientWidth, + height: layoutViewport.clientHeight, + deviceScaleFactor: 1.0, + }; + + await Emulation.setDeviceMetricsOverride(overrideSettings); + await loadURL(DOC_SMALL); + + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + layoutViewport.clientWidth, + "Expected layout viewport width set" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + layoutViewport.clientHeight, + "Expected layout viewport height set" + ); +}); + +add_task(async function noWidthChangeWithZeroWidth({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + + const overrideSettings = { + width: 0, + height: Math.floor(layoutViewport.clientHeight / 3), + deviceScaleFactor: 1.0, + }; + + await Emulation.setDeviceMetricsOverride(overrideSettings); + await loadURL(DOC_SMALL); + + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + layoutViewport.clientWidth, + "Expected layout viewport width set" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + overrideSettings.height, + "Expected layout viewport height set" + ); +}); + +add_task(async function noHeightChangeWithZeroHeight({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + + const overrideSettings = { + width: Math.floor(layoutViewport.clientWidth / 2), + height: 0, + deviceScaleFactor: 1.0, + }; + + await Emulation.setDeviceMetricsOverride(overrideSettings); + await loadURL(DOC_SMALL); + + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + overrideSettings.width, + "Expected layout viewport width set" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + layoutViewport.clientHeight, + "Expected layout viewport height set" + ); +}); + +add_task(async function nosizeChangeWithZeroWidthAndHeight({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + + const overrideSettings = { + width: 0, + height: 0, + deviceScaleFactor: 1.0, + }; + + await Emulation.setDeviceMetricsOverride(overrideSettings); + await loadURL(DOC_SMALL); + + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + layoutViewport.clientWidth, + "Expected layout viewport width set" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + layoutViewport.clientHeight, + "Expected layout viewport height set" + ); +}); + +add_task(async function failsWithNegativeWidth({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + const ratio = await getContentDPR(); + + const overrideSettings = { + width: -1, + height: Math.floor(layoutViewport.clientHeight / 3), + deviceScaleFactor: 1.0, + }; + + await Assert.rejects( + Emulation.setDeviceMetricsOverride(overrideSettings), + err => + err.message.includes( + "Width and height values must be positive, not greater than 10000000" + ), + "Negative width raised error" + ); + + await loadURL(DOC_SMALL); + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + layoutViewport.clientWidth, + "Visible layout width hasn't been changed" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + layoutViewport.clientHeight, + "Visible layout height hasn't been changed" + ); + is(await getContentDPR(), ratio, "Device pixel ratio hasn't been changed"); +}); + +add_task(async function failsWithTooLargeWidth({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + const ratio = await getContentDPR(); + + const overrideSettings = { + width: MAX_WINDOW_SIZE + 1, + height: Math.floor(layoutViewport.clientHeight / 3), + deviceScaleFactor: 1.0, + }; + + await Assert.rejects( + Emulation.setDeviceMetricsOverride(overrideSettings), + err => + err.message.includes( + "Width and height values must be positive, not greater than 10000000" + ), + "Too large width raised error" + ); + + await loadURL(DOC_SMALL); + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + layoutViewport.clientWidth, + "Visible layout width hasn't been changed" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + layoutViewport.clientHeight, + "Visible layout height hasn't been changed" + ); + is(await getContentDPR(), ratio, "Device pixel ratio hasn't been changed"); +}); + +add_task(async function failsWithNegativeHeight({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + const ratio = await getContentDPR(); + + const overrideSettings = { + width: Math.floor(layoutViewport.clientWidth / 2), + height: -1, + deviceScaleFactor: 1.0, + }; + + await Assert.rejects( + Emulation.setDeviceMetricsOverride(overrideSettings), + err => + err.message.includes( + "Width and height values must be positive, not greater than 10000000" + ), + "Negative height raised error" + ); + await loadURL(DOC_SMALL); + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + layoutViewport.clientWidth, + "Visible layout width hasn't been changed" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + layoutViewport.clientHeight, + "Visible layout height hasn't been changed" + ); + is(await getContentDPR(), ratio, "Device pixel ratio hasn't been changed"); +}); + +add_task(async function failsWithTooLargeHeight({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + const ratio = await getContentDPR(); + + const overrideSettings = { + width: Math.floor(layoutViewport.clientWidth / 2), + height: MAX_WINDOW_SIZE + 1, + deviceScaleFactor: 1.0, + }; + await Assert.rejects( + Emulation.setDeviceMetricsOverride(overrideSettings), + err => + err.message.includes( + "Width and height values must be positive, not greater than 10000000" + ), + "Too large height raised error" + ); + + await loadURL(DOC_SMALL); + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + layoutViewport.clientWidth, + "Visible layout width hasn't been changed" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + layoutViewport.clientHeight, + "Visible layout height hasn't been changed" + ); + is(await getContentDPR(), ratio, "Device pixel ratio hasn't been changed"); +}); + +add_task(async function setDevicePixelRatio({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + const ratio_orig = await getContentDPR(); + + const overrideSettings = { + width: layoutViewport.clientWidth, + height: layoutViewport.clientHeight, + deviceScaleFactor: ratio_orig * 2, + }; + + // Set a custom pixel ratio + await Emulation.setDeviceMetricsOverride(overrideSettings); + await loadURL(DOC_SMALL); + + is(await getContentDPR(), ratio_orig * 2, "Expected device pixel ratio set"); +}); + +add_task(async function failsWithNegativeRatio({ client }) { + const { Emulation, Page } = client; + + await loadURL(DOC_SMALL); + const { layoutViewport } = await Page.getLayoutMetrics(); + const ratio = await getContentDPR(); + + const overrideSettings = { + width: Math.floor(layoutViewport.clientHeight / 2), + height: Math.floor(layoutViewport.clientHeight / 3), + deviceScaleFactor: -1, + }; + + await Assert.rejects( + Emulation.setDeviceMetricsOverride(overrideSettings), + err => err.message.includes("deviceScaleFactor: must be positive"), + "Negative device scale factor raised error" + ); + + await loadURL(DOC_SMALL); + const updatedLayoutMetrics = await Page.getLayoutMetrics(); + + is( + updatedLayoutMetrics.layoutViewport.clientWidth, + layoutViewport.clientWidth, + "Visible layout width hasn't been changed" + ); + is( + updatedLayoutMetrics.layoutViewport.clientHeight, + layoutViewport.clientHeight, + "Visible layout height hasn't been changed" + ); + is(await getContentDPR(), ratio, "Device pixel ratio hasn't been changed"); +}); diff --git a/remote/cdp/test/browser/emulation/browser_setTouchEmulationEnabled.js b/remote/cdp/test/browser/emulation/browser_setTouchEmulationEnabled.js new file mode 100644 index 0000000000..1d4efacff1 --- /dev/null +++ b/remote/cdp/test/browser/emulation/browser_setTouchEmulationEnabled.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const DOC_TOUCH = toDataURL("<div>Hello world"); + +add_task(async function invalidEnabledType({ client }) { + const { Emulation } = client; + + for (const enabled of [null, "", 1, [], {}]) { + await Assert.rejects( + Emulation.setTouchEmulationEnabled({ enabled }), + /enabled: boolean value expected/, + `Fails with invalid type: ${enabled}` + ); + } +}); + +add_task(async function enableAndDisable({ client }) { + const { Emulation, Runtime } = client; + const url = toDataURL("<p>foo"); + + await enableRuntime(client); + + let contextCreated = Runtime.executionContextCreated(); + await loadURL(url); + let result = await contextCreated; + await assertTouchEnabled(client, result.context, false); + + await Emulation.setTouchEmulationEnabled({ enabled: true }); + contextCreated = Runtime.executionContextCreated(); + await loadURL(url); + result = await contextCreated; + await assertTouchEnabled(client, result.context, true); + + await Emulation.setTouchEmulationEnabled({ enabled: false }); + contextCreated = Runtime.executionContextCreated(); + await loadURL(url); + result = await contextCreated; + await assertTouchEnabled(client, result.context, false); +}); + +add_task(async function receiveTouchEventsWhenEnabled({ client }) { + const { Emulation, Runtime } = client; + + await enableRuntime(client); + + await Emulation.setTouchEmulationEnabled({ enabled: true }); + const contextCreated = Runtime.executionContextCreated(); + await loadURL(DOC_TOUCH); + const { context } = await contextCreated; + + await assertTouchEnabled(client, context, true); + + const { result } = await evaluate(client, context.id, () => { + return new Promise(resolve => { + window.ontouchstart = () => { + resolve(true); + }; + window.dispatchEvent(new Event("touchstart")); + resolve(false); + }); + }); + is(result.value, true, "Received touch event"); +}); + +async function assertTouchEnabled(client, context, expectedStatus) { + const { result } = await evaluate(client, context.id, () => { + return "ontouchstart" in window; + }); + + if (expectedStatus) { + ok(result.value, "Touch emulation enabled"); + } else { + ok(!result.value, "Touch emulation disabled"); + } +} diff --git a/remote/cdp/test/browser/emulation/browser_setUserAgentOverride.js b/remote/cdp/test/browser/emulation/browser_setUserAgentOverride.js new file mode 100644 index 0000000000..a0f292435d --- /dev/null +++ b/remote/cdp/test/browser/emulation/browser_setUserAgentOverride.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const DOC = toDataURL(`<script>document.write(navigator.userAgent);</script>`); + +add_task(async function invalidPlatform({ client }) { + const { Emulation } = client; + const userAgent = "Mozilla/5.0 (rv: 23) Romanesco/42.0\n"; + + for (const platform of [null, true, 1, [], {}]) { + await Assert.rejects( + Emulation.setUserAgentOverride({ userAgent, platform }), + /platform: string value expected/, + `Fails with invalid type: ${platform}` + ); + } +}); + +add_task(async function setAndResetUserAgent({ client }) { + const { Emulation } = client; + const userAgent = "Mozilla/5.0 (rv: 23) Romanesco/42.0"; + + await loadURL(DOC); + const originalUserAgent = await getNavigatorProperty("userAgent"); + + isnot(originalUserAgent, userAgent, "Custom user agent hasn't been set"); + + await Emulation.setUserAgentOverride({ userAgent }); + await loadURL(DOC); + is( + await getNavigatorProperty("userAgent"), + userAgent, + "Custom user agent has been set" + ); + + await Emulation.setUserAgentOverride({ userAgent: "" }); + await loadURL(DOC); + is( + await getNavigatorProperty("userAgent"), + originalUserAgent, + "Custom user agent has been reset" + ); +}); + +add_task(async function invalidUserAgent({ client }) { + const { Emulation } = client; + const userAgent = "Mozilla/5.0 (rv: 23) Romanesco/42.0\n"; + + await loadURL(DOC); + isnot( + await getNavigatorProperty("userAgent"), + userAgent, + "Custom user agent hasn't been set" + ); + + await Assert.rejects( + Emulation.setUserAgentOverride({ userAgent }), + err => err.message.includes("Invalid characters found in userAgent"), + "Invalid user agent format raised error" + ); +}); + +add_task(async function setAndResetPlatform({ client }) { + const { Emulation } = client; + const userAgent = "Mozilla/5.0 (rv: 23) Romanesco/42.0"; + const platform = "foobar"; + + await loadURL(DOC); + const originalUserAgent = await getNavigatorProperty("userAgent"); + const originalPlatform = await getNavigatorProperty("platform"); + + isnot(userAgent, originalUserAgent, "Custom user agent hasn't been set"); + isnot(platform, originalPlatform, "Custom platform hasn't been set"); + + await Emulation.setUserAgentOverride({ userAgent, platform }); + await loadURL(DOC); + is( + await getNavigatorProperty("userAgent"), + userAgent, + "Custom user agent has been set" + ); + is( + await getNavigatorProperty("platform"), + platform, + "Custom platform has been set" + ); + + await Emulation.setUserAgentOverride({ userAgent: "", platform: "" }); + await loadURL(DOC); + is( + await getNavigatorProperty("userAgent"), + originalUserAgent, + "Custom user agent has been reset" + ); + is( + await getNavigatorProperty("platform"), + originalPlatform, + "Custom platform has been reset" + ); +}); + +add_task(async function notSetForNewContext({ client }) { + const { Emulation, Target } = client; + const userAgent = "Mozilla/5.0 (rv: 23) Romanesco/42.0"; + const platform = "foobar"; + + await Emulation.setUserAgentOverride({ userAgent, platform }); + await loadURL(DOC); + is( + await getNavigatorProperty("userAgent"), + userAgent, + "Custom user agent has been set" + ); + is( + await getNavigatorProperty("platform"), + platform, + "Custom platform has been set" + ); + + await openTab(Target, { activate: true }); + + isnot( + await getNavigatorProperty("userAgent"), + userAgent, + "Custom user agent has not been set" + ); + isnot( + await getNavigatorProperty("platform"), + platform, + "Custom platform has not been set" + ); +}); + +async function getNavigatorProperty(prop) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [prop], _prop => { + return content.navigator[_prop]; + }); +} diff --git a/remote/cdp/test/browser/emulation/head.js b/remote/cdp/test/browser/emulation/head.js new file mode 100644 index 0000000000..1a1c90fbf6 --- /dev/null +++ b/remote/cdp/test/browser/emulation/head.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); diff --git a/remote/cdp/test/browser/fetch/browser.toml b/remote/cdp/test/browser/fetch/browser.toml new file mode 100644 index 0000000000..bf26c824a5 --- /dev/null +++ b/remote/cdp/test/browser/fetch/browser.toml @@ -0,0 +1,21 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", +] + +["browser_disable.js"] diff --git a/remote/cdp/test/browser/fetch/browser_disable.js b/remote/cdp/test/browser/fetch/browser_disable.js new file mode 100644 index 0000000000..01c65fb855 --- /dev/null +++ b/remote/cdp/test/browser/fetch/browser_disable.js @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function fetchDomainDisabled({ client }) { + const { Fetch } = client; + + await Fetch.disable(); + ok("Disabling Fetch domain successful"); +}); diff --git a/remote/cdp/test/browser/fetch/head.js b/remote/cdp/test/browser/fetch/head.js new file mode 100644 index 0000000000..1a1c90fbf6 --- /dev/null +++ b/remote/cdp/test/browser/fetch/head.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); diff --git a/remote/cdp/test/browser/head.js b/remote/cdp/test/browser/head.js new file mode 100644 index 0000000000..acf2216194 --- /dev/null +++ b/remote/cdp/test/browser/head.js @@ -0,0 +1,629 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// window.RemoteAgent is a simple object set in browser.js, and importing +// RemoteAgent conflicts with that. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { RemoteAgent } = ChromeUtils.importESModule( + "chrome://remote/content/components/RemoteAgent.sys.mjs" +); +const { RemoteAgentError } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/Error.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); +const { Stream } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/StreamRegistry.sys.mjs" +); + +const { getTimeoutMultiplier } = ChromeUtils.importESModule( + "chrome://remote/content/shared/AppInfo.sys.mjs" +); + +const TIMEOUT_MULTIPLIER = getTimeoutMultiplier(); +const TIMEOUT_EVENTS = 1000 * TIMEOUT_MULTIPLIER; + +/* +add_task() is overriden to setup and teardown a test environment +making it easier to write browser-chrome tests for the remote +debugger. + +Before the task is run, the nsIRemoteAgent listener is started and +a CDP client is connected to it. A new tab is also added. These +three things are exposed to the provided task like this: + + add_task(async function testName(client, CDP, tab) { + // client is an instance of the CDP class + // CDP is ./chrome-remote-interface.js + // tab is a fresh tab, destroyed after the test + }); + +Also target discovery is getting enabled, which means that targetCreated, +targetDestroyed, and targetInfoChanged events will be received by the client. + +add_plain_task() may be used to write test tasks without the implicit +setup and teardown described above. +*/ + +const add_plain_task = add_task.bind(this); + +this.add_task = function (taskFn, opts = {}) { + const { + createTab = true, // By default run each test in its own tab + } = opts; + + const fn = async function () { + let client, tab, target; + + try { + const CDP = await getCDP(); + + if (createTab) { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + const tabId = TabManager.getIdForBrowser(tab.linkedBrowser); + + const targets = await CDP.List(); + target = targets.find(target => target.id === tabId); + } + + client = await CDP({ target }); + info("CDP client instantiated"); + + // Bug 1605722 - Workaround to not hang when waiting for Target events + await getDiscoveredTargets(client.Target); + + await taskFn({ client, CDP, tab }); + + if (createTab) { + // taskFn may resolve within a tick after opening a new tab. + // We shouldn't remove the newly opened tab in the same tick. + // Wait for the next tick here. + await TestUtils.waitForTick(); + BrowserTestUtils.removeTab(tab); + } + } catch (e) { + // Display better error message with the server side stacktrace + // if an error happened on the server side: + if (e.response) { + throw RemoteAgentError.fromJSON(e.response); + } else { + throw e; + } + } finally { + if (client) { + await client.close(); + info("CDP client closed"); + } + + // Close any additional tabs, so that only a single tab remains open + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } + } + }; + + Object.defineProperty(fn, "name", { value: taskFn.name, writable: false }); + add_plain_task(fn); +}; + +/** + * Create a test document in an invisible window. + * This window will be automatically closed on test teardown. + */ +function createTestDocument() { + const browser = Services.appShell.createWindowlessBrowser(true); + registerCleanupFunction(() => browser.close()); + + // Create a system principal content viewer to ensure there is a valid + // empty document using system principal and avoid any wrapper issues + // when using document's JS Objects. + const webNavigation = browser.docShell.QueryInterface(Ci.nsIWebNavigation); + const system = Services.scriptSecurityManager.getSystemPrincipal(); + webNavigation.createAboutBlankDocumentViewer(system, system); + + return webNavigation.document; +} + +/** + * Retrieve an intance of CDP object from chrome-remote-interface library + */ +async function getCDP() { + // Instantiate a background test document in order to load the library + // as in a web page + const document = createTestDocument(); + + const window = document.defaultView.wrappedJSObject; + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/chrome-remote-interface.js", + window + ); + + // Implements `criRequest` to be called by chrome-remote-interface + // library in order to do the cross-domain http request, which, + // in a regular Web page, is impossible. + window.criRequest = (options, callback) => { + const { path } = options; + const url = `http://${RemoteAgent.host}:${RemoteAgent.port}${path}`; + const xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + + // Prevent "XML Parsing Error: syntax error" error messages + xhr.overrideMimeType("text/plain"); + + xhr.send(null); + xhr.onload = () => callback(null, xhr.responseText); + xhr.onerror = e => callback(e, null); + }; + + return window.CDP; +} + +async function getScrollbarSize() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const scrollbarHeight = {}; + const scrollbarWidth = {}; + + content.windowUtils.getScrollbarSize( + false, + scrollbarWidth, + scrollbarHeight + ); + return { + width: scrollbarWidth.value, + height: scrollbarHeight.value, + }; + }); +} + +function getTargets(CDP) { + return new Promise((resolve, reject) => { + CDP.List(null, (err, targets) => { + if (err) { + reject(err); + return; + } + resolve(targets); + }); + }); +} + +// Wait for all Target.targetCreated events. One for each tab. +async function getDiscoveredTargets(Target, options = {}) { + const { discover = true, filter } = options; + + const targets = []; + const unsubscribe = Target.targetCreated(target => { + targets.push(target.targetInfo); + }); + + await Target.setDiscoverTargets({ + discover, + filter, + }).finally(() => unsubscribe()); + + return targets; +} + +async function openTab(Target, options = {}) { + const { activate = false } = options; + + info("Create a new tab and wait for the target to be created"); + const targetCreated = Target.targetCreated(); + const newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + const { targetInfo } = await targetCreated; + + is(targetInfo.type, "page"); + + if (activate) { + await Target.activateTarget({ + targetId: targetInfo.targetId, + }); + info(`New tab with target id ${targetInfo.targetId} created and activated`); + } else { + info(`New tab with target id ${targetInfo.targetId} created`); + } + + return { targetInfo, newTab }; +} + +async function openWindow(Target, options = {}) { + const { activate = false } = options; + + info("Create a new window and wait for the target to be created"); + const targetCreated = Target.targetCreated(); + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const newTab = newWindow.gBrowser.selectedTab; + const { targetInfo } = await targetCreated; + is(targetInfo.type, "page"); + + if (activate) { + await Target.activateTarget({ + targetId: targetInfo.targetId, + }); + info( + `New window with target id ${targetInfo.targetId} created and activated` + ); + } else { + info(`New window with target id ${targetInfo.targetId} created`); + } + + return { targetInfo, newWindow, newTab }; +} + +/** Creates a data URL for the given source document. */ +function toDataURL(src, doctype = "html") { + let doc, mime; + switch (doctype) { + case "html": + mime = "text/html;charset=utf-8"; + doc = `<!doctype html>\n<meta charset=utf-8>\n${src}`; + break; + default: + throw new Error("Unexpected doctype: " + doctype); + } + + return `data:${mime},${encodeURIComponent(doc)}`; +} + +function convertArgument(arg) { + if (typeof arg === "bigint") { + return { unserializableValue: `${arg.toString()}n` }; + } + if (Object.is(arg, -0)) { + return { unserializableValue: "-0" }; + } + if (Object.is(arg, Infinity)) { + return { unserializableValue: "Infinity" }; + } + if (Object.is(arg, -Infinity)) { + return { unserializableValue: "-Infinity" }; + } + if (Object.is(arg, NaN)) { + return { unserializableValue: "NaN" }; + } + + return { value: arg }; +} + +async function evaluate(client, contextId, pageFunction, ...args) { + const { Runtime } = client; + + if (typeof pageFunction === "string") { + return Runtime.evaluate({ + expression: pageFunction, + contextId, + returnByValue: true, + awaitPromise: true, + }); + } else if (typeof pageFunction === "function") { + return Runtime.callFunctionOn({ + functionDeclaration: pageFunction.toString(), + executionContextId: contextId, + arguments: args.map(convertArgument), + returnByValue: true, + awaitPromise: true, + }); + } + throw new Error("pageFunction: expected 'string' or 'function'"); +} + +/** + * Load a given URL in the currently selected tab + */ +async function loadURL(url, expectedURL = undefined) { + expectedURL = expectedURL || url; + + const browser = gBrowser.selectedTab.linkedBrowser; + const loaded = BrowserTestUtils.browserLoaded(browser, true, expectedURL); + + BrowserTestUtils.startLoadingURIString(browser, url); + await loaded; +} + +/** + * Enable the Runtime domain + */ +async function enableRuntime(client) { + const { Runtime } = client; + + // Enable watching for new execution context + await Runtime.enable(); + info("Runtime domain has been enabled"); + + // Calling Runtime.enable will emit executionContextCreated for the existing contexts + const { context } = await Runtime.executionContextCreated(); + ok(!!context.id, "The execution context has an id"); + ok(context.auxData.isDefault, "The execution context is the default one"); + ok(!!context.auxData.frameId, "The execution context has a frame id set"); + + return context; +} + +/** + * Retrieve the value of a property on the content window. + */ +function getContentProperty(prop) { + info(`Retrieve ${prop} on the content window`); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [prop], + _prop => content[_prop] + ); +} + +/** + * Retrieve all frames for the current tab as flattened list. + * + * @returns {Map<number, Frame>} + * Flattened list of frames as Map + */ +async function getFlattenedFrameTree(client) { + const { Page } = client; + + function flatten(frames) { + return frames.reduce((result, current) => { + result.set(current.frame.id, current.frame); + if (current.childFrames) { + const frames = flatten(current.childFrames); + result = new Map([...result, ...frames]); + } + return result; + }, new Map()); + } + + const { frameTree } = await Page.getFrameTree(); + return flatten(Array(frameTree)); +} + +/** + * Return a new promise, which resolves after ms have been elapsed + */ +function timeoutPromise(ms) { + return new Promise(resolve => { + window.setTimeout(resolve, ms); + }); +} + +/** Fail a test. */ +function fail(message) { + ok(false, message); +} + +/** + * Create a stream with the specified contents. + * + * @param {string} contents + * Contents of the file. + * @param {object} options + * @param {string=} options.path + * Path of the file. Defaults to the temporary directory. + * @param {boolean=} options.remove + * If true, automatically remove the file after the test. Defaults to true. + * + * @returns {Promise<Stream>} + */ +async function createFileStream(contents, options = {}) { + let { path = null, remove = true } = options; + + if (!path) { + path = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "remote-agent.txt" + ); + } + + await IOUtils.writeUTF8(path, contents); + + const stream = new Stream(path); + if (remove) { + registerCleanupFunction(() => stream.destroy()); + } + + return stream; +} + +async function throwScriptError(options = {}) { + const { inContent = true } = options; + + const addScriptErrorInternal = options => { + const { + flag = Ci.nsIScriptError.errorFlag, + innerWindowId = content.windowGlobalChild.innerWindowId, + } = options; + + const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.initWithWindowID( + options.text, + options.sourceName || "sourceName", + null, + options.lineNumber || 0, + options.columnNumber || 0, + flag, + options.category || "javascript", + innerWindowId + ); + Services.console.logMessage(scriptError); + }; + + if (inContent) { + SpecialPowers.spawn( + gBrowser.selectedBrowser, + [options], + addScriptErrorInternal + ); + } else { + options.innerWindowId = window.windowGlobalChild.innerWindowId; + addScriptErrorInternal(options); + } +} + +class RecordEvents { + /** + * A timeline of events chosen by calls to `addRecorder`. + * Call `configure`` for each client event you want to record. + * Then `await record(someTimeout)` to record a timeline that you + * can make assertions about. + * + * const history = new RecordEvents(expectedNumberOfEvents); + * + * history.addRecorder({ + * event: Runtime.executionContextDestroyed, + * eventName: "Runtime.executionContextDestroyed", + * messageFn: payload => { + * return `Received Runtime.executionContextDestroyed for id ${payload.executionContextId}`; + * }, + * }); + * + * + * @param {number} total + * Number of expected events. Stop recording when this number is exceeded. + * + */ + constructor(total) { + this.events = []; + this.promises = new Set(); + this.subscriptions = new Set(); + this.total = total; + } + + /** + * Configure an event to be recorded and logged. + * The recording stops once we accumulate more than the expected + * total of all configured events. + * + * @param {object} options + * @param {CDPEvent} options.event + * https://github.com/cyrus-and/chrome-remote-interface#clientdomaineventcallback + * @param {string} options.eventName + * Name to use for reporting. + * @param {Function=} options.callback + * ({ eventName, payload }) => {} to be called when each event is received + * @param {function(payload):string=} options.messageFn + */ + addRecorder(options = {}) { + const { + event, + eventName, + messageFn = () => `Recorded ${eventName}`, + callback, + } = options; + + const promise = new Promise(resolve => { + const unsubscribe = event(payload => { + info(messageFn(payload)); + this.events.push({ eventName, payload, index: this.events.length }); + callback?.({ eventName, payload, index: this.events.length - 1 }); + if (this.events.length > this.total) { + this.subscriptions.delete(unsubscribe); + unsubscribe(); + resolve(this.events); + } + }); + this.subscriptions.add(unsubscribe); + }); + + this.promises.add(promise); + } + + /** + * Register a promise to await while recording the timeline. The returned + * callback resolves the registered promise and adds `step` + * to the timeline, along with an associated payload, if provided. + * + * @param {string} step + * @returns {Function} callback + */ + addPromise(step) { + let callback; + const promise = new Promise(resolve => { + callback = value => { + resolve(); + info(`Recorded ${step}`); + this.events.push({ + eventName: step, + payload: value, + index: this.events.length, + }); + return value; + }; + }); + + this.promises.add(promise); + return callback; + } + + /** + * Record events until we hit the timeout or the expected total is exceeded. + * + * @param {number=} timeout + * Timeout in milliseconds. Defaults to 1000. + * + * @returns {Array<{ eventName, payload, index }>} Recorded events + */ + async record(timeout = TIMEOUT_EVENTS) { + await Promise.race([Promise.all(this.promises), timeoutPromise(timeout)]); + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + return this.events; + } + + /** + * Filter events based on predicate + * + * @param {Function} predicate + * + * @returns {Array<{ eventName, payload, index }>} + * The list of events matching the filter. + */ + filter(predicate) { + return this.events.filter(predicate); + } + + /** + * Find first occurrence of the given event. + * + * @param {string} eventName + * + * @returns {{ eventName, payload, index }} The event, if any. + */ + findEvent(eventName) { + const event = this.events.find(el => el.eventName == eventName); + if (event) { + return event; + } + return {}; + } + + /** + * Find given events. + * + * @param {string} eventName + * + * @returns {Array<{ eventName, payload, index }>} + * The events, if any. + */ + findEvents(eventName) { + return this.events.filter(event => event.eventName == eventName); + } + + /** + * Find index of first occurrence of the given event. + * + * @param {string} eventName + * + * @returns {number} The event index, -1 if not found. + */ + indexOf(eventName) { + const event = this.events.find(el => el.eventName == eventName); + if (event) { + return event.index; + } + return -1; + } +} diff --git a/remote/cdp/test/browser/input/browser.toml b/remote/cdp/test/browser/input/browser.toml new file mode 100644 index 0000000000..b103734fc0 --- /dev/null +++ b/remote/cdp/test/browser/input/browser.toml @@ -0,0 +1,31 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", + "doc_events.html", + "doc_dispatchKeyEvent_race.html", +] + +["browser_dispatchKeyEvent.js"] + +["browser_dispatchKeyEvent_events.js"] +https_first_disabled = true + +["browser_dispatchKeyEvent_race.js"] +https_first_disabled = true + +["browser_dispatchMouseEvent.js"] diff --git a/remote/cdp/test/browser/input/browser_dispatchKeyEvent.js b/remote/cdp/test/browser/input/browser_dispatchKeyEvent.js new file mode 100644 index 0000000000..e2b214503b --- /dev/null +++ b/remote/cdp/test/browser/input/browser_dispatchKeyEvent.js @@ -0,0 +1,169 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testTypingPrintableCharacters({ client }) { + await setupForInput(toDataURL("<input>")); + const { Input } = client; + + info("Write 'h'"); + await sendTextKey(Input, "h"); + await checkInputContent("h", 1); + + info("Write 'H'"); + await sendTextKey(Input, "H"); + await checkInputContent("hH", 2); + + info("Send char type event for char [’]"); + await Input.dispatchKeyEvent({ + type: "char", + modifiers: 0, + key: "’", + }); + await checkInputContent("hH’", 3); +}); + +add_task(async function testArrowKeys({ client }) { + await setupForInput(toDataURL("<input>")); + const { Input } = client; + + await sendText(Input, "hH’"); + info("Send Left"); + await sendRawKey(Input, "ArrowLeft"); + await checkInputContent("hH’", 2); + + info("Write 'a'"); + await sendTextKey(Input, "a"); + await checkInputContent("hHa’", 3); + + info("Send Left"); + await sendRawKey(Input, "ArrowLeft"); + await checkInputContent("hHa’", 2); + + info("Send Left"); + await sendRawKey(Input, "ArrowLeft"); + await checkInputContent("hHa’", 1); + + info("Write 'a'"); + await sendTextKey(Input, "a"); + await checkInputContent("haHa’", 2); + + info("Send ALT/CONTROL + Right"); + const modCode = AppInfo.isMac ? alt : ctrl; + const modKey = AppInfo.isMac ? "Alt" : "Control"; + await dispatchKeyEvent(Input, modKey, "rawKeyDown", modCode); + await dispatchKeyEvent(Input, "ArrowRight", "rawKeyDown", modCode); + await dispatchKeyEvent(Input, "ArrowRight", "keyUp"); + await dispatchKeyEvent(Input, modKey, "keyUp"); + await checkInputContent("haHa’", 5); +}); + +add_task(async function testBackspace({ client }) { + await setupForInput(toDataURL("<input>")); + const { Input } = client; + + await sendText(Input, "haHa’"); + + info("Delete every character in the input"); + await checkBackspace(Input, "haHa"); + await checkBackspace(Input, "haH"); + await checkBackspace(Input, "ha"); + await checkBackspace(Input, "h"); + await checkBackspace(Input, ""); +}); + +add_task(async function testShiftSelect({ client }) { + await setupForInput(toDataURL("<input>")); + const { Input } = client; + await resetInput("word 2 word3"); + + info("Send Shift + Left (select one char to the left)"); + await dispatchKeyEvent(Input, "Shift", "rawKeyDown", shift); + await sendRawKey(Input, "ArrowLeft", shift); + await sendRawKey(Input, "ArrowLeft", shift); + await sendRawKey(Input, "ArrowLeft", shift); + info("(deleteContentBackward)"); + await checkBackspace(Input, "word 2 wo"); + await dispatchKeyEvent(Input, "Shift", "keyUp"); + + await resetInput("word 2 wo"); + info("Send Shift + Left (select one char to the left)"); + await dispatchKeyEvent(Input, "Shift", "rawKeyDown", shift); + await sendRawKey(Input, "ArrowLeft", shift); + await sendRawKey(Input, "ArrowLeft", shift); + await sendTextKey(Input, "H"); + await checkInputContent("word 2 H", 8); + await dispatchKeyEvent(Input, "Shift", "keyUp"); +}); + +add_task(async function testSelectWord({ client }) { + await setupForInput(toDataURL("<input>")); + const { Input } = client; + await resetInput("word 2 word3"); + + info("Send Shift + Ctrl/Alt + Left (select one word to the left)"); + const { primary, primaryKey } = keyForPlatform(); + const combined = shift | primary; + await dispatchKeyEvent(Input, "Shift", "rawKeyDown", shift); + await dispatchKeyEvent(Input, primaryKey, "rawKeyDown", combined); + await sendRawKey(Input, "ArrowLeft", combined); + await sendRawKey(Input, "ArrowLeft", combined); + await dispatchKeyEvent(Input, "Shift", "keyUp", primary); + await dispatchKeyEvent(Input, primaryKey, "keyUp"); + info("(deleteContentBackward)"); + await checkBackspace(Input, "word "); +}); + +add_task(async function testSelectDelete({ client }) { + await setupForInput(toDataURL("<input>")); + const { Input } = client; + await resetInput("word 2 word3"); + + info("Send Ctrl/Alt + Backspace (deleteWordBackward)"); + const { primary, primaryKey } = keyForPlatform(); + await dispatchKeyEvent(Input, primaryKey, "rawKeyDown", primary); + await checkBackspace(Input, "word 2 ", primary); + await dispatchKeyEvent(Input, primaryKey, "keyUp"); + + await resetInput("word 2 "); + await sendText(Input, "word4"); + await sendRawKey(Input, "ArrowLeft"); + await sendRawKey(Input, "ArrowLeft"); + await checkInputContent("word 2 word4", 10); + + if (AppInfo.isMac) { + info("Send Meta + Backspace (deleteSoftLineBackward)"); + await dispatchKeyEvent(Input, "Meta", "rawKeyDown", meta); + await sendRawKey(Input, "Backspace", meta); + await dispatchKeyEvent(Input, "Meta", "keyUp"); + await checkInputContent("d4", 0); + } +}); + +add_task(async function testCtrlShiftArrows({ client }) { + await loadURL( + toDataURL('<select multiple size="3"><option>a<option>b<option>c</select>') + ); + const { Input } = client; + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const select = content.document.querySelector("select"); + select.selectedIndex = 0; + select.focus(); + }); + + const combined = shift | ctrl; + await dispatchKeyEvent(Input, "Control", "rawKeyDown", shift); + await dispatchKeyEvent(Input, "Shift", "rawKeyDown", combined); + await sendRawKey(Input, "ArrowDown", combined); + await dispatchKeyEvent(Input, "Control", "keyUp", shift); + await dispatchKeyEvent(Input, "Shift", "keyUp"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const select = content.document.querySelector("select"); + ok(select[0].selected, "First option should be selected"); + ok(select[1].selected, "Second option should be selected"); + ok(!select[2].selected, "Third option should not be selected"); + }); +}); diff --git a/remote/cdp/test/browser/input/browser_dispatchKeyEvent_events.js b/remote/cdp/test/browser/input/browser_dispatchKeyEvent_events.js new file mode 100644 index 0000000000..195043019e --- /dev/null +++ b/remote/cdp/test/browser/input/browser_dispatchKeyEvent_events.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_URL = + "https://example.com/browser/remote/cdp/test/browser/input/doc_events.html"; + +add_task(async function testShiftEvents({ client }) { + await setupForInput(PAGE_URL); + const { Input } = client; + await resetEvents(); + + await withModifier(Input, "Shift", "shift", "A"); + await checkInputContent("A", 1); + let events = await getEvents(); + checkEvent(events[0], "keydown", "Shift", "shift", true); + checkEvent(events[1], "keydown", "A", "shift", true); + checkEvent(events[2], "keypress", "A", "shift", true); + checkProperties({ data: "A", inputType: "insertText" }, events[3]); + checkEvent(events[4], "keyup", "A", "shift", true); + checkEvent(events[5], "keyup", "Shift", "shift", false); + await resetEvents(); + + await withModifier(Input, "Shift", "shift", "Enter"); + events = await getEvents(); + checkEvent(events[2], "keypress", "Enter", "shift", true); + await resetEvents(); + + await withModifier(Input, "Shift", "shift", "Tab"); + events = await getEvents(); + checkEvent(events[1], "keydown", "Tab", "shift", true); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const input = content.document.querySelector("input"); + isnot(input, content.document.activeElement, "input should lose focus"); + }); +}); + +add_task(async function testAltEvents({ client }) { + await setupForInput(PAGE_URL); + const { Input } = client; + + await withModifier(Input, "Alt", "alt", "a"); + if (AppInfo.isMac) { + await checkInputContent("a", 1); + } else { + await checkInputContent("", 0); + } + let events = await getEvents(); + checkEvent(events[1], "keydown", "a", "alt", true); + checkEvent(events[events.length - 1], "keyup", "Alt", "alt", false); +}); + +add_task(async function testControlEvents({ client }) { + await setupForInput(PAGE_URL); + const { Input } = client; + + await withModifier(Input, "Control", "ctrl", "`"); + let events = await getEvents(); + // no keypress or input event + checkEvent(events[1], "keydown", "`", "ctrl", true); + checkEvent(events[events.length - 1], "keyup", "Control", "ctrl", false); +}); + +add_task(async function testMetaEvents({ client }) { + if (!AppInfo.isMac) { + return; + } + await setupForInput(PAGE_URL); + const { Input } = client; + + await withModifier(Input, "Meta", "meta", "a"); + let events = await getEvents(); + // no keypress or input event + checkEvent(events[1], "keydown", "a", "meta", true); + checkEvent(events[events.length - 1], "keyup", "Meta", "meta", false); +}); diff --git a/remote/cdp/test/browser/input/browser_dispatchKeyEvent_race.js b/remote/cdp/test/browser/input/browser_dispatchKeyEvent_race.js new file mode 100644 index 0000000000..ee1cd5be39 --- /dev/null +++ b/remote/cdp/test/browser/input/browser_dispatchKeyEvent_race.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Here we test that the `dispatchKeyEvent` API resolves after all the synchronous event +// handlers from the content page have been flushed. +// +// Say the content page has an event handler such as: +// +// el.addEventListener("keyup", () => { +// doSomeVeryLongProcessing(); // <- takes a long time but is synchronous! +// window.myVariable = "newValue"; +// }); +// +// And imagine this is tested via: +// +// await Input.dispatchKeyEvent(...); +// const myVariable = await Runtime.evaluate({ expression: "window.myVariable" }); +// equals(myVariable, "newValue"); +// +// In order for this to work, we need to be sure that `await Input.dispatchKeyEvent` +// resolves only after the content page flushed the event handlers (and +// `window.myVariable = "newValue"` was executed). +// +// This can be racy because Input.dispatchKeyEvent and window.myVariable = "newValue" run +// in different processes. + +const PAGE_URL = + "https://example.com/browser/remote/cdp/test/browser/input/doc_dispatchKeyEvent_race.html"; + +add_task(async function ({ client }) { + await loadURL(PAGE_URL); + + const { Input, Runtime } = client; + + // Need an enabled Runtime domain to run evaluate. + info("Enable the Runtime domain"); + await Runtime.enable(); + const { context } = await Runtime.executionContextCreated(); + + info("Focus the input on the page"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const input = content.document.querySelector("input"); + input.focus(); + is(input, content.document.activeElement, "Input should be focused"); + }); + + // See doc_input_dispatchKeyEvent_race.html + // The page listens to `input` events to update a property on window. + // We will check that the value is updated as soon dispatchKeyEvent has resolved. + await checkWindowTestValue("initial-value", context.id, Runtime); + + info("Write 'hhhhhh' ('h' times 6)"); + for (let i = 0; i < 6; i++) { + await dispatchKeyEvent(Input, "h", 72, "keyDown"); + await dispatchKeyEvent(Input, "h", 72, "keyUp"); + } + await checkWindowTestValue("hhhhhh", context.id, Runtime); + + info("Write 'aaaaaa' with 6 consecutive keydown and one keyup"); + await Promise.all([ + dispatchKeyEvent(Input, "a", 65, "keyDown"), + dispatchKeyEvent(Input, "a", 65, "keyDown"), + dispatchKeyEvent(Input, "a", 65, "keyDown"), + dispatchKeyEvent(Input, "a", 65, "keyDown"), + dispatchKeyEvent(Input, "a", 65, "keyDown"), + dispatchKeyEvent(Input, "a", 65, "keyDown"), + ]); + await dispatchKeyEvent(Input, "a", 65, "keyUp"); + await checkWindowTestValue("hhhhhhaaaaaa", context.id, Runtime); +}); + +function dispatchKeyEvent(Input, key, keyCode, type, modifiers = 0) { + info(`Send ${type} for key ${key}`); + return Input.dispatchKeyEvent({ + type, + modifiers, + windowsVirtualKeyCode: keyCode, + key, + }); +} + +async function checkWindowTestValue(expected, contextId, Runtime) { + info("Retrieve the value of `window.testValue` in the test page"); + const { result } = await Runtime.evaluate({ + contextId, + expression: "window.testValue", + }); + + is(result.value, expected, "Content window test value is correct"); +} diff --git a/remote/cdp/test/browser/input/browser_dispatchMouseEvent.js b/remote/cdp/test/browser/input/browser_dispatchMouseEvent.js new file mode 100644 index 0000000000..9ab2b74a67 --- /dev/null +++ b/remote/cdp/test/browser/input/browser_dispatchMouseEvent.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_URL = + "https://example.com/browser/remote/cdp/test/browser/input/doc_events.html"; + +add_task(async function testPressedReleasedAndClick({ client }) { + const { Input } = client; + + await loadURL(PAGE_URL); + await resetEvents(); + + info("Click the 'pointers' div."); + await Input.dispatchMouseEvent({ + type: "mousePressed", + x: 80, + y: 180, + }); + await Input.dispatchMouseEvent({ + type: "mouseReleased", + x: 80, + y: 180, + }); + + const events = await getEvents(); + ["mousedown", "mouseup", "click"].forEach((type, index) => { + info(`Checking properties for event ${type}`); + checkProperties({ type, button: 0 }, events[index]); + }); +}); + +add_task(async function testModifiers({ client }) { + const { Input } = client; + + await loadURL(PAGE_URL); + + const testable_modifiers = [ + [alt], + [ctrl], + [meta], + [shift], + [alt, meta], + [ctrl, shift], + [alt, ctrl, meta, shift], + ]; + + for (let modifiers of testable_modifiers) { + info(`MousePressed with modifier ${modifiers} on the 'pointers' div.`); + + await resetEvents(); + await Input.dispatchMouseEvent({ + type: "mousePressed", + x: 80, + y: 180, + modifiers: modifiers.reduce((previous, current) => previous | current), + }); + + const events = await getEvents(); + const expectedEvent = { + type: "mousedown", + button: 0, + altKey: modifiers.includes(alt), + ctrlKey: modifiers.includes(ctrl), + metaKey: modifiers.includes(meta), + shiftKey: modifiers.includes(shift), + }; + + checkProperties(expectedEvent, events[0]); + } +}); + +add_task(async function testClickCount({ client }) { + const { Input } = client; + + await loadURL(PAGE_URL); + + const testable_clickCounts = [ + { type: "click", clickCount: 1 }, + { type: "dblclick", clickCount: 2 }, + ]; + + for (const { clickCount, type } of testable_clickCounts) { + info(`MousePressed with clickCount ${clickCount} on the 'pointers' div.`); + + await resetEvents(); + await Input.dispatchMouseEvent({ + type: "mousePressed", + x: 80, + y: 180, + clickCount, + }); + await Input.dispatchMouseEvent({ + type: "mouseReleased", + x: 80, + y: 180, + clickCount, + }); + + const events = await getEvents(); + checkProperties({ type, button: 0 }, events[events.length - 1]); + } +}); + +add_task(async function testDispatchMouseEventAwaitClick({ client }) { + const { Input } = client; + + await setupForInput(PAGE_URL); + await loadURL( + toDataURL(` + <div onclick="setTimeout(() => result = 'clicked', 0)">foo</div> + `) + ); + + const { x, y } = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + const div = content.document.querySelector("div"); + return div.getBoundingClientRect(); + } + ); + + await Input.dispatchMouseEvent({ type: "mousePressed", x, y }); + await Input.dispatchMouseEvent({ type: "mouseReleased", x, y }); + + const context = await enableRuntime(client); + const { result } = await evaluate( + client, + context.id, + () => globalThis.result + ); + + is(result.value, "clicked", "Awaited click event handler"); +}); diff --git a/remote/cdp/test/browser/input/doc_dispatchKeyEvent_race.html b/remote/cdp/test/browser/input/doc_dispatchKeyEvent_race.html new file mode 100644 index 0000000000..c761a224e6 --- /dev/null +++ b/remote/cdp/test/browser/input/doc_dispatchKeyEvent_race.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test page for dispatchKeyEvent race test</title> +</head> +<body> + <input type="text"> + <script type="text/javascript"> + window.testValue = "initial-value"; + + // Small helper to synchronously pause for a given delay, in ms. + function sleep(delay) { + const start = Date.now(); + while (Date.now() - start < delay) { + // Wait for the condition to fail. + } + } + + document.querySelector("input").addEventListener("input", () => { + // Block the execution synchronously for one second. + sleep(1000); + // Update the value that will be checked by the test. + window.testValue = document.querySelector("input").value; + }); + </script> +</body> +</html> diff --git a/remote/cdp/test/browser/input/doc_events.html b/remote/cdp/test/browser/input/doc_events.html new file mode 100644 index 0000000000..74df931233 --- /dev/null +++ b/remote/cdp/test/browser/input/doc_events.html @@ -0,0 +1,148 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Input Events</title> + <style> + div { padding:0px; margin: 0px; } + #trackPointer { position: fixed; } + #resultContainer { width: 600px; height: 60px; } + .area { width: 100px; height: 50px; background-color: #ccc; } + .block { width: 5px; height: 5px; border: solid 1px red; } + #dragArea { position: relative; } + #dragTarget { position: absolute; top:22px; left:47px;} + </style> + <script> + "use strict"; + var allEvents = []; + function makeParagraph(message) { + let paragraph = document.createElement("p"); + paragraph.textContent = message; + return paragraph; + } + function displayMessage(message) { + let eventNode = document.getElementById("events"); + eventNode.innerHTML = "" + eventNode.appendChild(makeParagraph(message)); + } + + function appendMessage(message) { + document.getElementById("events").appendChild(makeParagraph(message)); + } + + /** + * Escape |key| if it's in a surrogate-half character range. + * + * Example: given "\ud83d" return "U+d83d". + * + * Otherwise JSON.stringify will convert it to U+FFFD (REPLACEMENT CHARACTER) + * when returning a value from executeScript, for example. + */ + function escapeSurrogateHalf(key) { + if (typeof key !== "undefined" && key.length === 1) { + var charCode = key.charCodeAt(0); + var highSurrogate = charCode >= 0xD800 && charCode <= 0xDBFF; + var surrogate = highSurrogate || (charCode >= 0xDC00 && charCode <= 0xDFFF); + if (surrogate) { + key = "U+" + charCode.toString(16); + } + } + return key; + } + + function recordKeyboardEvent(event) { + var key = escapeSurrogateHalf(event.key); + allEvents.push({ + "code": event.code, + "key": key, + "which": event.which, + "location": event.location, + "alt": event.altKey, + "ctrl": event.ctrlKey, + "meta": event.metaKey, + "shift": event.shiftKey, + "repeat": event.repeat, + "type": event.type + }); + appendMessage(event.type + " " + + "code: " + event.code + ", " + + "key: " + key + ", " + + "which: " + event.which + ", " + + "keyCode: " + event.keyCode); + } + function recordInputEvent(event) { + allEvents.push({ + "data": event.data, + "inputType": event.inputType, + "isComposing": event.isComposing, + }); + appendMessage("InputEvent " + + "data: " + event.data + ", " + + "inputType: " + event.inputType + ", " + + "isComposing: " + event.isComposing); + } + function recordPointerEvent(event) { + if (event.type === "contextmenu") { + event.preventDefault(); + } + allEvents.push({ + "type": event.type, + "button": event.button, + "buttons": event.buttons, + "pageX": event.pageX, + "pageY": event.pageY, + "ctrlKey": event.ctrlKey, + "metaKey": event.metaKey, + "altKey": event.altKey, + "shiftKey": event.shiftKey, + "target": event.target.id + }); + appendMessage(event.type + " " + + "pageX: " + event.pageX + ", " + + "pageY: " + event.pageY + ", " + + "button: " + event.button + ", " + + "buttons: " + event.buttons + ", " + + "ctrlKey: " + event.ctrlKey + ", " + + "altKey: " + event.altKey + ", " + + "metaKey: " + event.metaKey + ", " + + "shiftKey: " + event.shiftKey + ", " + + "target id: " + event.target.id); + } + function resetEvents() { + allEvents.length = 0; + displayMessage(""); + } + + document.addEventListener("DOMContentLoaded", function() { + let keyReporter = document.getElementById("keys"); + keyReporter.addEventListener("keyup", recordKeyboardEvent); + keyReporter.addEventListener("keypress", recordKeyboardEvent); + keyReporter.addEventListener("keydown", recordKeyboardEvent); + keyReporter.addEventListener("input", recordInputEvent); + + let mouseReporter = document.getElementById("pointers"); + mouseReporter.addEventListener("click", recordPointerEvent); + mouseReporter.addEventListener("dblclick", recordPointerEvent); + mouseReporter.addEventListener("mousedown", recordPointerEvent); + mouseReporter.addEventListener("mouseup", recordPointerEvent); + mouseReporter.addEventListener("contextmenu", recordPointerEvent); + }); + </script> +</head> +<body> + <div id="trackPointer" class="block"></div> + <div> + <h2>KeyReporter</h2> + <input type="text" id="keys" size="80"> + </div> + <div> + <h2>ClickReporter</h2> + <div id="pointers" class="area"> + </div> + </div> + <div id="resultContainer"> + <h2>Events</h2> + <div id="events"></div> + </div> +</body> +</html> diff --git a/remote/cdp/test/browser/input/head.js b/remote/cdp/test/browser/input/head.js new file mode 100644 index 0000000000..4076935802 --- /dev/null +++ b/remote/cdp/test/browser/input/head.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); + +const { Input: I } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/domains/parent/Input.sys.mjs" +); +const { AppInfo } = ChromeUtils.importESModule( + "chrome://remote/content/shared/AppInfo.sys.mjs" +); + +const { alt, ctrl, meta, shift } = I.Modifier; + +// Map of key codes used in Input tests. +const KEYCODES = { + a: KeyboardEvent.DOM_VK_A, + A: KeyboardEvent.DOM_VK_A, + b: KeyboardEvent.DOM_VK_B, + B: KeyboardEvent.DOM_VK_B, + c: KeyboardEvent.DOM_VK_C, + C: KeyboardEvent.DOM_VK_C, + h: KeyboardEvent.DOM_VK_H, + H: KeyboardEvent.DOM_VK_H, + Alt: KeyboardEvent.DOM_VK_ALT, + ArrowLeft: KeyboardEvent.DOM_VK_LEFT, + ArrowRight: KeyboardEvent.DOM_VK_RIGHT, + ArrowDown: KeyboardEvent.DOM_VK_DOWN, + Backspace: KeyboardEvent.DOM_VK_BACK_SPACE, + Control: KeyboardEvent.DOM_VK_CONTROL, + Meta: KeyboardEvent.DM_VK_META, + Shift: KeyboardEvent.DOM_VK_SHIFT, + Tab: KeyboardEvent.DOM_VK_TAB, +}; + +async function setupForInput(url) { + await loadURL(url); + info("Focus the input on the page"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const input = content.document.querySelector("input"); + input.focus(); + is(input, content.document.activeElement, "Input should be focused"); + is(input.value, "", "Check input content"); + is(input.selectionStart, 0, "Check position of input caret"); + }); +} + +async function withModifier(Input, modKey, mod, key) { + await dispatchKeyEvent(Input, modKey, "rawKeyDown", I.Modifier[mod]); + await dispatchKeyEvent(Input, key, "keyDown", I.Modifier[mod]); + await dispatchKeyEvent(Input, key, "keyUp", I.Modifier[mod]); + await dispatchKeyEvent(Input, modKey, "keyUp"); +} + +function dispatchKeyEvent(Input, key, type, modifiers = 0) { + info(`Send ${type} for key ${key}`); + return Input.dispatchKeyEvent({ + type, + modifiers, + windowsVirtualKeyCode: KEYCODES[key], + key, + }); +} + +async function getEvents() { + const events = await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return content.wrappedJSObject.allEvents; + }); + info(`Events: ${JSON.stringify(events)}`); + return events; +} + +function getInputContent() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const input = content.document.querySelector("input"); + return { value: input.value, caret: input.selectionStart }; + }); +} + +function checkEvent(event, type, key, property, expectedValue) { + let expected = { type, key }; + expected[property] = expectedValue; + checkProperties(expected, event, "Event"); +} + +async function checkInputContent(expectedValue, expectedCaret) { + const { value, caret } = await getInputContent(); + is(value, expectedValue, "Check input content"); + is(caret, expectedCaret, "Check position of input caret"); +} + +function checkProperties(expectedObj, targetObj, message = "Compare objects") { + for (const prop in expectedObj) { + is(targetObj[prop], expectedObj[prop], message + `: check ${prop}`); + } +} + +function keyForPlatform() { + // TODO add cases for other key-combinations as the need arises + let primary = ctrl; + let primaryKey = "Control"; + if (AppInfo.isMac) { + primary = alt; + primaryKey = "Alt"; + } + return { primary, primaryKey }; +} + +async function sendTextKey(Input, key, modifiers = 0) { + await dispatchKeyEvent(Input, key, "keyDown", modifiers); + await dispatchKeyEvent(Input, key, "keyUp", modifiers); +} + +async function sendText(Input, text) { + for (const sym of text) { + await sendTextKey(Input, sym); + } +} + +async function sendRawKey(Input, key, modifiers = 0) { + await dispatchKeyEvent(Input, key, "rawKeyDown", modifiers); + await dispatchKeyEvent(Input, key, "keyUp", modifiers); +} + +async function checkBackspace(Input, expected, modifiers = 0) { + info("Send Backspace"); + await sendRawKey(Input, "Backspace", modifiers); + await checkInputContent(expected, expected.length); +} + +async function resetEvents() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.wrappedJSObject.resetEvents(); + const events = content.wrappedJSObject.allEvents; + is(events.length, 0, "List of events should be empty"); + }); +} + +function resetInput(value = "") { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [value], function (arg) { + const input = content.document.querySelector("input"); + input.value = arg; + input.focus(); + }); +} diff --git a/remote/cdp/test/browser/io/browser.toml b/remote/cdp/test/browser/io/browser.toml new file mode 100644 index 0000000000..5b43f8b9fa --- /dev/null +++ b/remote/cdp/test/browser/io/browser.toml @@ -0,0 +1,23 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", +] + +["browser_close.js"] + +["browser_read.js"] diff --git a/remote/cdp/test/browser/io/browser_close.js b/remote/cdp/test/browser/io/browser_close.js new file mode 100644 index 0000000000..37a10b4963 --- /dev/null +++ b/remote/cdp/test/browser/io/browser_close.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function fileRemovedAfterClose({ client }) { + const { IO } = client; + const contents = "Lorem ipsum"; + const { handle, stream } = await registerFileStream(contents); + + await IO.close({ handle }); + ok( + !(await IOUtils.exists(stream.path)), + "Discarded the temporary backing storage" + ); +}); + +add_task(async function unknownHandle({ client }) { + const { IO } = client; + const handle = "1000000"; + + await Assert.rejects( + IO.close({ handle }), + err => err.message.includes(`Invalid stream handle`), + "Error contains expected message" + ); +}); + +add_task(async function invalidHandleTypes({ client }) { + const { IO } = client; + for (const handle of [null, true, 1, [], {}]) { + await Assert.rejects( + IO.close({ handle }), + err => err.message.includes(`handle: string value expected`), + "Error contains expected message" + ); + } +}); diff --git a/remote/cdp/test/browser/io/browser_read.js b/remote/cdp/test/browser/io/browser_read.js new file mode 100644 index 0000000000..4af05e4ba2 --- /dev/null +++ b/remote/cdp/test/browser/io/browser_read.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function seekByOffsets({ client }) { + const { IO } = client; + const contents = "Lorem ipsum"; + const { handle } = await registerFileStream(contents); + + for (const offset of [0, 5, 10, 100, 0, -1]) { + const result = await IO.read({ handle, offset }); + ok(result.base64Encoded, `Data for offset ${offset} is base64 encoded`); + ok(result.eof, `All data has been read for offset ${offset}`); + is( + atob(result.data), + contents.substring(offset >= 0 ? offset : 0), + `Found expected data for offset ${offset}` + ); + } +}); + +add_task(async function remembersOffsetAfterRead({ client }) { + const { IO } = client; + const contents = "Lorem ipsum"; + const { handle } = await registerFileStream(contents); + + let expectedOffset = 0; + const size = 3; + do { + const result = await IO.read({ handle, size }); + is( + atob(result.data), + contents.substring(expectedOffset, expectedOffset + size), + `Found expected data for expectedOffset ${expectedOffset}` + ); + ok( + result.base64Encoded, + `Data up to expected offset ${expectedOffset} is base64 encoded` + ); + + is( + result.eof, + expectedOffset + size >= contents.length, + `All data has been read up to expected offset ${expectedOffset}` + ); + + expectedOffset = Math.min(expectedOffset + size, contents.length); + } while (expectedOffset < contents.length); +}); + +add_task(async function readBySize({ client }) { + const { IO } = client; + const contents = "Lorem ipsum"; + const { handle } = await registerFileStream(contents); + + for (const size of [0, 5, 10, 100, 0, -1]) { + const result = await IO.read({ handle, offset: 0, size }); + ok(result.base64Encoded, `Data for size ${size} is base64 encoded`); + is( + result.eof, + size >= contents.length, + `All data has been read for size ${size}` + ); + is( + atob(result.data), + contents.substring(0, size), + `Found expected data for size ${size}` + ); + } +}); + +add_task(async function readAfterClose({ client }) { + const { IO } = client; + const contents = "Lorem ipsum"; + + // If we omit remove: false, then by the time the registered cleanup function + // runs we will have deleted our temp file (in the following call to IO.close) + // *but* another test will have created a file with the same name (due to the + // way IOUtils.createUniqueFile works). That file's stream will not be closed + // and so we won't be able to delete it, resulting in an exception and + // therefore a test failure. + const { handle, stream } = await registerFileStream(contents, { + remove: false, + }); + + await IO.close({ handle }); + + ok(!(await IOUtils.exists(stream.path)), "File should no longer exist"); + + await Assert.rejects( + IO.read({ handle }), + err => err.message.includes(`Invalid stream handle`), + "Error contains expected message" + ); +}); + +add_task(async function unknownHandle({ client }) { + const { IO } = client; + const handle = "1000000"; + + await Assert.rejects( + IO.read({ handle }), + err => err.message.includes(`Invalid stream handle`), + "Error contains expected message" + ); +}); + +add_task(async function invalidHandleTypes({ client }) { + const { IO } = client; + for (const handle of [null, true, 1, [], {}]) { + await Assert.rejects( + IO.read({ handle }), + err => err.message.includes(`handle: string value expected`), + "Error contains expected message" + ); + } +}); + +add_task(async function invalidOffsetTypes({ client }) { + const { IO } = client; + const contents = "Lorem ipsum"; + const { handle } = await registerFileStream(contents); + + for (const offset of [null, true, "1", [], {}]) { + await Assert.rejects( + IO.read({ handle, offset }), + err => err.message.includes(`offset: integer value expected`), + "Error contains expected message" + ); + } +}); + +add_task(async function invalidSizeTypes({ client }) { + const { IO } = client; + const contents = "Lorem ipsum"; + const { handle } = await registerFileStream(contents); + + for (const size of [null, true, "1", [], {}]) { + await Assert.rejects( + IO.read({ handle, size }), + err => err.message.includes(`size: integer value expected`), + "Error contains expected message" + ); + } +}); diff --git a/remote/cdp/test/browser/io/head.js b/remote/cdp/test/browser/io/head.js new file mode 100644 index 0000000000..4c6a67178e --- /dev/null +++ b/remote/cdp/test/browser/io/head.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); + +const { streamRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/domains/parent/IO.sys.mjs" +); + +async function registerFileStream(contents, options) { + const stream = await createFileStream(contents, options); + const handle = streamRegistry.add(stream); + + return { handle, stream }; +} diff --git a/remote/cdp/test/browser/log/browser.toml b/remote/cdp/test/browser/log/browser.toml new file mode 100644 index 0000000000..728e461609 --- /dev/null +++ b/remote/cdp/test/browser/log/browser.toml @@ -0,0 +1,21 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", +] + +["browser_entryAdded.js"] diff --git a/remote/cdp/test/browser/log/browser_entryAdded.js b/remote/cdp/test/browser/log/browser_entryAdded.js new file mode 100644 index 0000000000..1c6ba2e0a8 --- /dev/null +++ b/remote/cdp/test/browser/log/browser_entryAdded.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noEventsWhenLogDomainDisabled({ client }) { + await runEntryAddedTest(client, 0, async () => { + await throwScriptError("foo"); + }); +}); + +add_task(async function noEventsAfterLogDomainDisabled({ client }) { + const { Log } = client; + + await Log.enable(); + await Log.disable(); + + await runEntryAddedTest(client, 0, async () => { + await throwScriptError("foo"); + }); +}); + +add_task(async function noEventsForConsoleMessageWithException({ client }) { + const { Log } = client; + + await Log.enable(); + + const context = await enableRuntime(client); + await runEntryAddedTest(client, 0, async () => { + evaluate(client, context.id, () => { + const foo = {}; + foo.bar(); + }); + }); +}); + +add_task(async function eventsForScriptErrorWithoutException({ client }) { + const { Log } = client; + + await Log.enable(); + + await enableRuntime(client); + const events = await runEntryAddedTest(client, 1, async () => { + throwScriptError({ + text: "foo", + sourceName: "https://foo.bar", + lineNumber: 7, + category: "javascript", + }); + }); + + is(events[0].source, "javascript", "Got expected source"); + is(events[0].level, "error", "Got expected level"); + is(events[0].text, "foo", "Got expected text"); + is(events[0].url, "https://foo.bar", "Got expected url"); + is(events[0].lineNumber, 7, "Got expected line number"); +}); + +add_task(async function eventsForScriptErrorLevels({ client }) { + const { Log } = client; + + await Log.enable(); + + const flags = { + info: Ci.nsIScriptError.infoFlag, + warning: Ci.nsIScriptError.warningFlag, + error: Ci.nsIScriptError.errorFlag, + }; + + await enableRuntime(client); + for (const [level, flag] of Object.entries(flags)) { + const events = await runEntryAddedTest(client, 1, async () => { + throwScriptError({ text: level, flag }); + }); + + is(events[0].text, level, "Got expected text"); + is(events[0].level, level, "Got expected level"); + } +}); + +add_task(async function eventsForScriptErrorContent({ client }) { + const { Log } = client; + + await Log.enable(); + + const context = await enableRuntime(client); + const events = await runEntryAddedTest(client, 1, async () => { + evaluate(client, context.id, () => { + document.execCommand("copy"); + }); + }); + + is(events[0].source, "other", "Got expected source"); + is(events[0].level, "warning", "Got expected level"); + ok( + events[0].text.includes("document.execCommand(‘cut’/‘copy’) was denied"), + "Got expected text" + ); + is(events[0].url, undefined, "Got undefined url"); + is(events[0].lineNumber, 2, "Got expected line number"); +}); + +async function runEntryAddedTest(client, eventCount, callback, options = {}) { + const { Log } = client; + + const EVENT_ENTRY_ADDED = "Log.entryAdded"; + + const history = new RecordEvents(eventCount); + history.addRecorder({ + event: Log.entryAdded, + eventName: EVENT_ENTRY_ADDED, + messageFn: payload => `Received "${EVENT_ENTRY_ADDED}"`, + }); + + const timeBefore = Date.now(); + await callback(); + + const entryAddedEvents = await history.record(); + is(entryAddedEvents.length, eventCount, "Got expected amount of events"); + + if (eventCount == 0) { + return []; + } + + const timeAfter = Date.now(); + + // Check basic details for entryAdded events + entryAddedEvents.forEach(event => { + const timestamp = event.payload.entry.timestamp; + + ok( + timestamp >= timeBefore && timestamp <= timeAfter, + "Got valid timestamp" + ); + }); + + return entryAddedEvents.map(event => event.payload.entry); +} diff --git a/remote/cdp/test/browser/log/head.js b/remote/cdp/test/browser/log/head.js new file mode 100644 index 0000000000..1a1c90fbf6 --- /dev/null +++ b/remote/cdp/test/browser/log/head.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); diff --git a/remote/cdp/test/browser/network/browser.toml b/remote/cdp/test/browser/network/browser.toml new file mode 100644 index 0000000000..8ec02a25d7 --- /dev/null +++ b/remote/cdp/test/browser/network/browser.toml @@ -0,0 +1,55 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", + "doc_empty.html", + "doc_frameset.html", + "doc_get_cookies_frame.html", + "doc_get_cookies_page.html", + "doc_networkEvents.html", + "file_networkEvents.js", + "file_framesetEvents.js", + "sjs-cookies.sjs", +] + +["browser_deleteCookies.js"] +https_first_disabled = true + +["browser_emulateNetworkConditions.js"] + +["browser_getAllCookies.js"] +https_first_disabled = true + +["browser_getCookies.js"] +https_first_disabled = true + +["browser_navigationEvents.js"] +https_first_disabled = true + +["browser_requestWillBeSent.js"] +https_first_disabled = true + +["browser_responseReceived.js"] +https_first_disabled = true + +["browser_setCacheDisabled.js"] + +["browser_setCookie.js"] + +["browser_setCookies.js"] + +["browser_setUserAgentOverride.js"] diff --git a/remote/cdp/test/browser/network/browser_deleteCookies.js b/remote/cdp/test/browser/network/browser_deleteCookies.js new file mode 100644 index 0000000000..1b5b0779d5 --- /dev/null +++ b/remote/cdp/test/browser/network/browser_deleteCookies.js @@ -0,0 +1,299 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SJS_PATH = "/browser/remote/cdp/test/browser/network/sjs-cookies.sjs"; + +const DEFAULT_HOST = "http://example.org"; +const DEFAULT_HOSTNAME = "example.org"; +const ALT_HOST = "http://example.net"; +const SECURE_HOST = "https://example.com"; + +const DEFAULT_URL = `${DEFAULT_HOST}${SJS_PATH}`; + +// Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default" +Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false); +registerCleanupFunction(() => { + Services.prefs.clearUserPref("network.cookie.sameSite.laxByDefault"); +}); + +add_task(async function failureWithoutArguments({ client }) { + const { Network } = client; + + await Assert.rejects( + Network.deleteCookies(), + err => err.message.includes("name: string value expected"), + "Fails without any arguments" + ); +}); + +add_task(async function failureWithoutDomainOrURL({ client }) { + const { Network } = client; + + await Assert.rejects( + Network.deleteCookies({ name: "foo" }), + err => + err.message.includes( + "At least one of the url and domain needs to be specified" + ), + "Fails without domain or URL" + ); +}); + +add_task(async function failureWithInvalidProtocol({ client }) { + const { Network } = client; + + const FTP_URL = `ftp://${DEFAULT_HOSTNAME}`; + + await Assert.rejects( + Network.deleteCookies({ name: "foo", url: FTP_URL }), + err => err.message.includes("An http or https url must be specified"), + "Fails for invalid protocol in URL" + ); +}); + +add_task(async function pristineContext({ client }) { + const { Network } = client; + + await loadURL(DEFAULT_URL); + + const { cookies } = await Network.getCookies(); + is(cookies.length, 0, "No cookies have been found"); + + await Network.deleteCookies({ name: "foo", url: DEFAULT_URL }); +}); + +add_task(async function fromHostWithPort({ client }) { + const { Network } = client; + + const PORT_URL = `${DEFAULT_HOST}:8000${SJS_PATH}`; + await loadURL(PORT_URL + "?name=id&value=1"); + + const cookie = { + name: "id", + value: "1", + }; + + try { + const { cookies: before } = await Network.getCookies(); + is(before.length, 1, "A cookie has been found"); + + await Network.deleteCookies({ name: cookie.name, url: PORT_URL }); + + const { cookies: after } = await Network.getCookies(); + is(after.length, 0, "No cookie has been found"); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function forSpecificDomain({ client }) { + const { Network } = client; + + const ALT_URL = ALT_HOST + SJS_PATH; + + await loadURL(`${ALT_URL}?name=foo&value=bar`); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + + const cookie = { + name: "foo", + value: "bar", + domain: "example.net", + }; + + try { + const { cookies: before } = await Network.getCookies(); + is(before.length, 1, "A cookie has been found"); + + await Network.deleteCookies({ + name: cookie.name, + domain: DEFAULT_HOSTNAME, + }); + + const { cookies: after } = await Network.getCookies(); + is(after.length, 0, "No cookie has been found"); + + await loadURL(ALT_URL); + + const { cookies: other } = await Network.getCookies(); + is(other.length, 1, "A cookie has been found"); + assertCookie(other[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function forSpecificURL({ client }) { + const { Network } = client; + + const ALT_URL = ALT_HOST + SJS_PATH; + + await loadURL(`${ALT_URL}?name=foo&value=bar`); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + + const cookie = { + name: "foo", + value: "bar", + domain: "example.net", + }; + + try { + const { cookies: before } = await Network.getCookies(); + is(before.length, 1, "A cookie has been found"); + + await Network.deleteCookies({ name: cookie.name, url: DEFAULT_URL }); + + const { cookies: after } = await Network.getCookies(); + is(after.length, 0, "No cookie has been found"); + + await loadURL(ALT_URL); + + const { cookies: other } = await Network.getCookies(); + is(other.length, 1, "A cookie has been found"); + assertCookie(other[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function forSecureURL({ client }) { + const { Network } = client; + + const SECURE_URL = `${SECURE_HOST}${SJS_PATH}`; + + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + await loadURL(`${SECURE_URL}?name=foo&value=bar`); + + const cookie = { + name: "foo", + value: "bar", + domain: "example.org", + }; + + try { + const { cookies: before } = await Network.getCookies(); + is(before.length, 1, "A cookie has been found"); + + await Network.deleteCookies({ name: cookie.name, url: SECURE_URL }); + + const { cookies: after } = await Network.getCookies(); + is(after.length, 0, "No cookie has been found"); + + await loadURL(DEFAULT_URL); + + const { cookies: other } = await Network.getCookies(); + is(other.length, 1, "A cookie has been found"); + assertCookie(other[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function forSpecificDomainAndURL({ client }) { + const { Network } = client; + + const ALT_URL = ALT_HOST + SJS_PATH; + + await loadURL(`${ALT_URL}?name=foo&value=bar`); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + + const cookie = { + name: "foo", + value: "bar", + domain: "example.net", + }; + + try { + const { cookies: before } = await Network.getCookies(); + is(before.length, 1, "A cookie has been found"); + + // Domain has precedence before URL + await Network.deleteCookies({ + name: cookie.name, + domain: DEFAULT_HOSTNAME, + url: ALT_URL, + }); + + const { cookies: after } = await Network.getCookies(); + is(after.length, 0, "No cookie has been found"); + + await loadURL(ALT_URL); + + const { cookies: other } = await Network.getCookies(); + is(other.length, 1, "A cookie has been found"); + assertCookie(other[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function path({ client }) { + const { Network } = client; + + const PATH = "/browser/remote/cdp/test/browser/"; + const PARENT_PATH = "/browser/remote/cdp/test/"; + const SUB_PATH = "/browser/remote/cdp/test/browser/network/"; + + const cookie = { + name: "foo", + value: "bar", + path: PATH, + }; + + try { + console.log("Check exact path"); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&path=${PATH}`); + let result = await Network.getCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + + await Network.deleteCookies({ + name: cookie.name, + path: PATH, + url: DEFAULT_URL, + }); + result = await Network.getCookies(); + is(result.cookies.length, 0, "No cookie has been found"); + + console.log("Check sub path"); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&path=${PATH}`); + result = await Network.getCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + + await Network.deleteCookies({ + name: cookie.name, + path: SUB_PATH, + url: DEFAULT_URL, + }); + result = await Network.getCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + + console.log("Check parent path"); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&path=${PATH}`); + result = await Network.getCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + + await Network.deleteCookies({ + name: cookie.name, + path: PARENT_PATH, + url: DEFAULT_URL, + }); + result = await Network.getCookies(); + is(result.cookies.length, 0, "No cookie has been found"); + + console.log("Check non matching path"); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&path=${PATH}`); + result = await Network.getCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + + await Network.deleteCookies({ + name: cookie.name, + path: "/foo/bar", + url: DEFAULT_URL, + }); + result = await Network.getCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + } finally { + Services.cookies.removeAll(); + } +}); diff --git a/remote/cdp/test/browser/network/browser_emulateNetworkConditions.js b/remote/cdp/test/browser/network/browser_emulateNetworkConditions.js new file mode 100644 index 0000000000..858e551aca --- /dev/null +++ b/remote/cdp/test/browser/network/browser_emulateNetworkConditions.js @@ -0,0 +1,208 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const pageEmptyURL = + "https://example.com/browser/remote/cdp/test/browser/page/doc_empty.html"; + +/* + * Set the optional preference to disallow access to localhost when offline. This is + * required because `example.com` resolves to `localhost` in the tests and therefore + * would still be accessible even though we are simulating being offline. + * By setting this preference, we make sure that these connections to `localhost` + * (and by extension, to `example.com`) will fail when we are offline. + */ +Services.prefs.setBoolPref("network.disable-localhost-when-offline", true); +registerCleanupFunction(() => { + Services.prefs.clearUserPref("network.disable-localhost-when-offline"); +}); + +/** + * Acts just as `add_task`, but does cleanup afterwards + * + * @param {Function} taskFn + */ +function add_networking_task(taskFn) { + add_task(async client => { + try { + await taskFn(client); + } finally { + Services.io.offline = false; + } + }); +} + +add_networking_task(async function offlineWithoutArguments({ client }) { + const { Network } = client; + + await Assert.rejects( + Network.emulateNetworkConditions(), + /offline: boolean value expected/, + "Fails without any arguments" + ); +}); + +add_networking_task(async function offlineWithEmptyArguments({ client }) { + const { Network } = client; + + await Assert.rejects( + Network.emulateNetworkConditions({}), + /offline: boolean value expected/, + "Fails with only empty arguments" + ); +}); + +add_networking_task(async function offlineWithInvalidArguments({ client }) { + const { Network } = client; + const testTable = [null, undefined, 1, "foo", [], {}]; + + for (const testCase of testTable) { + const testType = typeof testCase; + await Assert.rejects( + Network.emulateNetworkConditions({ offline: testCase }), + /offline: boolean value expected/, + `Fails with ${testType}-type argument for offline` + ); + } +}); + +add_networking_task(async function offlineWithUnsupportedArguments({ client }) { + const { Network } = client; + + // Random valid values for the Network.emulateNetworkConditions command, even though we don't support them yet + const args = { + offline: true, + latency: 500, + downloadThroughput: 500, + uploadThroughput: 500, + connectionType: "cellular2g", + someFutureArg: false, + }; + + await Network.emulateNetworkConditions(args); + + ok(true, "No errors should be thrown due to non-implemented arguments"); +}); + +add_networking_task(async function emulateOfflineWhileOnline({ client }) { + const { Network } = client; + + // Assert we're online to begin with + await assertOfflineStatus(false); + + // Assert for offline + await Network.emulateNetworkConditions({ offline: true }); + await assertOfflineStatus(true); + + // Assert we really can't navigate after setting offline + await assertOfflineNavigationFails(); +}); + +add_networking_task(async function emulateOfflineWhileOffline({ client }) { + const { Network } = client; + + // Assert we're online to begin with + await assertOfflineStatus(false); + + // Assert for offline + await Network.emulateNetworkConditions({ offline: true }); + await assertOfflineStatus(true); + + // Assert for no-offline event, because we're offline - and changing to offline - so nothing changes + await Network.emulateNetworkConditions({ offline: true }); + await assertOfflineStatus(true); + + // Assert we still can't navigate after setting offline twice + await assertOfflineNavigationFails(); +}); + +add_networking_task(async function emulateOnlineWhileOnline({ client }) { + const { Network } = client; + + // Assert we're online to begin with + await assertOfflineStatus(false); + + // Assert for no-offline event, because we're online - and changing to online - so nothing changes + await Network.emulateNetworkConditions({ offline: false }); + await assertOfflineStatus(false); +}); + +add_networking_task(async function emulateOnlineWhileOffline({ client }) { + const { Network } = client; + + // Assert we're online to begin with + await assertOfflineStatus(false); + + // Assert for offline event, because we're online - and changing to offline + const offlineChanged = Promise.race([ + BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "online", + true + ), + BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "offline", + true + ), + ]); + + await Network.emulateNetworkConditions({ offline: true }); + + info("Waiting for offline event on window"); + is(await offlineChanged, "offline", "Only the offline-event should fire"); + await assertOfflineStatus(true); + + // Assert for online event, because we're offline - and changing to online + const offlineChangedBack = Promise.race([ + BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "online", + true + ), + BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "offline", + true + ), + ]); + await Network.emulateNetworkConditions({ offline: false }); + + info("Waiting for online event on window"); + is(await offlineChangedBack, "online", "Only the online-event should fire"); + await assertOfflineStatus(false); +}); + +/** + * Navigates to a page, and asserting any status code to appear + */ +async function assertOfflineNavigationFails() { + const browser = gBrowser.selectedTab.linkedBrowser; + let netErrorLoaded = BrowserTestUtils.waitForErrorPage(browser); + + BrowserTestUtils.startLoadingURIString(browser, pageEmptyURL); + await netErrorLoaded; +} + +/** + * Checks on the page what the value of window.navigator.onLine is on the currently navigated page + * + * @param {boolean} offline + * True if offline is expected + */ +function assertOfflineStatus(offline) { + is( + Services.io.offline, + offline, + "Services.io.offline should be " + (offline ? "true" : "false") + ); + + return SpecialPowers.spawn(gBrowser.selectedBrowser, [offline], offline => { + is( + content.navigator.onLine, + !offline, + "Page should be " + (offline ? "offline" : "online") + ); + }); +} diff --git a/remote/cdp/test/browser/network/browser_getAllCookies.js b/remote/cdp/test/browser/network/browser_getAllCookies.js new file mode 100644 index 0000000000..2e2d404410 --- /dev/null +++ b/remote/cdp/test/browser/network/browser_getAllCookies.js @@ -0,0 +1,231 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SJS_PATH = "/browser/remote/cdp/test/browser/network/sjs-cookies.sjs"; + +const DEFAULT_HOST = "http://example.org"; +const ALT_HOST = "http://example.net"; +const SECURE_HOST = "https://example.com"; + +const DEFAULT_URL = `${DEFAULT_HOST}${SJS_PATH}`; + +// Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default" +Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false); +registerCleanupFunction(() => { + Services.prefs.clearUserPref("network.cookie.sameSite.laxByDefault"); +}); + +add_task(async function noCookiesWhenNoneAreSet({ client }) { + const { Network } = client; + const { cookies } = await Network.getAllCookies(); + is(cookies.length, 0, "No cookies have been found"); +}); + +add_task(async function noCookiesForPristineContext({ client }) { + const { Network } = client; + await loadURL(DEFAULT_URL); + + try { + const { cookies } = await Network.getAllCookies(); + is(cookies.length, 0, "No cookies have been found"); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function allCookiesFromHostWithPort({ client }) { + const { Network } = client; + const PORT_URL = `${DEFAULT_HOST}:8000${SJS_PATH}?name=id&value=1`; + await loadURL(PORT_URL); + + const cookie = { + name: "id", + value: "1", + }; + + try { + const { cookies } = await Network.getAllCookies(); + is(cookies.length, 1, "All cookies have been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function allCookiesFromMultipleOrigins({ client }) { + const { Network } = client; + await loadURL(`${ALT_HOST}${SJS_PATH}?name=users&value=password`); + await loadURL(`${SECURE_HOST}${SJS_PATH}?name=secure&value=password`); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + + const cookie1 = { name: "foo", value: "bar", domain: "example.org" }; + const cookie2 = { name: "secure", value: "password", domain: "example.com" }; + const cookie3 = { name: "users", value: "password", domain: "example.net" }; + + try { + const { cookies } = await Network.getAllCookies(); + cookies.sort((a, b) => a.name.localeCompare(b.name)); + is(cookies.length, 3, "All cookies have been found"); + assertCookie(cookies[0], cookie1); + assertCookie(cookies[1], cookie2); + assertCookie(cookies[2], cookie3); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function secure({ client }) { + const { Network } = client; + await loadURL(`${SECURE_HOST}${SJS_PATH}?name=foo&value=bar&secure`); + + const cookie = { + name: "foo", + value: "bar", + domain: "example.com", + secure: true, + }; + + try { + // Cookie returned for secure protocols + let result = await Network.getAllCookies(); + is(result.cookies.length, 1, "The secure cookie has been found"); + assertCookie(result.cookies[0], cookie); + + // For unsecure protocols the secure cookies are also returned + await loadURL(DEFAULT_URL); + result = await Network.getAllCookies(); + is(result.cookies.length, 1, "The secure cookie has been found"); + assertCookie(result.cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function expiry({ client }) { + const { Network } = client; + const date = new Date(); + date.setDate(date.getDate() + 3); + + const encodedDate = encodeURI(date.toUTCString()); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&expiry=${encodedDate}`); + + const cookie = { + name: "foo", + value: "bar", + expires: Math.floor(date.getTime() / 1000), + session: false, + }; + + try { + const { cookies } = await Network.getAllCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function session({ client }) { + const { Network } = client; + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + + const cookie = { + name: "foo", + value: "bar", + expiry: -1, + session: true, + }; + + try { + const { cookies } = await Network.getAllCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function path({ client }) { + const { Network } = client; + const PATH = "/browser/remote/cdp/test/browser/"; + const PARENT_PATH = "/browser/remote/cdp/test/"; + + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&path=${PATH}`); + + const cookie = { + name: "foo", + value: "bar", + path: PATH, + }; + + try { + console.log("Check exact path"); + await loadURL(`${DEFAULT_HOST}${PATH}`); + let result = await Network.getAllCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + assertCookie(result.cookies[0], cookie); + + console.log("Check sub path"); + await loadURL(`${DEFAULT_HOST}${SJS_PATH}`); + result = await Network.getAllCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + assertCookie(result.cookies[0], cookie); + + console.log("Check parent path"); + await loadURL(`${DEFAULT_HOST}${PARENT_PATH}`); + result = await Network.getAllCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + assertCookie(result.cookies[0], cookie); + + console.log("Check non matching path"); + await loadURL(`${DEFAULT_HOST}/foo/bar`); + result = await Network.getAllCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + assertCookie(result.cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function httpOnly({ client }) { + const { Network } = client; + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&httpOnly`); + + const cookie = { + name: "foo", + value: "bar", + httpOnly: true, + }; + + try { + const { cookies } = await Network.getAllCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function sameSite({ client }) { + const { Network } = client; + for (const value of ["Lax", "Strict"]) { + console.log(`Test cookie with SameSite=${value}`); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&sameSite=${value}`); + + const cookie = { + name: "foo", + value: "bar", + sameSite: value, + }; + + try { + const { cookies } = await Network.getAllCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } + } +}); diff --git a/remote/cdp/test/browser/network/browser_getCookies.js b/remote/cdp/test/browser/network/browser_getCookies.js new file mode 100644 index 0000000000..99fedb86be --- /dev/null +++ b/remote/cdp/test/browser/network/browser_getCookies.js @@ -0,0 +1,351 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const DEFAULT_DOMAIN = "example.org"; +const ALT_DOMAIN = "example.net"; +const SECURE_DOMAIN = "example.com"; + +const DEFAULT_HOST = "http://" + DEFAULT_DOMAIN; +const ALT_HOST = "http://" + ALT_DOMAIN; +const SECURE_HOST = "https://" + SECURE_DOMAIN; + +const BASE_PATH = "/browser/remote/cdp/test/browser/network"; +const SJS_PATH = `${BASE_PATH}/sjs-cookies.sjs`; + +const DEFAULT_URL = `${DEFAULT_HOST}${SJS_PATH}`; + +// Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default" +Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false); +registerCleanupFunction(() => { + Services.prefs.clearUserPref("network.cookie.sameSite.laxByDefault"); +}); + +add_task(async function noCookiesWhenNoneAreSet({ client }) { + const { Network } = client; + const { cookies } = await Network.getCookies({ urls: [DEFAULT_HOST] }); + is(cookies.length, 0, "No cookies have been found"); +}); + +add_task(async function noCookiesForPristineContext({ client }) { + const { Network } = client; + await loadURL(DEFAULT_URL); + + try { + const { cookies } = await Network.getCookies(); + is(cookies.length, 0, "No cookies have been found"); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function allCookiesFromHostWithPort({ client }) { + const { Network } = client; + const PORT_URL = `${DEFAULT_HOST}:8000${SJS_PATH}?name=id&value=1`; + await loadURL(PORT_URL); + + const cookie = { + name: "id", + value: "1", + }; + + try { + const { cookies } = await Network.getCookies(); + is(cookies.length, 1, "All cookies have been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function allCookiesFromCurrentURL({ client }) { + const { Network } = client; + await loadURL(`${ALT_HOST}${SJS_PATH}?name=user&value=password`); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + await loadURL(`${DEFAULT_URL}?name=user&value=password`); + + const cookie1 = { name: "foo", value: "bar", domain: "example.org" }; + const cookie2 = { name: "user", value: "password", domain: "example.org" }; + + try { + const { cookies } = await Network.getCookies(); + cookies.sort((a, b) => a.name.localeCompare(b.name)); + is(cookies.length, 2, "All cookies have been found"); + assertCookie(cookies[0], cookie1); + assertCookie(cookies[1], cookie2); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function allCookiesIncludingSubFrames({ client }) { + const GET_COOKIES_PAGE_URL = `${DEFAULT_HOST}${BASE_PATH}/doc_get_cookies_page.html`; + + const { Network } = client; + await loadURL(GET_COOKIES_PAGE_URL); + + const cookie_page = { name: "page", value: "mainpage", path: BASE_PATH }; + const cookie_frame = { name: "frame", value: "subframe", path: BASE_PATH }; + + try { + const { cookies } = await Network.getCookies(); + cookies.sort((a, b) => a.name.localeCompare(b.name)); + is(cookies.length, 2, "All cookies have been found including subframe"); + assertCookie(cookies[0], cookie_frame); + assertCookie(cookies[1], cookie_page); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function secure({ client }) { + const { Network } = client; + await loadURL(`${SECURE_HOST}${SJS_PATH}?name=foo&value=bar&secure`); + + const cookie = { + name: "foo", + value: "bar", + domain: "example.com", + secure: true, + }; + + try { + // Cookie returned for secure protocols + let result = await Network.getCookies(); + is(result.cookies.length, 1, "The secure cookie has been found"); + assertCookie(result.cookies[0], cookie); + + // For unsecure protocols no secure cookies are returned + await loadURL(DEFAULT_URL); + result = await Network.getCookies(); + is(result.cookies.length, 0, "No secure cookies have been found"); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function expiry({ client }) { + const { Network } = client; + const date = new Date(); + date.setDate(date.getDate() + 3); + + const encodedDate = encodeURI(date.toUTCString()); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&expiry=${encodedDate}`); + + const cookie = { + name: "foo", + value: "bar", + expires: Math.floor(date.getTime() / 1000), + session: false, + }; + + try { + const { cookies } = await Network.getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function session({ client }) { + const { Network } = client; + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + + const cookie = { + name: "foo", + value: "bar", + expiry: -1, + session: true, + }; + + try { + const { cookies } = await Network.getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function path({ client }) { + const { Network } = client; + const PATH = "/browser/remote/cdp/test/browser/"; + const PARENT_PATH = "/browser/remote/cdp/test/"; + + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&path=${PATH}`); + + const cookie = { + name: "foo", + value: "bar", + path: PATH, + }; + + try { + console.log("Check exact path"); + await loadURL(`${DEFAULT_HOST}${PATH}`); + let result = await Network.getCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + assertCookie(result.cookies[0], cookie); + + console.log("Check sub path"); + await loadURL(`${DEFAULT_HOST}${SJS_PATH}`); + result = await Network.getCookies(); + is(result.cookies.length, 1, "A single cookie has been found"); + assertCookie(result.cookies[0], cookie); + + console.log("Check parent path"); + await loadURL(`${DEFAULT_HOST}${PARENT_PATH}`); + result = await Network.getCookies(); + is(result.cookies.length, 0, "No cookies have been found"); + + console.log("Check non matching path"); + await loadURL(`${DEFAULT_HOST}/foo/bar`); + result = await Network.getCookies(); + is(result.cookies.length, 0, "No cookies have been found"); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function httpOnly({ client }) { + const { Network } = client; + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&httpOnly`); + + const cookie = { + name: "foo", + value: "bar", + httpOnly: true, + }; + + try { + const { cookies } = await Network.getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function sameSite({ client }) { + const { Network } = client; + for (const value of ["Lax", "Strict"]) { + console.log(`Test cookie with SameSite=${value}`); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar&sameSite=${value}`); + + const cookie = { + name: "foo", + value: "bar", + sameSite: value, + }; + + try { + const { cookies } = await Network.getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } + } +}); + +add_task(async function testUrlsMissing({ client }) { + const { Network } = client; + await loadURL(`${DEFAULT_HOST}${BASE_PATH}/doc_get_cookies_page.html`); + await loadURL(`${DEFAULT_URL}?name=foo&value=bar`); + await loadURL(`${ALT_HOST}${SJS_PATH}?name=alt&value=true`); + + const cookie = { + name: "alt", + value: "true", + domain: ALT_DOMAIN, + }; + + try { + const { cookies } = await Network.getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function testUrls({ client }) { + const { Network } = client; + await loadURL(`${SECURE_HOST}${BASE_PATH}/doc_get_cookies_page.html`); + await loadURL(`${DEFAULT_HOST}${BASE_PATH}/doc_get_cookies_page.html`); + await loadURL(`${ALT_HOST}${SJS_PATH}?name=alt&value=true`); + + const cookie1 = { + name: "page", + value: "mainpage", + path: BASE_PATH, + domain: DEFAULT_DOMAIN, + }; + const cookie2 = { + name: "frame", + value: "subframe", + path: BASE_PATH, + domain: DEFAULT_DOMAIN, + }; + const cookie3 = { + name: "page", + value: "mainpage", + path: BASE_PATH, + domain: SECURE_DOMAIN, + }; + const cookie4 = { + name: "frame", + value: "subframe", + path: BASE_PATH, + domain: SECURE_DOMAIN, + }; + + try { + const { cookies } = await Network.getCookies({ + urls: [`${DEFAULT_HOST}${BASE_PATH}`, `${SECURE_HOST}${BASE_PATH}`], + }); + is(cookies.length, 4, "4 cookies have been found"); + assertCookie(cookies[0], cookie1); + assertCookie(cookies[1], cookie2); + assertCookie(cookies[2], cookie3); + assertCookie(cookies[3], cookie4); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function testUrlsInvalidTypes({ client }) { + const { Network } = client; + + const testTable = [null, 1, "foo", true, {}]; + + for (const testCase of testTable) { + await Assert.rejects( + Network.getCookies({ urls: testCase }), + /urls: array expected/, + `Fails the argument type for urls` + ); + } +}); + +add_task(async function testUrlsEntriesInvalidTypes({ client }) { + const { Network } = client; + + const testTable = [[null], [1], [true]]; + + for (const testCase of testTable) { + await Assert.rejects( + Network.getCookies({ urls: testCase }), + /urls: string value expected at index 0/, + `Fails the argument type for urls` + ); + } +}); + +add_task(async function testUrlsEmpty({ client }) { + const { Network } = client; + + const { cookies } = await Network.getCookies({ urls: [] }); + is(cookies.length, 0, "No cookies returned"); +}); diff --git a/remote/cdp/test/browser/network/browser_navigationEvents.js b/remote/cdp/test/browser/network/browser_navigationEvents.js new file mode 100644 index 0000000000..57680c2a57 --- /dev/null +++ b/remote/cdp/test/browser/network/browser_navigationEvents.js @@ -0,0 +1,201 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test order and consistency of Network/Page events as a whole. +// Details of specific events are checked in event-specific test files. + +// Bug 1734694: network request header mismatch when using HTTPS +const BASE_PATH = "http://example.com/browser/remote/cdp/test/browser/network"; +const FRAMESET_URL = `${BASE_PATH}/doc_frameset.html`; +const FRAMESET_JS_URL = `${BASE_PATH}/file_framesetEvents.js`; +const PAGE_URL = `${BASE_PATH}/doc_networkEvents.html`; +const PAGE_JS_URL = `${BASE_PATH}/file_networkEvents.js`; + +add_task(async function eventsForTopFrameNavigation({ client }) { + const { history, frameId: frameIdNav } = await prepareTest( + client, + FRAMESET_URL, + 10 + ); + + const documentEvents = filterEventsByType(history, "Document"); + const scriptEvents = filterEventsByType(history, "Script"); + const subdocumentEvents = filterEventsByType(history, "Subdocument"); + + is(documentEvents.length, 2, "Expected number of Document events"); + is(subdocumentEvents.length, 2, "Expected number of Subdocument events"); + is(scriptEvents.length, 4, "Expected number of Script events"); + + const navigatedEvents = history.findEvents("Page.navigate"); + is(navigatedEvents.length, 1, "Expected number of navigate done events"); + + const frameAttachedEvents = history.findEvents("Page.frameAttached"); + is(frameAttachedEvents.length, 1, "Expected number of frame attached events"); + + // network events for document and script + assertEventOrder(documentEvents[0], documentEvents[1]); + assertEventOrder(documentEvents[1], navigatedEvents[0], { + ignoreTimestamps: true, + }); + assertEventOrder(navigatedEvents[0], scriptEvents[0], { + ignoreTimestamps: true, + }); + assertEventOrder(scriptEvents[0], scriptEvents[1]); + + const docRequest = documentEvents[0].payload; + is(docRequest.documentURL, FRAMESET_URL, "documenURL matches target url"); + is(docRequest.frameId, frameIdNav, "Got the expected frame id"); + is(docRequest.request.url, FRAMESET_URL, "Got the Document request"); + + const docResponse = documentEvents[1].payload; + is(docResponse.frameId, frameIdNav, "Got the expected frame id"); + is(docResponse.response.url, FRAMESET_URL, "Got the Document response"); + ok(!!docResponse.response.headers.server, "Document response has headers"); + // TODO? response reports extra request header "upgrade-insecure-requests":"1" + // Assert.deepEqual( + // docResponse.response.requestHeaders, + // docRequest.request.headers, + // "Response event reports same request headers as request event" + // ); + + const scriptRequest = scriptEvents[0].payload; + is( + scriptRequest.documentURL, + FRAMESET_URL, + "documentURL is trigger document" + ); + is(scriptRequest.frameId, frameIdNav, "Got the expected frame id"); + is(scriptRequest.request.url, FRAMESET_JS_URL, "Got the Script request"); + + const scriptResponse = scriptEvents[1].payload; + is(scriptResponse.frameId, frameIdNav, "Got the expected frame id"); + todo( + scriptResponse.loaderId === docRequest.loaderId, + "The same loaderId is used for dependent responses (Bug 1637838)" + ); + is(scriptResponse.response.url, FRAMESET_JS_URL, "Got the Script response"); + Assert.deepEqual( + scriptResponse.response.requestHeaders, + scriptRequest.request.headers, + "Response event reports same request headers as request event" + ); + + // frame is attached after all resources of the document have been loaded + // and before sub document starts loading + assertEventOrder(scriptEvents[1], frameAttachedEvents[0], { + ignoreTimestamps: true, + }); + assertEventOrder(frameAttachedEvents[0], subdocumentEvents[0], { + ignoreTimestamps: true, + }); + + const { frameId: frameIdSubFrame, parentFrameId } = + frameAttachedEvents[0].payload; + is(parentFrameId, frameIdNav, "Got expected parent frame id"); + + // network events for subdocument and script + assertEventOrder(subdocumentEvents[0], subdocumentEvents[1]); + assertEventOrder(subdocumentEvents[1], scriptEvents[2]); + assertEventOrder(scriptEvents[2], scriptEvents[3]); + + const subdocRequest = subdocumentEvents[0].payload; + is( + subdocRequest.documentURL, + FRAMESET_URL, + "documentURL is trigger document" + ); + is(subdocRequest.frameId, frameIdSubFrame, "Got the expected frame id"); + is(subdocRequest.request.url, PAGE_URL, "Got the Subdocument request"); + + const subdocResponse = subdocumentEvents[1].payload; + is(subdocResponse.frameId, frameIdSubFrame, "Got the expected frame id"); + is(subdocResponse.response.url, PAGE_URL, "Got the Subdocument response"); + + const subscriptRequest = scriptEvents[2].payload; + is(subscriptRequest.documentURL, PAGE_URL, "documentURL is trigger document"); + is(subscriptRequest.frameId, frameIdSubFrame, "Got the expected frame id"); + is(subscriptRequest.request.url, PAGE_JS_URL, "Got the Script request"); + + const subscriptResponse = scriptEvents[3].payload; + is(subscriptResponse.frameId, frameIdSubFrame, "Got the expected frame id"); + is(subscriptResponse.response.url, PAGE_JS_URL, "Got the Script response"); + todo( + subscriptResponse.loaderId === subdocRequest.loaderId, + "The same loaderId is used for dependent responses (Bug 1637838)" + ); + Assert.deepEqual( + subscriptResponse.response.requestHeaders, + subscriptRequest.request.headers, + "Response event reports same request headers as request event" + ); + + const lifeCycleEvents = history + .findEvents("Page.lifecycleEvent") + .map(event => event.payload); + for (const { name, loaderId } of lifeCycleEvents) { + is( + loaderId, + docRequest.loaderId, + `${name} lifecycle event has same loaderId as Document request` + ); + } +}); + +async function prepareTest(client, url, totalCount) { + const REQUEST = "Network.requestWillBeSent"; + const RESPONSE = "Network.responseReceived"; + const FRAMEATTACHED = "Page.frameAttached"; + const LIFECYCLE = "Page.livecycleEvent"; + + const { Network, Page } = client; + const history = new RecordEvents(totalCount); + + history.addRecorder({ + event: Network.requestWillBeSent, + eventName: REQUEST, + messageFn: payload => { + return `Received ${REQUEST} for ${payload.request?.url}`; + }, + }); + + history.addRecorder({ + event: Network.responseReceived, + eventName: RESPONSE, + messageFn: payload => { + return `Received ${RESPONSE} for ${payload.response?.url}`; + }, + }); + + history.addRecorder({ + event: Page.frameAttached, + eventName: FRAMEATTACHED, + messageFn: ({ frameId, parentFrameId: parentId }) => { + return `Received ${FRAMEATTACHED} frame=${frameId} parent=${parentId}`; + }, + }); + + history.addRecorder({ + event: Page.lifecycleEvent, + eventName: LIFECYCLE, + messageFn: payload => { + return `Received ${LIFECYCLE} ${payload.name}`; + }, + }); + + await Network.enable(); + await Page.enable(); + + const navigateDone = history.addPromise("Page.navigate"); + const { frameId } = await Page.navigate({ url }).then(navigateDone); + ok(frameId, "Page.navigate returned a frameId"); + + info("Wait for events"); + const events = await history.record(); + + info(`Received events: ${events.map(getDescriptionForEvent)}`); + is(events.length, totalCount, "Received expected number of events"); + + return { history, frameId }; +} diff --git a/remote/cdp/test/browser/network/browser_requestWillBeSent.js b/remote/cdp/test/browser/network/browser_requestWillBeSent.js new file mode 100644 index 0000000000..9a4745952c --- /dev/null +++ b/remote/cdp/test/browser/network/browser_requestWillBeSent.js @@ -0,0 +1,224 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BASE_PATH = "https://example.com/browser/remote/cdp/test/browser/network"; +const FRAMESET_URL = `${BASE_PATH}/doc_frameset.html`; +const FRAMESET_URL_JS = `${BASE_PATH}/file_framesetEvents.js`; +const PAGE_EMPTY_URL = `${BASE_PATH}/doc_empty.html`; +const PAGE_URL = `${BASE_PATH}/doc_networkEvents.html`; +const PAGE_EMPTY_HASH = `#`; +const PAGE_HASH = `#foo`; +const PAGE_URL_WITH_HASH = `${PAGE_URL}${PAGE_HASH}`; +const PAGE_URL_WITH_EMPTY_HASH = `${PAGE_URL}${PAGE_EMPTY_HASH}`; +const PAGE_URL_JS = `${BASE_PATH}/file_networkEvents.js`; + +add_task(async function noEventsWhenNetworkDomainDisabled({ client }) { + const history = configureHistory(client, 0); + await loadURL(PAGE_URL); + + const events = await history.record(); + is(events.length, 0, "Expected no Network.responseReceived events"); +}); + +add_task(async function noEventsAfterNetworkDomainDisabled({ client }) { + const { Network } = client; + + const history = configureHistory(client, 0); + await Network.enable(); + await Network.disable(); + await loadURL(PAGE_URL); + + const events = await history.record(); + is(events.length, 0, "Expected no Network.responseReceived events"); +}); + +add_task(async function documentNavigationWithResource({ client }) { + const { Page, Network } = client; + + await Network.enable(); + await Page.enable(); + + const history = configureHistory(client, 4); + + const frameAttached = Page.frameAttached(); + const { frameId: frameIdNav } = await Page.navigate({ url: FRAMESET_URL }); + const { frameId: frameIdSubFrame } = await frameAttached; + ok(frameIdNav, "Page.navigate returned a frameId"); + + info("Wait for Network events"); + const events = await history.record(); + is(events.length, 4, "Expected number of Network.requestWillBeSent events"); + + // Check top-level document request + const docRequest = events[0].payload; + is(docRequest.type, "Document", "Document request has the expected type"); + is(docRequest.documentURL, FRAMESET_URL, "documentURL matches requested url"); + is(docRequest.frameId, frameIdNav, "Got the expected frame id"); + is(docRequest.request.url, FRAMESET_URL, "Got the Document request"); + is(docRequest.request.urlFragment, undefined, "Has no URL fragment set"); + is(docRequest.request.method, "GET", "Has the expected request method"); + is( + docRequest.requestId, + docRequest.loaderId, + "The request id is equal to the loader id" + ); + is( + docRequest.request.headers.host, + "example.com", + "Document request has headers" + ); + + // Check top-level script request + const scriptRequest = events[1].payload; + is(scriptRequest.type, "Script", "Script request has the expected type"); + is( + scriptRequest.documentURL, + FRAMESET_URL, + "documentURL is trigger document for the script request" + ); + is(scriptRequest.frameId, frameIdNav, "Got the expected frame id"); + is(scriptRequest.request.url, FRAMESET_URL_JS, "Got the Script request"); + is(scriptRequest.request.method, "GET", "Has the expected request method"); + is( + scriptRequest.request.headers.host, + "example.com", + "Script request has headers" + ); + todo( + scriptRequest.loaderId === docRequest.loaderId, + "The same loaderId is used for dependent requests (Bug 1637838)" + ); + assertEventOrder(events[0], events[1]); + + // Check subdocument request + const subdocRequest = events[2].payload; + is( + subdocRequest.type, + "Subdocument", + "Subdocument request has the expected type" + ); + is(subdocRequest.documentURL, FRAMESET_URL, "documenURL matches request url"); + is(subdocRequest.frameId, frameIdSubFrame, "Got the expected frame id"); + is( + subdocRequest.requestId, + subdocRequest.loaderId, + "The request id is equal to the loader id" + ); + is(subdocRequest.request.url, PAGE_URL, "Got the Subdocument request"); + is(subdocRequest.request.method, "GET", "Has the expected request method"); + is( + subdocRequest.request.headers.host, + "example.com", + "Subdocument request has headers" + ); + assertEventOrder(events[1], events[2]); + + // Check script request (frame) + const subscriptRequest = events[3].payload; + is(subscriptRequest.type, "Script", "Script request has the expected type"); + is( + subscriptRequest.documentURL, + PAGE_URL, + "documentURL is trigger document for the script request" + ); + is(subscriptRequest.frameId, frameIdSubFrame, "Got the expected frame id"); + todo( + subscriptRequest.loaderId === docRequest.loaderId, + "The same loaderId is used for dependent requests (Bug 1637838)" + ); + is(subscriptRequest.request.url, PAGE_URL_JS, "Got the Script request"); + is( + subscriptRequest.request.method, + "GET", + "Script request has the expected method" + ); + is( + subscriptRequest.request.headers.host, + "example.com", + "Script request has headers" + ); + assertEventOrder(events[2], events[3]); +}); + +add_task(async function documentNavigationToURLWithHash({ client }) { + const { Page, Network } = client; + + await loadURL(PAGE_EMPTY_URL); + + await Network.enable(); + await Page.enable(); + + const history = configureHistory(client, 4); + + const frameNavigated = Page.frameNavigated(); + const { frameId: frameIdNav } = await Page.navigate({ + url: PAGE_URL_WITH_HASH, + }); + await frameNavigated; + ok(frameIdNav, "Page.navigate returned a frameId"); + + info("Wait for Network events"); + const events = await history.record(); + is(events.length, 2, "Expected number of Network.requestWillBeSent events"); + + // Check top-level document request only for fragment usage + const docRequest = events[0].payload; + is(docRequest.documentURL, PAGE_URL, "documentURL matches requested URL"); + is(docRequest.request.url, PAGE_URL, "Request url matches requested URL"); + is( + docRequest.request.urlFragment, + PAGE_HASH, + "Request URL fragment is present" + ); +}); + +add_task(async function documentNavigationToURLWithEmptyHash({ client }) { + const { Page, Network } = client; + + await loadURL(PAGE_EMPTY_URL); + + await Network.enable(); + await Page.enable(); + + const history = configureHistory(client, 4); + + const frameNavigated = Page.frameNavigated(); + const { frameId: frameIdNav } = await Page.navigate({ + url: PAGE_URL_WITH_EMPTY_HASH, + }); + await frameNavigated; + ok(frameIdNav, "Page.navigate returned a frameId"); + + info("Wait for Network events"); + const events = await history.record(); + is(events.length, 2, "Expected number of Network.requestWillBeSent events"); + + // Check top-level document request only for fragment usage + const docRequest = events[0].payload; + is(docRequest.documentURL, PAGE_URL, "documentURL matches requested URL"); + is(docRequest.request.url, PAGE_URL, "Request url matches requested URL"); + is( + docRequest.request.urlFragment, + PAGE_EMPTY_HASH, + "Request URL fragment is present" + ); +}); + +function configureHistory(client, total) { + const REQUEST = "Network.requestWillBeSent"; + + const { Network } = client; + const history = new RecordEvents(total); + + history.addRecorder({ + event: Network.requestWillBeSent, + eventName: REQUEST, + messageFn: payload => { + return `Received ${REQUEST} for ${payload.request.url}`; + }, + }); + + return history; +} diff --git a/remote/cdp/test/browser/network/browser_responseReceived.js b/remote/cdp/test/browser/network/browser_responseReceived.js new file mode 100644 index 0000000000..41e854de8a --- /dev/null +++ b/remote/cdp/test/browser/network/browser_responseReceived.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BASE_PATH = "https://example.com/browser/remote/cdp/test/browser/network"; +const FRAMESET_URL = `${BASE_PATH}/doc_frameset.html`; +const FRAMESET_JS_URL = `${BASE_PATH}/file_framesetEvents.js`; +const PAGE_URL = `${BASE_PATH}/doc_networkEvents.html`; +const PAGE_JS_URL = `${BASE_PATH}/file_networkEvents.js`; + +add_task(async function noEventsWhenNetworkDomainDisabled({ client }) { + const history = configureHistory(client, 0); + await loadURL(PAGE_URL); + + const events = await history.record(); + is(events.length, 0, "Expected no Network.responseReceived events"); +}); + +add_task(async function noEventsAfterNetworkDomainDisabled({ client }) { + const { Network } = client; + + const history = configureHistory(client, 0); + await Network.enable(); + await Network.disable(); + await loadURL(PAGE_URL); + + const events = await history.record(); + is(events.length, 0, "Expected no Network.responseReceived events"); +}); + +add_task(async function documentNavigationWithResource({ client }) { + const { Page, Network } = client; + + await Network.enable(); + await Page.enable(); + + const history = configureHistory(client, 4); + + const frameAttached = Page.frameAttached(); + const { frameId: frameIdNav } = await Page.navigate({ url: FRAMESET_URL }); + const { frameId: frameIdSubframe } = await frameAttached; + ok(frameIdNav, "Page.navigate returned a frameId"); + + info("Wait for Network events"); + const events = await history.record(); + is(events.length, 4, "Expected number of Network.responseReceived events"); + + // Check top-level document response + const docResponse = events[0].payload; + is(docResponse.type, "Document", "Document response has expected type"); + is(docResponse.frameId, frameIdNav, "Got the expected frame id"); + is( + docResponse.requestId, + docResponse.loaderId, + "The response id is equal to the loader id" + ); + is(docResponse.response.url, FRAMESET_URL, "Got the Document response"); + is( + docResponse.response.mimeType, + "text/html", + "Document response has expected mimeType" + ); + ok(!!docResponse.response.headers.server, "Document response has headers"); + is(docResponse.response.status, 200, "Document response has expected status"); + is( + docResponse.response.statusText, + "OK", + "Document response has expected status text" + ); + if (docResponse.response.fromDiskCache === false) { + is( + docResponse.response.remoteIPAddress, + "127.0.0.1", + "Document response has the expected IP address" + ); + ok( + typeof docResponse.response.remotePort == "number", + "Document response has a remotePort" + ); + } + is( + docResponse.response.protocol, + "http/1.1", + "Document response has expected protocol" + ); + + // Check top-level script response + const scriptResponse = events[1].payload; + is(scriptResponse.type, "Script", "Script response has expected type"); + is(scriptResponse.frameId, frameIdNav, "Got the expected frame id"); + is(scriptResponse.response.url, FRAMESET_JS_URL, "Got the Script response"); + is( + scriptResponse.response.mimeType, + "application/x-javascript", + "Script response has expected mimeType" + ); + ok(!!scriptResponse.response.headers.server, "Script response has headers"); + is( + scriptResponse.response.status, + 200, + "Script response has the expected status" + ); + is( + scriptResponse.response.statusText, + "OK", + "Script response has the expected status text" + ); + if (scriptResponse.response.fromDiskCache === false) { + is( + scriptResponse.response.remoteIPAddress, + docResponse.response.remoteIPAddress, + "Script response has same IP address as document response" + ); + ok( + typeof scriptResponse.response.remotePort == "number", + "Script response has a remotePort" + ); + } + is( + scriptResponse.response.protocol, + "http/1.1", + "Script response has the expected protocol" + ); + + // Check subdocument response + const frameDocResponse = events[2].payload; + is( + frameDocResponse.type, + "Subdocument", + "Subdocument response has expected type" + ); + is(frameDocResponse.frameId, frameIdSubframe, "Got the expected frame id"); + is( + frameDocResponse.requestId, + frameDocResponse.loaderId, + "The response id is equal to the loader id" + ); + is( + frameDocResponse.response.url, + PAGE_URL, + "Got the expected Document response" + ); + is( + frameDocResponse.response.mimeType, + "text/html", + "Document response has expected mimeType" + ); + ok( + !!frameDocResponse.response.headers.server, + "Subdocument response has headers" + ); + is( + frameDocResponse.response.status, + 200, + "Subdocument response has expected status" + ); + is( + frameDocResponse.response.statusText, + "OK", + "Subdocument response has expected status text" + ); + if (frameDocResponse.response.fromDiskCache === false) { + is( + frameDocResponse.response.remoteIPAddress, + "127.0.0.1", + "Subdocument response has the expected IP address" + ); + ok( + typeof frameDocResponse.response.remotePort == "number", + "Subdocument response has a remotePort" + ); + } + is( + frameDocResponse.response.protocol, + "http/1.1", + "Subdocument response has expected protocol" + ); + + // Check frame script response + const frameScriptResponse = events[3].payload; + is(frameScriptResponse.type, "Script", "Script response has expected type"); + is(frameScriptResponse.frameId, frameIdSubframe, "Got the expected frame id"); + is(frameScriptResponse.response.url, PAGE_JS_URL, "Got the Script response"); + is( + frameScriptResponse.response.mimeType, + "application/x-javascript", + "Script response has expected mimeType" + ); + ok( + !!frameScriptResponse.response.headers.server, + "Script response has headers" + ); + is( + frameScriptResponse.response.status, + 200, + "Script response has the expected status" + ); + is( + frameScriptResponse.response.statusText, + "OK", + "Script response has the expected status text" + ); + if (frameScriptResponse.response.fromDiskCache === false) { + is( + frameScriptResponse.response.remoteIPAddress, + docResponse.response.remoteIPAddress, + "Script response has same IP address as document response" + ); + ok( + typeof frameScriptResponse.response.remotePort == "number", + "Script response has a remotePort" + ); + } + is( + frameScriptResponse.response.protocol, + "http/1.1", + "Script response has the expected protocol" + ); +}); + +function configureHistory(client, total) { + const RESPONSE = "Network.responseReceived"; + + const { Network } = client; + const history = new RecordEvents(total); + + history.addRecorder({ + event: Network.responseReceived, + eventName: RESPONSE, + messageFn: payload => { + return `Received ${RESPONSE} for ${payload.response.url}`; + }, + }); + return history; +} diff --git a/remote/cdp/test/browser/network/browser_setCacheDisabled.js b/remote/cdp/test/browser/network/browser_setCacheDisabled.js new file mode 100644 index 0000000000..4af9f0fc07 --- /dev/null +++ b/remote/cdp/test/browser/network/browser_setCacheDisabled.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { INHIBIT_CACHING, LOAD_BYPASS_CACHE, LOAD_NORMAL } = Ci.nsIRequest; + +const TEST_PAGE = + "https://example.com/browser/remote/cdp/test/browser/network/doc_empty.html"; + +add_task(async function cacheEnabledAfterDisabled({ client }) { + const { Network } = client; + await Network.setCacheDisabled({ cacheDisabled: true }); + await Network.setCacheDisabled({ cacheDisabled: false }); + + await watchLoadFlags(LOAD_NORMAL, TEST_PAGE); + await loadURL(TEST_PAGE); + await waitForLoadFlags(); +}); + +add_task(async function cacheEnabledByDefault({ Network }) { + await watchLoadFlags(LOAD_NORMAL, TEST_PAGE); + await loadURL(TEST_PAGE); + await waitForLoadFlags(); +}); + +add_task(async function cacheDisabled({ client }) { + const { Network } = client; + await Network.setCacheDisabled({ cacheDisabled: true }); + + await watchLoadFlags(LOAD_BYPASS_CACHE | INHIBIT_CACHING, TEST_PAGE); + await loadURL(TEST_PAGE); + await waitForLoadFlags(); +}); + +// This helper will resolve when the content-process progressListener is started +// and ready to monitor requests. +// The promise itself will resolve when the progressListener will detect the +// expected flags for the provided url. +function watchLoadFlags(flags, url) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [{ flags, url }], + async (options = {}) => { + const { flags, url } = options; + + // an nsIWebProgressListener that checks all requests made by the docShell + // have the flags we expect. + var RequestWatcher = { + init(docShell, expectedLoadFlags, url, callback) { + this.callback = callback; + this.docShell = docShell; + this.expectedLoadFlags = expectedLoadFlags; + this.url = url; + + this.requestCount = 0; + + const { NOTIFY_STATE_DOCUMENT, NOTIFY_STATE_REQUEST } = + Ci.nsIWebProgress; + + this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress) + .addProgressListener( + this, + NOTIFY_STATE_DOCUMENT | NOTIFY_STATE_REQUEST + ); + }, + + onStateChange(webProgress, request, flags, status) { + // We are checking requests - if there isn't one, ignore it. + if (!request) { + return; + } + + // We will usually see requests for 'about:document-onload-blocker' not + // have the flag, so we just ignore them. + // We also see, eg, resource://gre-resources/loading-image.png, so + // skip resource:// URLs too. + // We may also see, eg, chrome://global/skin/icons/chevron.svg, so + // skip chrome:// URLs too. + if ( + request.name.startsWith("about:") || + request.name.startsWith("resource:") || + request.name.startsWith("chrome:") + ) { + return; + } + is( + request.loadFlags & this.expectedLoadFlags, + this.expectedLoadFlags, + "request " + request.name + " has the expected flags" + ); + this.requestCount += 1; + + var stopFlags = + Ci.nsIWebProgressListener.STATE_STOP | + Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + + if (request.name == this.url && (flags & stopFlags) == stopFlags) { + this.docShell.removeProgressListener(this); + ok( + this.requestCount > 1, + this.url + " saw " + this.requestCount + " requests" + ); + this.callback(); + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + // Store the promise that should be awaited for on the current window. + content.resolveCheckLoadFlags = new Promise(resolve => { + RequestWatcher.init(docShell, flags, url, resolve); + }); + } + ); +} + +// Wait for the latest promise created in-content by watchLoadFlags to resolve. +function waitForLoadFlags() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + await content.resolveCheckLoadFlags; + delete content.resolveCheckLoadFlags; + }); +} diff --git a/remote/cdp/test/browser/network/browser_setCookie.js b/remote/cdp/test/browser/network/browser_setCookie.js new file mode 100644 index 0000000000..42a6c6ace4 --- /dev/null +++ b/remote/cdp/test/browser/network/browser_setCookie.js @@ -0,0 +1,291 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SJS_PATH = "/browser/remote/cdp/test/browser/network/sjs-cookies.sjs"; + +const DEFAULT_HOST = "example.org"; +const ALT_HOST = "foo.example.org"; +const SECURE_HOST = "example.com"; + +add_task(async function failureWithoutArguments({ client }) { + const { Network } = client; + + await Assert.rejects( + Network.setCookie(), + err => err.message.includes("name: string value expected"), + "Fails without any arguments" + ); +}); + +add_task(async function failureWithMissingNameAndValue({ client }) { + const { Network } = client; + + await Assert.rejects( + Network.setCookie({ + value: "bar", + domain: "example.org", + }), + err => err.message.includes("name: string value expected"), + "Fails without name specified" + ); + + await Assert.rejects( + Network.setCookie({ + name: "foo", + domain: "example.org", + }), + err => err.message.includes("value: string value expected"), + "Fails without value specified" + ); +}); + +add_task(async function failureWithMissingDomainAndURL({ client }) { + const { Network } = client; + + await Assert.rejects( + Network.setCookie({ name: "foo", value: "bar" }), + err => + err.message.includes( + "At least one of the url and domain needs to be specified" + ), + "Fails without domain and URL specified" + ); +}); + +add_task(async function setCookieWithDomain({ client }) { + const { Network } = client; + + const cookie = { + name: "foo", + value: "bar", + domain: ALT_HOST, + }; + + try { + const { success } = await Network.setCookie(cookie); + ok(success, "Cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function setCookieWithEmptyDomain({ client }) { + const { Network } = client; + + try { + const { success } = await Network.setCookie({ + name: "foo", + value: "bar", + url: "", + }); + ok(!success, "Cookie has not been set"); + + const cookies = getCookies(); + is(cookies.length, 0, "No cookie has been found"); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function setCookieWithURL({ client }) { + const { Network } = client; + + const cookie = { + name: "foo", + value: "bar", + domain: ALT_HOST, + }; + + try { + const { success } = await Network.setCookie({ + name: cookie.name, + value: cookie.value, + url: `http://${ALT_HOST}`, + }); + ok(success, "Cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function setCookieWithEmptyURL({ client }) { + const { Network } = client; + + try { + const { success } = await Network.setCookie({ + name: "foo", + value: "bar", + url: "", + }); + ok(!success, "No cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 0, "No cookie has been found"); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function setCookieWithDomainAndURL({ client }) { + const { Network } = client; + + const cookie = { + name: "foo", + value: "bar", + domain: ALT_HOST, + }; + + try { + const { success } = await Network.setCookie({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + url: `http://${DEFAULT_HOST}`, + }); + ok(success, "Cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function setCookieWithHttpOnly({ client }) { + const { Network } = client; + + const cookie = { + name: "foo", + value: "bar", + domain: DEFAULT_HOST, + httpOnly: true, + }; + + try { + const { success } = await Network.setCookie(cookie); + ok(success, "Cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function setCookieWithExpiry({ client }) { + const { Network } = client; + + const tomorrow = Math.floor(Date.now() / 1000) + 60 * 60 * 24; + + const cookie = { + name: "foo", + value: "bar", + domain: DEFAULT_HOST, + expires: tomorrow, + session: false, + }; + + try { + const { success } = await Network.setCookie(cookie); + ok(success, "Cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function setCookieWithPath({ client }) { + const { Network } = client; + + const cookie = { + name: "foo", + value: "bar", + domain: ALT_HOST, + path: SJS_PATH, + }; + + try { + const { success } = await Network.setCookie(cookie); + ok(success, "Cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function testAddSameSiteCookie({ client }) { + const { Network } = client; + + for (const sameSite of ["None", "Lax", "Strict"]) { + console.log(`Check same site value: ${sameSite}`); + const cookie = { + name: "foo", + value: "bar", + domain: DEFAULT_HOST, + }; + if (sameSite != "None") { + cookie.sameSite = sameSite; + } + + try { + const { success } = await Network.setCookie({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + sameSite, + }); + ok(success, "Cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + } finally { + Services.cookies.removeAll(); + } + } +}); + +add_task(async function testAddSecureCookie({ client }) { + const { Network } = client; + + const cookie = { + name: "foo", + value: "bar", + domain: "example.com", + secure: true, + }; + + try { + const { success } = await Network.setCookie({ + name: cookie.name, + value: cookie.value, + url: `https://${SECURE_HOST}`, + }); + ok(success, "Cookie has been set"); + + const cookies = getCookies(); + is(cookies.length, 1, "A single cookie has been found"); + assertCookie(cookies[0], cookie); + ok(cookies[0].secure, `Cookie for HTTPS is secure`); + } finally { + Services.cookies.removeAll(); + } +}); diff --git a/remote/cdp/test/browser/network/browser_setCookies.js b/remote/cdp/test/browser/network/browser_setCookies.js new file mode 100644 index 0000000000..53c6021e1c --- /dev/null +++ b/remote/cdp/test/browser/network/browser_setCookies.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const ALT_HOST = "foo.example.org"; +const DEFAULT_HOST = "example.org"; + +add_task(async function failureWithoutArguments({ client }) { + const { Network } = client; + + await Assert.rejects( + Network.setCookies(), + err => err.message.includes("Invalid parameters (cookies: array expected)"), + "Fails without any arguments" + ); +}); + +add_task(async function setCookies({ client }) { + const { Network } = client; + + const expected_cookies = [ + { + name: "foo", + value: "bar", + domain: DEFAULT_HOST, + }, + { + name: "user", + value: "password", + domain: ALT_HOST, + }, + ]; + + try { + await Network.setCookies({ cookies: expected_cookies }); + + const cookies = getCookies(); + cookies.sort((a, b) => a.name.localeCompare(b.name)); + is(cookies.length, expected_cookies.length, "Correct number of cookies"); + assertCookie(cookies[0], expected_cookies[0]); + assertCookie(cookies[1], expected_cookies[1]); + } finally { + Services.cookies.removeAll(); + } +}); + +add_task(async function setCookiesWithInvalidField({ client }) { + const { Network } = client; + + const cookies = [ + { + name: "foo", + value: "bar", + domain: "", + }, + ]; + + await Assert.rejects( + Network.setCookies({ cookies }), + err => err.message.includes("Invalid cookie fields"), + "Fails with an invalid field" + ); +}); diff --git a/remote/cdp/test/browser/network/browser_setUserAgentOverride.js b/remote/cdp/test/browser/network/browser_setUserAgentOverride.js new file mode 100644 index 0000000000..911a0a296c --- /dev/null +++ b/remote/cdp/test/browser/network/browser_setUserAgentOverride.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const DOC = toDataURL(`<script>document.write(navigator.userAgent);</script>`); + +// Network.setUserAgentOverride is a redirect to Emulation.setUserAgentOverride. +// Run at least one test for setting and resetting the user agent to make sure +// that the redirect works. + +add_task(async function forwardToEmulation({ client }) { + const { Network } = client; + const userAgent = "Mozilla/5.0 (rv: 23) Romanesco/42.0"; + const platform = "foobar"; + + await loadURL(DOC); + const originalUserAgent = await getNavigatorProperty("userAgent"); + const originalPlatform = await getNavigatorProperty("platform"); + + isnot(originalUserAgent, userAgent, "Custom user agent hasn't been set"); + isnot(originalPlatform, platform, "Custom platform hasn't been set"); + + await Network.setUserAgentOverride({ userAgent, platform }); + await loadURL(DOC); + is( + await getNavigatorProperty("userAgent"), + userAgent, + "Custom user agent has been set" + ); + is( + await getNavigatorProperty("platform"), + platform, + "Custom platform has been set" + ); + + await Network.setUserAgentOverride({ userAgent: "", platform: "" }); + await loadURL(DOC); + is( + await getNavigatorProperty("userAgent"), + originalUserAgent, + "Custom user agent has been reset" + ); + is( + await getNavigatorProperty("platform"), + originalPlatform, + "Custom platform has been reset" + ); + + await Network.setUserAgentOverride({ userAgent, platform }); + await loadURL(DOC); + is( + await getNavigatorProperty("userAgent"), + userAgent, + "Custom user agent has been set" + ); + is( + await getNavigatorProperty("platform"), + platform, + "Custom platform has been set" + ); +}); + +async function getNavigatorProperty(prop) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [prop], _prop => { + return content.navigator[_prop]; + }); +} diff --git a/remote/cdp/test/browser/network/doc_empty.html b/remote/cdp/test/browser/network/doc_empty.html new file mode 100644 index 0000000000..779a0ab052 --- /dev/null +++ b/remote/cdp/test/browser/network/doc_empty.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test page</title> +</head> +<body> + <div id="content">Example page</div> +</body> +</html> diff --git a/remote/cdp/test/browser/network/doc_frameset.html b/remote/cdp/test/browser/network/doc_frameset.html new file mode 100644 index 0000000000..2710312381 --- /dev/null +++ b/remote/cdp/test/browser/network/doc_frameset.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Frameset for Network events</title> + <script type="text/javascript" src="file_framesetEvents.js"></script> +</head> +<body> + <iframe src="doc_networkEvents.html"></iframe> +</body> +</html> diff --git a/remote/cdp/test/browser/network/doc_get_cookies_frame.html b/remote/cdp/test/browser/network/doc_get_cookies_frame.html new file mode 100644 index 0000000000..2996b91589 --- /dev/null +++ b/remote/cdp/test/browser/network/doc_get_cookies_frame.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Sub Frame to get cookies</title> +</head> + +<body> + <div id="content">Example Sub Frame</div> + <script type="text/javascript"> + document.cookie = "frame=subframe"; + </script> +</body> + +</html> diff --git a/remote/cdp/test/browser/network/doc_get_cookies_page.html b/remote/cdp/test/browser/network/doc_get_cookies_page.html new file mode 100644 index 0000000000..c62c65cf66 --- /dev/null +++ b/remote/cdp/test/browser/network/doc_get_cookies_page.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="utf-8"> + <title>Page to get cookies</title> + +</head> + +<body> + <iframe src="doc_get_cookies_frame.html"></iframe> + <script type="text/javascript"> + document.cookie = "page=mainpage"; + </script> +</body> + +</html> diff --git a/remote/cdp/test/browser/network/doc_networkEvents.html b/remote/cdp/test/browser/network/doc_networkEvents.html new file mode 100644 index 0000000000..0c03609337 --- /dev/null +++ b/remote/cdp/test/browser/network/doc_networkEvents.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test page for Network events</title> + <script type="text/javascript" src="file_networkEvents.js"></script> +</head> +<body> +</body> +</html> diff --git a/remote/cdp/test/browser/network/file_framesetEvents.js b/remote/cdp/test/browser/network/file_framesetEvents.js new file mode 100644 index 0000000000..4771d96f79 --- /dev/null +++ b/remote/cdp/test/browser/network/file_framesetEvents.js @@ -0,0 +1,3 @@ +/* eslint-disable no-unused-vars */ +// Test file to emit Network events. +var foo = true; diff --git a/remote/cdp/test/browser/network/file_networkEvents.js b/remote/cdp/test/browser/network/file_networkEvents.js new file mode 100644 index 0000000000..4771d96f79 --- /dev/null +++ b/remote/cdp/test/browser/network/file_networkEvents.js @@ -0,0 +1,3 @@ +/* eslint-disable no-unused-vars */ +// Test file to emit Network events. +var foo = true; diff --git a/remote/cdp/test/browser/network/head.js b/remote/cdp/test/browser/network/head.js new file mode 100644 index 0000000000..3347e79e0f --- /dev/null +++ b/remote/cdp/test/browser/network/head.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); + +function assertCookie(cookie, expected = {}) { + const { + name = "", + value = "", + domain = "example.org", + path = "/", + expires = -1, + size = name.length + value.length, + httpOnly = false, + secure = false, + session = true, + sameSite, + } = expected; + + const expectedCookie = { + name, + value, + domain, + path, + expires, + size, + httpOnly, + secure, + session, + }; + + if (sameSite) { + expectedCookie.sameSite = sameSite; + } + + Assert.deepEqual(cookie, expectedCookie); +} + +function assertEventOrder(first, second, options = {}) { + const { ignoreTimestamps = false } = options; + + const firstDescription = getDescriptionForEvent(first); + const secondDescription = getDescriptionForEvent(second); + + ok( + first.index < second.index, + `${firstDescription} received before ${secondDescription})` + ); + + if (!ignoreTimestamps) { + ok( + first.payload.timestamp <= second.payload.timestamp, + `Timestamp of ${firstDescription}) is earlier than ${secondDescription})` + ); + } +} + +function filterEventsByType(history, type) { + return history.filter(event => event.payload.type == type); +} + +function getCookies() { + return Services.cookies.cookies.map(cookie => { + const data = { + name: cookie.name, + value: cookie.value, + domain: cookie.host, + path: cookie.path, + expires: cookie.isSession ? -1 : cookie.expiry, + // The size is the combined length of both the cookie name and value + size: cookie.name.length + cookie.value.length, + httpOnly: cookie.isHttpOnly, + secure: cookie.isSecure, + session: cookie.isSession, + }; + + if (cookie.sameSite) { + const sameSiteMap = new Map([ + [Ci.nsICookie.SAMESITE_LAX, "Lax"], + [Ci.nsICookie.SAMESITE_STRICT, "Strict"], + ]); + + data.sameSite = sameSiteMap.get(cookie.sameSite); + } + + return data; + }); +} + +function getDescriptionForEvent(event) { + const { eventName, payload } = event; + + return `${eventName}(${payload.type || payload.name || payload.frameId})`; +} diff --git a/remote/cdp/test/browser/network/sjs-cookies.sjs b/remote/cdp/test/browser/network/sjs-cookies.sjs new file mode 100644 index 0000000000..8cc0ec67ed --- /dev/null +++ b/remote/cdp/test/browser/network/sjs-cookies.sjs @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + const queryString = new URLSearchParams(request.queryString); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain; charset=utf-8", false); + + if (queryString.has("name") && queryString.has("value")) { + const name = queryString.get("name"); + const value = queryString.get("value"); + const path = queryString.get("path") || "/"; + + const expiry = queryString.get("expiry"); + const httpOnly = queryString.has("httpOnly"); + const secure = queryString.has("secure"); + const sameSite = queryString.get("sameSite"); + + let cookie = `${name}=${value}; Path=${path}`; + + if (expiry) { + cookie += `; Expires=${expiry}`; + } + + if (httpOnly) { + cookie += "; HttpOnly"; + } + + if (sameSite != undefined) { + cookie += `; sameSite=${sameSite}`; + } + + if (secure) { + cookie += "; Secure"; + } + + response.setHeader("Set-Cookie", cookie, true); + response.write(`Set cookie: ${cookie}`); + } +} diff --git a/remote/cdp/test/browser/page/browser.toml b/remote/cdp/test/browser/page/browser.toml new file mode 100644 index 0000000000..23d1b0fde5 --- /dev/null +++ b/remote/cdp/test/browser/page/browser.toml @@ -0,0 +1,81 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", + "doc_empty.html", + "doc_frame.html", + "doc_frameset_multi.html", + "doc_frameset_nested.html", + "doc_frameset_single.html", + "sjs_redirect.sjs", +] + +["browser_bringToFront.js"] + +["browser_captureScreenshot.js"] + +["browser_createIsolatedWorld.js"] + +["browser_domContentEventFired.js"] + +["browser_frameAttached.js"] + +["browser_frameDetached.js"] + +["browser_frameNavigated.js"] + +["browser_frameStartedLoading.js"] + +["browser_frameStoppedLoading.js"] + +["browser_getFrameTree.js"] + +["browser_getLayoutMetrics.js"] + +["browser_getNavigationHistory.js"] + +["browser_javascriptDialog_alert.js"] + +["browser_javascriptDialog_beforeunload.js"] + +["browser_javascriptDialog_confirm.js"] + +["browser_javascriptDialog_otherTarget.js"] + +["browser_javascriptDialog_prompt.js"] + +["browser_lifecycleEvent.js"] +https_first_disabled = true + +["browser_loadEventFired.js"] + +["browser_navigate.js"] +https_first_disabled = true + +["browser_navigateToHistoryEntry.js"] + +["browser_navigatedWithinDocument.js"] + +["browser_navigationEvents.js"] + +["browser_printToPDF.js"] + +["browser_reload.js"] + +["browser_runtimeEvents.js"] + +["browser_scriptToEvaluateOnNewDocument.js"] diff --git a/remote/cdp/test/browser/page/browser_bringToFront.js b/remote/cdp/test/browser/page/browser_bringToFront.js new file mode 100644 index 0000000000..ed00071922 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_bringToFront.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const FIRST_DOC = toDataURL("first"); +const SECOND_DOC = toDataURL("second"); + +add_task(async function testBringToFrontUpdatesSelectedTab({ client }) { + const tab = gBrowser.selectedTab; + + await loadURL(FIRST_DOC); + + info("Open another tab that should become the front tab"); + const otherTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SECOND_DOC + ); + + try { + is(gBrowser.selectedTab, otherTab, "Selected tab is now the new tab"); + + const { Page } = client; + info( + "Call Page.bringToFront() and check that the test tab becomes the selected tab" + ); + await Page.bringToFront(); + is(gBrowser.selectedTab, tab, "Selected tab is the target tab again"); + is(tab.ownerGlobal, getFocusedNavigator(), "The initial window is focused"); + } finally { + BrowserTestUtils.removeTab(otherTab); + } +}); + +add_task(async function testBringToFrontUpdatesFocusedWindow({ client }) { + const tab = gBrowser.selectedTab; + + await loadURL(FIRST_DOC); + + is(tab.ownerGlobal, getFocusedNavigator(), "The initial window is focused"); + + const otherWindow = await BrowserTestUtils.openNewBrowserWindow(); + + try { + is(otherWindow, getFocusedNavigator(), "The new window is focused"); + + const { Page } = client; + info( + "Call Page.bringToFront() and check that the tab window is focused again" + ); + await Page.bringToFront(); + is( + tab.ownerGlobal, + getFocusedNavigator(), + "The initial window is focused again" + ); + } finally { + await BrowserTestUtils.closeWindow(otherWindow); + } +}); + +function getFocusedNavigator() { + return Services.wm.getMostRecentWindow("navigator:browser"); +} diff --git a/remote/cdp/test/browser/page/browser_captureScreenshot.js b/remote/cdp/test/browser/page/browser_captureScreenshot.js new file mode 100644 index 0000000000..ecd688fd14 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_captureScreenshot.js @@ -0,0 +1,553 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function documentSmallerThanViewport({ client }) { + const { Page } = client; + + await loadURLWithElement(); + + info("Check that captureScreenshot() captures the viewport by default"); + const { data } = await Page.captureScreenshot(); + ok(!!data, "Screenshot data is not empty"); + + const scale = await getDevicePixelRatio(); + const viewport = await getViewportSize(); + const { mimeType, width, height } = await getImageDetails(data); + + is(mimeType, "image/png", "Screenshot has correct MIME type"); + is(width, (viewport.width - viewport.x) * scale, "Image has expected width"); + is( + height, + (viewport.height - viewport.y) * scale, + "Image has expected height" + ); +}); + +add_task(async function documentLargerThanViewport({ client }) { + const { Page } = client; + + await loadURL(toDataURL("<div style='margin: 100vh 100vw'>Hello world")); + + info("Check that captureScreenshot() captures the viewport by default"); + const { data } = await Page.captureScreenshot(); + ok(!!data, "Screenshot data is not empty"); + + const scale = await getDevicePixelRatio(); + const scrollbarSize = await getScrollbarSize(); + const viewport = await getViewportSize(); + const { mimeType, width, height } = await getImageDetails(data); + + is(mimeType, "image/png", "Screenshot has correct MIME type"); + is( + width, + (viewport.width - viewport.x - scrollbarSize.width) * scale, + "Image has expected width" + ); + is( + height, + (viewport.height - viewport.y - scrollbarSize.height) * scale, + "Image has expected height" + ); +}); + +add_task(async function invalidFormat({ client }) { + const { Page } = client; + await loadURL(toDataURL("<div>Hello world")); + + await Assert.rejects( + Page.captureScreenshot({ format: "foo" }), + err => err.message.includes(`Unsupported MIME type: image`), + "captureScreenshot raised error for invalid image format" + ); +}); + +add_task(async function asJPEGFormat({ client }) { + const { Page } = client; + await loadURL(toDataURL("<div>Hello world")); + + info("Check that captureScreenshot() captures as JPEG format"); + const { data } = await Page.captureScreenshot({ format: "jpeg" }); + ok(!!data, "Screenshot data is not empty"); + + const scale = await getDevicePixelRatio(); + const viewport = await getViewportSize(); + const { mimeType, height, width } = await getImageDetails(data); + + is(mimeType, "image/jpeg", "Screenshot has correct MIME type"); + is(width, (viewport.width - viewport.x) * scale); + is(height, (viewport.height - viewport.y) * scale); +}); + +add_task(async function asJPEGFormatAndQuality({ client }) { + const { Page } = client; + await loadURL(toDataURL("<div>Hello world")); + + info("Check that captureScreenshot() captures as JPEG format"); + const imageDefault = await Page.captureScreenshot({ format: "jpeg" }); + ok(!!imageDefault, "Screenshot data with default quality is not empty"); + + const image100 = await Page.captureScreenshot({ + format: "jpeg", + quality: 100, + }); + ok(!!image100, "Screenshot data with quality 100 is not empty"); + + const image10 = await Page.captureScreenshot({ + format: "jpeg", + quality: 10, + }); + ok(!!image10, "Screenshot data with quality 10 is not empty"); + + const infoDefault = await getImageDetails(imageDefault.data); + const info100 = await getImageDetails(image100.data); + const info10 = await getImageDetails(image10.data); + + // All screenshots are of mimeType JPEG + is( + infoDefault.mimeType, + "image/jpeg", + "Screenshot with default quality has correct MIME type" + ); + is( + info100.mimeType, + "image/jpeg", + "Screenshot with quality 100 has correct MIME type" + ); + is( + info10.mimeType, + "image/jpeg", + "Screenshot with quality 10 has correct MIME type" + ); + + const scale = await getDevicePixelRatio(); + const viewport = await getViewportSize(); + + // Images are all of the same dimension + is(infoDefault.width, (viewport.width - viewport.x) * scale); + is(infoDefault.height, (viewport.height - viewport.y) * scale); + + is(info100.width, (viewport.width - viewport.x) * scale); + is(info100.height, (viewport.height - viewport.y) * scale); + + is(info10.width, (viewport.width - viewport.x) * scale); + is(info10.height, (viewport.height - viewport.y) * scale); + + // Images of different quality result in different content sizes + ok( + info100.length > infoDefault.length, + "Size of quality 100 is larger than default" + ); + ok( + info10.length < infoDefault.length, + "Size of quality 10 is smaller than default" + ); +}); + +add_task(async function clipMissingProperties({ client }) { + const { Page } = client; + const contentSize = await getContentSize(); + + for (const prop of ["x", "y", "width", "height", "scale"]) { + console.info(`Check for missing ${prop}`); + + const clip = { + x: 0, + y: 0, + width: contentSize.width, + height: contentSize.height, + }; + clip[prop] = undefined; + + await Assert.rejects( + Page.captureScreenshot({ clip }), + err => err.message.includes(`clip.${prop}: double value expected`), + `raised error for missing clip.${prop} property` + ); + } +}); + +add_task(async function clipOutOfBoundsXAndY({ client }) { + const { Page } = client; + + const ratio = await getDevicePixelRatio(); + const size = 50; + + await loadURLWithElement(); + const contentSize = await getContentSize(); + + var { data: refData } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: size, + height: size, + scale: 1, + }, + }); + + for (const x of [-1, contentSize.width]) { + console.info(`Check out-of-bounds x for ${x}`); + const { data } = await Page.captureScreenshot({ + clip: { + x, + y: 0, + width: size, + height: size, + scale: 1, + }, + }); + const { width, height } = await getImageDetails(data); + + is(width, size * ratio, "Image has expected width"); + is(height, size * ratio, "Image has expected height"); + is(data, refData, "Image is equal"); + } + + for (const y of [-1, contentSize.height]) { + console.info(`Check out-of-bounds y for ${y}`); + const { data } = await Page.captureScreenshot({ + clip: { + x: 0, + y, + width: size, + height: size, + scale: 1, + }, + }); + const { width, height } = await getImageDetails(data); + + is(width, size * ratio, "Image has expected width"); + is(height, size * ratio, "Image has expected height"); + is(data, refData, "Image is equal"); + } +}); + +add_task(async function clipOutOfBoundsWidthAndHeight({ client }) { + const { Page } = client; + const ratio = await getDevicePixelRatio(); + + await loadURL(toDataURL("<div style='margin: 100vh 100vw'>Hello world")); + const contentSize = await getContentSize(); + + var { data: refData } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: contentSize.width, + height: contentSize.height, + scale: 1, + }, + }); + + for (const value of [-1, 0]) { + console.info(`Check out-of-bounds width for ${value}`); + const clip = { + x: 0, + y: 0, + width: value, + height: contentSize.height, + scale: 1, + }; + + const { data } = await Page.captureScreenshot({ clip }); + const { width, height } = await getImageDetails(data); + is(width, contentSize.width * ratio, "Image has expected width"); + is(height, contentSize.height * ratio, "Image has expected height"); + is(data, refData, "Image is equal"); + } + + for (const value of [-1, 0]) { + console.info(`Check out-of-bounds height for ${value}`); + const clip = { + x: 0, + y: 0, + width: contentSize.width, + height: value, + scale: 1, + }; + + const { data } = await Page.captureScreenshot({ clip }); + const { width, height } = await getImageDetails(data); + is(width, contentSize.width * ratio, "Image has expected width"); + is(height, contentSize.height * ratio, "Image has expected height"); + is(data, refData, "Image is equal"); + } +}); + +add_task(async function clipOutOfBoundsScale({ client }) { + const { Page } = client; + const ratio = await getDevicePixelRatio(); + + await loadURLWithElement(); + const contentSize = await getContentSize(); + + var { data: refData } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: contentSize.width, + height: contentSize.height, + scale: 1, + }, + }); + + for (const value of [-1, 0]) { + console.info(`Check out-of-bounds scale for ${value}`); + var { data } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: 50, + height: 50, + scale: value, + }, + }); + + const { width, height } = await getImageDetails(data); + is(width, contentSize.width * ratio, "Image has expected width"); + is(height, contentSize.height * ratio, "Image has expected height"); + is(data, refData, "Image is equal"); + } +}); + +add_task(async function clipScale({ client }) { + const { Page } = client; + const ratio = await getDevicePixelRatio(); + + for (const scale of [1.5, 2]) { + console.info(`Check scale for ${scale}`); + await loadURLWithElement({ width: 100 * scale, height: 100 * scale }); + var { data: refData } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: 100 * scale, + height: 100 * scale, + scale: 1, + }, + }); + + await loadURLWithElement({ width: 100, height: 100 }); + var { data } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: 100, + height: 100, + scale, + }, + }); + + const { width, height } = await getImageDetails(data); + is(width, 100 * ratio * scale, "Image has expected width"); + is(height, 100 * ratio * scale, "Image has expected height"); + is(data, refData, "Image is equal"); + } +}); + +add_task(async function clipScaleAndDevicePixelRatio({ client }) { + const { Page } = client; + + const originalRatio = await getDevicePixelRatio(); + + const ratio = 2; + const scale = 1.5; + const size = 100; + + const expectedSize = size * ratio * scale; + + console.info(`Create reference screenshot: ${expectedSize}x${expectedSize}`); + await loadURLWithElement({ + width: expectedSize, + height: expectedSize, + }); + var { data: refData } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: expectedSize, + height: expectedSize, + scale: 1, + }, + }); + + await setDevicePixelRatio(originalRatio * ratio); + + await loadURLWithElement({ width: size, height: size }); + var { data } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: size, + height: size, + scale, + }, + }); + + const { width, height } = await getImageDetails(data); + is(width, expectedSize * originalRatio, "Image has expected width"); + is(height, expectedSize * originalRatio, "Image has expected height"); + is(data, refData, "Image is equal"); +}); + +add_task(async function clipPosition({ client }) { + const { Page } = client; + const ratio = await getDevicePixelRatio(); + + await loadURLWithElement(); + var { data: refData } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width: 100, + height: 100, + scale: 1, + }, + }); + + for (const [x, y] of [ + [10, 20], + [20, 10], + [20, 20], + ]) { + console.info(`Check postion for ${x} and ${y}`); + await loadURLWithElement({ x, y }); + var { data } = await Page.captureScreenshot({ + clip: { + x, + y, + width: 100, + height: 100, + scale: 1, + }, + }); + + const { width, height } = await getImageDetails(data); + is(width, 100 * ratio, "Image has expected width"); + is(height, 100 * ratio, "Image has expected height"); + is(data, refData, "Image is equal"); + } +}); + +add_task(async function clipDimension({ client }) { + const { Page } = client; + const ratio = await getDevicePixelRatio(); + + for (const [width, height] of [ + [10, 20], + [20, 10], + [20, 20], + ]) { + console.info(`Check width and height for ${width} and ${height}`); + + // Get reference image as section from a larger image + await loadURLWithElement({ width: 50, height: 50 }); + var { data: refData } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width, + height, + scale: 1, + }, + }); + + await loadURLWithElement({ width, height }); + var { data } = await Page.captureScreenshot({ + clip: { + x: 0, + y: 0, + width, + height, + scale: 1, + }, + }); + + const dimension = await getImageDetails(data); + is(dimension.width, width * ratio, "Image has expected width"); + is(dimension.height, height * ratio, "Image has expected height"); + is(data, refData, "Image is equal"); + } +}); + +async function loadURLWithElement(options = {}) { + const { x = 0, y = 0, width = 100, height = 100 } = options; + + const doc = ` + <style> + body { + margin: 0; + } + div { + margin-left: ${x}px; + margin-top: ${y}px; + width: ${width}px; + height: ${height}px; + background: green; + } + </style> + <body> + <div></div> + `; + + await loadURL(toDataURL(doc)); +} + +async function getDevicePixelRatio() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.browsingContext.overrideDPPX || content.devicePixelRatio; + }); +} + +async function setDevicePixelRatio(dppx) { + gBrowser.selectedBrowser.browsingContext.overrideDPPX = dppx; +} + +async function getImageDetails(image) { + const mimeType = getMimeType(image); + + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [{ mimeType, image }], + async function ({ mimeType, image }) { + return new Promise(resolve => { + const img = new content.Image(); + img.addEventListener( + "load", + () => { + resolve({ + mimeType, + width: img.width, + height: img.height, + length: image.length, + }); + }, + { once: true } + ); + + img.src = `data:${mimeType};base64,${image}`; + }); + } + ); +} + +function getMimeType(image) { + // Decode from base64 and convert the first 4 bytes to hex + const raw = atob(image).slice(0, 4); + let magicBytes = ""; + for (let i = 0; i < raw.length; i++) { + magicBytes += raw.charCodeAt(i).toString(16).toUpperCase(); + } + + switch (magicBytes) { + case "89504E47": + return "image/png"; + case "FFD8FFDB": + case "FFD8FFE0": + return "image/jpeg"; + default: + throw new Error("Unknown MIME type"); + } +} diff --git a/remote/cdp/test/browser/page/browser_createIsolatedWorld.js b/remote/cdp/test/browser/page/browser_createIsolatedWorld.js new file mode 100644 index 0000000000..41ed83d264 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_createIsolatedWorld.js @@ -0,0 +1,471 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test Page.createIsolatedWorld + +const WORLD_NAME_1 = "testWorld1"; +const WORLD_NAME_2 = "testWorld2"; + +const DESTROYED = "Runtime.executionContextDestroyed"; +const CREATED = "Runtime.executionContextCreated"; +const CLEARED = "Runtime.executionContextsCleared"; + +add_task(async function frameIdMissing({ client }) { + const { Page } = client; + + await Assert.rejects( + Page.createIsolatedWorld({ + worldName: WORLD_NAME_1, + grantUniversalAccess: true, + }), + /frameId: string value expected/, + `Fails with missing frameId` + ); +}); + +add_task(async function frameIdInvalidTypes({ client }) { + const { Page } = client; + + for (const frameId of [null, true, 1, [], {}]) { + await Assert.rejects( + Page.createIsolatedWorld({ + frameId, + }), + /frameId: string value expected/, + `Fails with invalid type: ${frameId}` + ); + } +}); + +add_task(async function worldNameInvalidTypes({ client }) { + const { Page } = client; + + await Page.enable(); + info("Page notifications are enabled"); + + const loadEvent = Page.loadEventFired(); + const { frameId } = await Page.navigate({ url: PAGE_URL }); + await loadEvent; + + for (const worldName of [null, true, 1, [], {}]) { + await Assert.rejects( + Page.createIsolatedWorld({ + frameId, + worldName, + }), + /worldName: string value expected/, + `Fails with invalid type: ${worldName}` + ); + } +}); + +add_task(async function noEventsWhenRuntimeDomainDisabled({ client }) { + const { Page, Runtime } = client; + + await Page.enable(); + info("Page notifications are enabled"); + + const history = recordEvents(Runtime, 0); + const loadEvent = Page.loadEventFired(); + const { frameId } = await Page.navigate({ url: PAGE_URL }); + await loadEvent; + + let errorThrown = ""; + try { + await Page.createIsolatedWorld({ + frameId, + worldName: WORLD_NAME_1, + grantUniversalAccess: true, + }); + await assertEvents({ history, expectedEvents: [] }); + } catch (e) { + errorThrown = e.message; + } + todo( + errorThrown === "", + "No contexts tracked internally without Runtime enabled (Bug 1623482)" + ); +}); + +add_task(async function noEventsAfterRuntimeDomainDisabled({ client }) { + const { Page, Runtime } = client; + + await Page.enable(); + info("Page notifications are enabled"); + + await enableRuntime(client); + await Runtime.disable(); + info("Runtime notifications are disabled"); + + const history = recordEvents(Runtime, 0); + const loadEvent = Page.loadEventFired(); + const { frameId } = await Page.navigate({ url: PAGE_URL }); + await loadEvent; + + await Page.createIsolatedWorld({ + frameId, + worldName: WORLD_NAME_2, + grantUniversalAccess: true, + }); + await assertEvents({ history, expectedEvents: [] }); +}); + +add_task(async function contextCreatedAfterNavigation({ client }) { + const { Page, Runtime } = client; + + await Page.enable(); + info("Page notifications are enabled"); + + await enableRuntime(client); + + const history = recordEvents(Runtime, 3); + const loadEvent = Page.loadEventFired(); + const { frameId } = await Page.navigate({ url: PAGE_URL }); + await loadEvent; + + const { executionContextId: isolatedId } = await Page.createIsolatedWorld({ + frameId, + worldName: WORLD_NAME_1, + grantUniversalAccess: true, + }); + await assertEvents({ + history, + expectedEvents: [ + DESTROYED, // default, about:blank + CREATED, // default, PAGE_URL + CREATED, // isolated, PAGE_URL + ], + }); + + const contexts = history + .findEvents(CREATED) + .map(event => event.payload.context); + const defaultContext = contexts[0]; + const isolatedContext = contexts[1]; + is(defaultContext.auxData.isDefault, true, "Default context is default"); + is( + defaultContext.auxData.type, + "default", + "Default context has type 'default'" + ); + is(defaultContext.origin, BASE_ORIGIN, "Default context has expected origin"); + checkIsolated(isolatedContext, isolatedId, WORLD_NAME_1, frameId); + compareContexts(isolatedContext, defaultContext); +}); + +add_task(async function contextDestroyedForNavigation({ client }) { + const { Page, Runtime } = client; + + const defaultContext = await enableRuntime(client); + const isolatedContext = await createIsolatedContext(client, defaultContext); + + await Page.enable(); + + const history = recordEvents(Runtime, 4, true); + const frameNavigated = Page.frameNavigated(); + await Page.navigate({ url: PAGE_URL }); + await frameNavigated; + + await assertEvents({ + history, + expectedEvents: [ + DESTROYED, // default, about:blank + DESTROYED, // isolated, about:blank + CLEARED, + CREATED, // default, PAGE_URL + ], + }); + + const destroyed = history + .findEvents(DESTROYED) + .map(event => event.payload.executionContextId); + ok(destroyed.includes(isolatedContext.id), "Isolated context destroyed"); + ok(destroyed.includes(defaultContext.id), "Default context destroyed"); + + const { context: newContext } = history.findEvent(CREATED).payload; + is(newContext.auxData.isDefault, true, "The new context is a default one"); + ok(!!newContext.id, "The new context has an id"); + ok( + ![defaultContext.id, isolatedContext.id].includes(newContext.id), + "The new context has a new id" + ); +}); + +add_task(async function contextsForFramesetNavigation({ client }) { + const { Page, Runtime } = client; + + await Page.enable(); + info("Page notifications are enabled"); + + await enableRuntime(client); + + // check creation when navigating to a frameset + const historyTo = recordEvents(Runtime, 5); + const loadEventTo = Page.loadEventFired(); + const { frameId: frameIdTo } = await Page.navigate({ + url: FRAMESET_SINGLE_URL, + }); + await loadEventTo; + + const { frameTree } = await Page.getFrameTree(); + const subFrame = frameTree.childFrames[0].frame; + + const { executionContextId: contextIdParent } = + await Page.createIsolatedWorld({ + frameId: frameIdTo, + worldName: WORLD_NAME_1, + grantUniversalAccess: true, + }); + const { executionContextId: contextIdSubFrame } = + await Page.createIsolatedWorld({ + frameId: subFrame.id, + worldName: WORLD_NAME_2, + grantUniversalAccess: true, + }); + + await assertEvents({ + history: historyTo, + expectedEvents: [ + DESTROYED, // default, about:blank + CREATED, // default, FRAMESET_SINGLE_URL + CREATED, // default, PAGE_URL + CREATED, // isolated, FRAMESET_SINGLE_URL + CREATED, // isolated, PAGE_URL + ], + }); + + const contextsCreated = historyTo + .findEvents(CREATED) + .map(event => event.payload.context); + const parentDefaultContextCreated = contextsCreated[0]; + const frameDefaultContextCreated = contextsCreated[1]; + const parentIsolatedContextCreated = contextsCreated[2]; + const frameIsolatedContextCreated = contextsCreated[3]; + + checkIsolated( + parentIsolatedContextCreated, + contextIdParent, + WORLD_NAME_1, + frameIdTo + ); + compareContexts(parentIsolatedContextCreated, parentDefaultContextCreated); + + checkIsolated( + frameIsolatedContextCreated, + contextIdSubFrame, + WORLD_NAME_2, + subFrame.id + ); + compareContexts(frameIsolatedContextCreated, frameDefaultContextCreated); + + // check destroying when navigating away from a frameset + const historyFrom = recordEvents(Runtime, 6); + const loadEventFrom = Page.loadEventFired(); + await Page.navigate({ url: PAGE_URL }); + await loadEventFrom; + + await assertEvents({ + history: historyFrom, + expectedEvents: [ + DESTROYED, // default, PAGE_URL + DESTROYED, // isolated, PAGE_URL + DESTROYED, // default, FRAMESET_SINGLE_URL + DESTROYED, // isolated, FRAMESET_SINGLE_URL + CREATED, // default, PAGE_URL + ], + }); + + const contextsDestroyed = historyFrom + .findEvents(DESTROYED) + .map(event => event.payload.executionContextId); + contextsCreated.forEach(context => { + ok( + contextsDestroyed.includes(context.id), + `Context with id ${context.id} destroyed` + ); + }); + + const { context: newContext } = historyFrom.findEvent(CREATED).payload; + is(newContext.auxData.isDefault, true, "The new context is a default one"); + ok(!!newContext.id, "The new context has an id"); + ok( + ![parentDefaultContextCreated.id, frameDefaultContextCreated.id].includes( + newContext.id + ), + "The new context has a new id" + ); +}); + +add_task(async function evaluateInIsolatedAndDefault({ client }) { + const { Runtime } = client; + + const defaultContext = await enableRuntime(client); + const isolatedContext = await createIsolatedContext(client, defaultContext); + + const { result: objDefault } = await Runtime.evaluate({ + contextId: defaultContext.id, + expression: "({ foo: 1 })", + }); + const { result: objIsolated } = await Runtime.evaluate({ + contextId: isolatedContext.id, + expression: "({ foo: 10 })", + }); + const { result: result1 } = await Runtime.callFunctionOn({ + executionContextId: isolatedContext.id, + functionDeclaration: "arg => ++arg.foo", + arguments: [{ objectId: objIsolated.objectId }], + }); + is(result1.value, 11, "Isolated context incremented the expected value"); + + await Assert.rejects( + Runtime.callFunctionOn({ + executionContextId: isolatedContext.id, + functionDeclaration: "arg => ++arg.foo", + arguments: [{ objectId: objDefault.objectId }], + }), + /Could not find object with given id/, + "Contexts do not share objects" + ); +}); + +add_task(async function contextEvaluationIsIsolated({ client }) { + const { Runtime } = client; + + // If a document makes changes to standard global object, an isolated + // world should not be affected + await loadURL(toDataURL("<script>window.Node = null</script>")); + + const defaultContext = await enableRuntime(client); + const isolatedContext = await createIsolatedContext(client, defaultContext); + + const { result: result1 } = await Runtime.callFunctionOn({ + executionContextId: defaultContext.id, + functionDeclaration: "arg => window.Node", + }); + const { result: result2 } = await Runtime.callFunctionOn({ + executionContextId: isolatedContext.id, + functionDeclaration: "arg => window.Node", + }); + is(result1.value, null, "Default context sees content changes to global"); + todo_isnot( + result2.value, + null, + "Isolated context is not affected by changes to global, Bug 1601421" + ); +}); + +function checkIsolated(context, expectedId, expectedName, expectedFrameId) { + is( + expectedId, + context.id, + "createIsolatedWorld returns id of isolated context" + ); + is( + context.auxData.frameId, + expectedFrameId, + "Isolated context has expected frameId" + ); + is(context.auxData.isDefault, false, "Isolated context is not default"); + is(context.auxData.type, "isolated", "Isolated context has type 'isolated'"); + is(context.name, expectedName, "Isolated context is named as requested"); + ok(!!context.origin, "Isolated context has an origin"); +} + +function compareContexts(isolatedContext, defaultContext) { + isnot( + defaultContext.name, + isolatedContext.name, + "The contexts have different names" + ); + isnot( + defaultContext.id, + isolatedContext.id, + "The contexts have different ids" + ); + is( + defaultContext.origin, + isolatedContext.origin, + "The contexts have same origin" + ); + is( + defaultContext.auxData.frameId, + isolatedContext.auxData.frameId, + "The contexts have same frameId" + ); +} + +async function createIsolatedContext( + client, + defaultContext, + worldName = WORLD_NAME_1 +) { + const { Page, Runtime } = client; + + const frameId = defaultContext.auxData.frameId; + + const isolatedContextCreated = Runtime.executionContextCreated(); + const { executionContextId: isolatedId } = await Page.createIsolatedWorld({ + frameId, + worldName, + grantUniversalAccess: true, + }); + const { context: isolatedContext } = await isolatedContextCreated; + info("Isolated world created"); + + checkIsolated(isolatedContext, isolatedId, worldName, frameId); + compareContexts(isolatedContext, defaultContext); + + return isolatedContext; +} + +function recordEvents(Runtime, total, cleared = false) { + const history = new RecordEvents(total); + + history.addRecorder({ + event: Runtime.executionContextDestroyed, + eventName: DESTROYED, + messageFn: payload => { + return `Received ${DESTROYED} for id ${payload.executionContextId}`; + }, + }); + history.addRecorder({ + event: Runtime.executionContextCreated, + eventName: CREATED, + messageFn: payload => { + return ( + `Received ${CREATED} for id ${payload.context.id}` + + ` type: ${payload.context.auxData.type}` + + ` name: ${payload.context.name}` + + ` origin: ${payload.context.origin}` + ); + }, + }); + if (cleared) { + history.addRecorder({ + event: Runtime.executionContextsCleared, + eventName: CLEARED, + }); + } + + return history; +} + +async function assertEvents(options = {}) { + const { history, expectedEvents, timeout = 1000 } = options; + const events = await history.record(timeout); + const eventNames = events.map(item => item.eventName); + info(`Expected events: ${expectedEvents}`); + info(`Received events: ${eventNames}`); + is( + events.length, + expectedEvents.length, + "Received expected number of Runtime context events" + ); + Assert.deepEqual( + eventNames.sort(), + expectedEvents.sort(), + "Received expected Runtime context events" + ); +} diff --git a/remote/cdp/test/browser/page/browser_domContentEventFired.js b/remote/cdp/test/browser/page/browser_domContentEventFired.js new file mode 100644 index 0000000000..7bdf8f127d --- /dev/null +++ b/remote/cdp/test/browser/page/browser_domContentEventFired.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noEventWhenPageDomainDisabled({ client }) { + await runContentEventFiredTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.disable(); + + await runContentEventFiredTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNoFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runContentEventFiredTest(client, 1, async () => { + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runContentEventFiredTest(client, 1, async () => { + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNestedFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runContentEventFiredTest(client, 1, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +async function runContentEventFiredTest(client, expectedEventCount, callback) { + const { Page } = client; + + if (![0, 1].includes(expectedEventCount)) { + throw new Error(`Invalid value for expectedEventCount`); + } + + const DOM_CONTENT_EVENT_FIRED = "Page.domContentEventFired"; + + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.domContentEventFired, + eventName: DOM_CONTENT_EVENT_FIRED, + messageFn: payload => { + return `Received ${DOM_CONTENT_EVENT_FIRED} at time ${payload.timestamp}`; + }, + }); + + const timeBefore = Date.now() / 1000; + await callback(); + const domContentEventFiredEvents = await history.record(); + const timeAfter = Date.now() / 1000; + + is( + domContentEventFiredEvents.length, + expectedEventCount, + "Got expected amount of domContentEventFired events" + ); + if (expectedEventCount == 0) { + return; + } + + const timestamp = domContentEventFiredEvents[0].payload.timestamp; + ok( + timestamp >= timeBefore && timestamp <= timeAfter, + `Timestamp ${timestamp} in expected range [${timeBefore} - ${timeAfter}]` + ); +} diff --git a/remote/cdp/test/browser/page/browser_frameAttached.js b/remote/cdp/test/browser/page/browser_frameAttached.js new file mode 100644 index 0000000000..bda66b2814 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_frameAttached.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noEventWhenPageDomainDisabled({ client }) { + await runFrameAttachedTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.disable(); + + await runFrameAttachedTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventWhenNavigatingWithNoFrames({ client }) { + const { Page } = client; + + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_NESTED_URL); + + await Page.enable(); + + await runFrameAttachedTest(client, 0, async () => { + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameAttachedTest(client, 2, async () => { + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNestedFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameAttachedTest(client, 3, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function eventWhenAttachingFrame({ client }) { + const { Page } = client; + + await loadURL(FRAMESET_NESTED_URL); + + await Page.enable(); + + await runFrameAttachedTest(client, 1, async () => { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [PAGE_FRAME_URL], + async frameURL => { + const frame = content.document.createElement("iframe"); + frame.src = frameURL; + const loaded = new Promise(resolve => (frame.onload = resolve)); + content.document.body.appendChild(frame); + await loaded; + } + ); + }); +}); + +async function runFrameAttachedTest(client, expectedEventCount, callback) { + const { Page } = client; + + const ATTACHED = "Page.frameAttached"; + + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.frameAttached, + eventName: ATTACHED, + messageFn: payload => { + return `Received ${ATTACHED} for frame id ${payload.frameId}`; + }, + }); + + const framesBefore = await getFlattenedFrameTree(client); + await callback(); + const framesAfter = await getFlattenedFrameTree(client); + + const frameAttachedEvents = await history.record(); + + if (expectedEventCount == 0) { + is(frameAttachedEvents.length, 0, "Got no frame attached event"); + return; + } + + // check how many frames were attached or detached + const count = Math.abs(framesBefore.size - framesAfter.size); + + is(count, expectedEventCount, "Expected amount of frames attached"); + is( + frameAttachedEvents.length, + count, + "Received the expected amount of frameAttached events" + ); + + // extract the new or removed frames + const framesAll = new Map([...framesBefore, ...framesAfter]); + const expectedFrames = new Map( + [...framesAll].filter(([key, _value]) => { + return !framesBefore.has(key) && framesAfter.has(key); + }) + ); + + frameAttachedEvents.forEach(({ payload }) => { + const { frameId, parentFrameId } = payload; + + info(`Check frame id ${frameId}`); + const expectedFrame = expectedFrames.get(frameId); + + ok(expectedFrame, `Found expected frame with id ${frameId}`); + is( + frameId, + expectedFrame.id, + "Got expected frame id for frameAttached event" + ); + is( + parentFrameId, + expectedFrame.parentId, + "Got expected parent frame id for frameAttached event" + ); + }); +} diff --git a/remote/cdp/test/browser/page/browser_frameDetached.js b/remote/cdp/test/browser/page/browser_frameDetached.js new file mode 100644 index 0000000000..90db5087b1 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_frameDetached.js @@ -0,0 +1,171 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Disable bfcache to force documents to be destroyed on navigation +Services.prefs.setIntPref("browser.sessionhistory.max_total_viewers", 0); +registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.sessionhistory.max_total_viewers"); +}); + +add_task(async function noEventWhenPageDomainDisabled({ client }) { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + + await runFrameDetachedTest(client, 0, async () => { + info("Navigate away from a page with an iframe"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + + await Page.enable(); + await Page.disable(); + + await runFrameDetachedTest(client, 0, async () => { + info("Navigate away to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function noEventWhenNavigatingWithNoFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + + await runFrameDetachedTest(client, 0, async () => { + info("Navigate away to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithFrames({ client }) { + const { Page } = client; + + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + + await Page.enable(); + + await runFrameDetachedTest(client, 2, async () => { + info("Navigate away to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNestedFrames({ client }) { + const { Page } = client; + + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + + await Page.enable(); + + await runFrameDetachedTest(client, 3, async () => { + info("Navigate away to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventWhenDetachingFrame({ client }) { + const { Page } = client; + + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + + await Page.enable(); + + await runFrameDetachedTest(client, 1, async () => { + // Remove the single frame from the page + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const frame = content.document.getElementsByTagName("iframe")[0]; + frame.remove(); + }); + }); +}); + +add_task(async function eventWhenDetachingNestedFrames({ client }) { + const { Page, Runtime } = client; + + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + + await Page.enable(); + await Runtime.enable(); + + const { context } = await Runtime.executionContextCreated(); + + await runFrameDetachedTest(client, 3, async () => { + // Remove top-frame, which also removes any nested frames + await evaluate(client, context.id, async () => { + const frame = document.getElementsByTagName("iframe")[0]; + frame.remove(); + }); + }); +}); + +async function runFrameDetachedTest(client, expectedEventCount, callback) { + const { Page } = client; + + const DETACHED = "Page.frameDetached"; + + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.frameDetached, + eventName: DETACHED, + messageFn: payload => { + return `Received ${DETACHED} for frame id ${payload.frameId}`; + }, + }); + + const framesBefore = await getFlattenedFrameTree(client); + await callback(); + const framesAfter = await getFlattenedFrameTree(client); + + const frameDetachedEvents = await history.record(); + + if (expectedEventCount == 0) { + is(frameDetachedEvents.length, 0, "Got no frame detached event"); + return; + } + + // check how many frames were attached or detached + const count = Math.abs(framesBefore.size - framesAfter.size); + + is(count, expectedEventCount, "Expected amount of frames detached"); + is( + frameDetachedEvents.length, + count, + "Received the expected amount of frameDetached events" + ); + + // extract the new or removed frames + const framesAll = new Map([...framesBefore, ...framesAfter]); + const expectedFrames = new Map( + [...framesAll].filter(([key, _value]) => { + return framesBefore.has(key) && !framesAfter.has(key); + }) + ); + + frameDetachedEvents.forEach(({ payload }) => { + const { frameId } = payload; + + info(`Check frame id ${frameId}`); + const expectedFrame = expectedFrames.get(frameId); + + is( + frameId, + expectedFrame.id, + "Got expected frame id for frameDetached event" + ); + }); +} diff --git a/remote/cdp/test/browser/page/browser_frameNavigated.js b/remote/cdp/test/browser/page/browser_frameNavigated.js new file mode 100644 index 0000000000..4453c63749 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_frameNavigated.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noEventWhenPageDomainDisabled({ client }) { + await runFrameNavigatedTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.disable(); + + await runFrameNavigatedTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNoFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameNavigatedTest(client, 1, async () => { + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventsWhenNavigatingWithFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameNavigatedTest(client, 3, async () => { + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNestedFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameNavigatedTest(client, 4, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +async function runFrameNavigatedTest(client, expectedEventCount, callback) { + const { Page } = client; + + const NAVIGATED = "Page.frameNavigated"; + + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.frameNavigated, + eventName: NAVIGATED, + messageFn: payload => { + return `Received ${NAVIGATED} for frame id ${payload.frame.id}`; + }, + }); + + await callback(); + + const frameNavigatedEvents = await history.record(); + + is( + frameNavigatedEvents.length, + expectedEventCount, + "Got expected amount of frameNavigated events" + ); + if (expectedEventCount == 0) { + return; + } + + const frames = await getFlattenedFrameTree(client); + + frameNavigatedEvents.forEach(({ payload }) => { + const { frame } = payload; + + const expectedFrame = frames.get(frame.id); + Assert.deepEqual(frame, expectedFrame, "Got expected frame details"); + }); +} diff --git a/remote/cdp/test/browser/page/browser_frameStartedLoading.js b/remote/cdp/test/browser/page/browser_frameStartedLoading.js new file mode 100644 index 0000000000..d5bb91a952 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_frameStartedLoading.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noEventWhenPageDomainDisabled({ client }) { + await runFrameStartedLoadingTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.disable(); + + await runFrameStartedLoadingTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNoFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameStartedLoadingTest(client, 1, async () => { + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventsWhenNavigatingWithFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameStartedLoadingTest(client, 3, async () => { + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + }); +}); + +add_task(async function eventsWhenNavigatingWithNestedFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameStartedLoadingTest(client, 4, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +async function runFrameStartedLoadingTest( + client, + expectedEventCount, + callback +) { + const { Page } = client; + + const STARTED_LOADING = "Page.frameStartedLoading"; + + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.frameStartedLoading, + eventName: STARTED_LOADING, + messageFn: payload => { + return `Received ${STARTED_LOADING} for frame id ${payload.frameId}`; + }, + }); + + await callback(); + + const frameStartedLoadingEvents = await history.record(); + + is( + frameStartedLoadingEvents.length, + expectedEventCount, + "Got expected amount of frameStartedLoading events" + ); + if (expectedEventCount == 0) { + return; + } + + const frames = await getFlattenedFrameTree(client); + + frameStartedLoadingEvents.forEach(({ payload }) => { + const { frameId } = payload; + + info(`Check frame id ${frameId}`); + const expectedFrame = frames.get(frameId); + + ok(expectedFrame, `Found expected frame with id ${frameId}`); + is( + frameId, + expectedFrame.id, + "Got expected frame id for frameStartedLoading event" + ); + }); +} diff --git a/remote/cdp/test/browser/page/browser_frameStoppedLoading.js b/remote/cdp/test/browser/page/browser_frameStoppedLoading.js new file mode 100644 index 0000000000..9d7c37ddc4 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_frameStoppedLoading.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noEventWhenPageDomainDisabled({ client }) { + await runFrameStoppedLoadingTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.disable(); + + await runFrameStoppedLoadingTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNoFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameStoppedLoadingTest(client, 1, async () => { + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventsWhenNavigatingWithFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameStoppedLoadingTest(client, 3, async () => { + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + }); +}); + +add_task(async function eventsWhenNavigatingWithNestedFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runFrameStoppedLoadingTest(client, 4, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +async function runFrameStoppedLoadingTest( + client, + expectedEventCount, + callback +) { + const { Page } = client; + + const STOPPED_LOADING = "Page.frameStoppedLoading"; + + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.frameStoppedLoading, + eventName: STOPPED_LOADING, + messageFn: payload => { + return `Received ${STOPPED_LOADING} for frame id ${payload.frameId}`; + }, + }); + + await callback(); + + const frameStoppedLoadingEvents = await history.record(); + + is( + frameStoppedLoadingEvents.length, + expectedEventCount, + "Got expected amount of frameStoppedLoading events" + ); + if (expectedEventCount == 0) { + return; + } + + const frames = await getFlattenedFrameTree(client); + + frameStoppedLoadingEvents.forEach(({ payload }) => { + const { frameId } = payload; + + info(`Check frame id ${frameId}`); + const expectedFrame = frames.get(frameId); + + ok(expectedFrame, `Found expected frame with id ${frameId}`); + is( + frameId, + expectedFrame.id, + "Got expected frame id for frameStartedLoading event" + ); + }); +} diff --git a/remote/cdp/test/browser/page/browser_getFrameTree.js b/remote/cdp/test/browser/page/browser_getFrameTree.js new file mode 100644 index 0000000000..e96dc26d45 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_getFrameTree.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function pageWithoutFrames({ client }) { + const { Page } = client; + + info("Navigate to a page without a frame"); + await loadURL(PAGE_URL); + + const { frameTree } = await Page.getFrameTree(); + ok(!!frameTree.frame, "Expected frame details found"); + + const expectedFrames = await getFlattenedFrameList(); + + // Check top-level frame + const expectedFrame = expectedFrames.get(frameTree.frame.id); + is(frameTree.frame.id, expectedFrame.id, "Expected frame id found"); + is(frameTree.frame.parentId, undefined, "Parent frame doesn't exist"); + is(frameTree.name, undefined, "Top frame doens't contain name property"); + is(frameTree.frame.url, expectedFrame.url, "Expected url found"); + is(frameTree.childFrames, undefined, "No sub frames found"); +}); + +add_task(async function PageWithFrames({ client }) { + const { Page } = client; + + info("Navigate to a page with frames"); + await loadURL(FRAMESET_MULTI_URL); + + const { frameTree } = await Page.getFrameTree(); + ok(!!frameTree.frame, "Expected frame details found"); + + const expectedFrames = await getFlattenedFrameList(); + + let frame = frameTree.frame; + let expectedFrame = expectedFrames.get(frame.id); + + info(`Check top frame with id: ${frame.id}`); + is(frame.id, expectedFrame.id, "Expected frame id found"); + is(frame.parentId, undefined, "Parent frame doesn't exist"); + is(frame.name, undefined, "Top frame doesn't contain name property"); + is(frame.url, expectedFrame.url, "Expected URL found"); + + is(frameTree.childFrames.length, 2, "Expected two sub frames"); + for (const childFrameTree of frameTree.childFrames) { + let frame = childFrameTree.frame; + let expectedFrame = expectedFrames.get(frame.id); + + info(`Check sub frame with id: ${frame.id}`); + is(frame.id, expectedFrame.id, "Expected frame id found"); + is(frame.parentId, expectedFrame.parentId, "Expected parent id found"); + is(frame.name, expectedFrame.name, "Frame has expected name set"); + is(frame.url, expectedFrame.url, "Expected URL found"); + is(childFrameTree.childFrames, undefined, "No sub frames found"); + } +}); + +add_task(async function pageWithNestedFrames({ client }) { + const { Page } = client; + + info("Navigate to a page with nested frames"); + await loadURL(FRAMESET_NESTED_URL); + + const { frameTree } = await Page.getFrameTree(); + ok(!!frameTree.frame, "Expected frame details found"); + + const expectedFrames = await getFlattenedFrameList(); + + let frame = frameTree.frame; + let expectedFrame = expectedFrames.get(frame.id); + + info(`Check top frame with id: ${frame.id}`); + is(frame.id, expectedFrame.id, "Expected frame id found"); + is(frame.parentId, undefined, "Parent frame doesn't exist"); + is(frame.name, undefined, "Top frame doesn't contain name property"); + is(frame.url, expectedFrame.url, "Expected URL found"); + is(frameTree.childFrames.length, 1, "Expected a single sub frame"); + + const childFrameTree = frameTree.childFrames[0]; + frame = childFrameTree.frame; + expectedFrame = expectedFrames.get(frame.id); + + info(`Check sub frame with id: ${frame.id}`); + is(frame.id, expectedFrame.id, "Expected frame id found"); + is(frame.parentId, expectedFrame.parentId, "Expected parent id found"); + is(frame.name, expectedFrame.name, "Frame has expected name set"); + is(frame.url, expectedFrame.url, "Expected URL found"); + is(childFrameTree.childFrames.length, 2, "Expected two sub frames"); + + let nestedChildFrameTree = childFrameTree.childFrames[0]; + frame = nestedChildFrameTree.frame; + expectedFrame = expectedFrames.get(frame.id); + + info(`Check first nested frame with id: ${frame.id}`); + is(frame.id, expectedFrame.id, "Expected frame id found"); + is(frame.parentId, expectedFrame.parentId, "Expected parent id found"); + is(frame.name, expectedFrame.name, "Frame has expected name set"); + is(frame.url, expectedFrame.url, "Expected URL found"); + is(nestedChildFrameTree.childFrames, undefined, "No sub frames found"); + + nestedChildFrameTree = childFrameTree.childFrames[1]; + frame = nestedChildFrameTree.frame; + expectedFrame = expectedFrames.get(frame.id); + + info(`Check second nested frame with id: ${frame.id}`); + is(frame.id, expectedFrame.id, "Expected frame id found"); + is(frame.parentId, expectedFrame.parentId, "Expected parent id found"); + is(frame.name, expectedFrame.name, "Frame has expected name set"); + is(frame.url, expectedFrame.url, "Expected URL found"); + is(nestedChildFrameTree.childFrames, undefined, "No sub frames found"); +}); + +/** + * Retrieve all frames for the current tab as flattened list. + */ +function getFlattenedFrameList() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const frames = new Map(); + + function getFrameDetails(context) { + const frameElement = context.embedderElement; + + const frame = { + id: context.id.toString(), + parentId: context.parent ? context.parent.id.toString() : null, + loaderId: null, + name: frameElement?.id || frameElement?.name, + url: context.docShell.domWindow.location.href, + securityOrigin: null, + mimeType: null, + }; + + if (context.parent) { + frame.parentId = context.parent.id.toString(); + } + + frames.set(context.id.toString(), frame); + + for (const childContext of context.children) { + getFrameDetails(childContext); + } + } + + getFrameDetails(content.docShell.browsingContext); + return frames; + }); +} diff --git a/remote/cdp/test/browser/page/browser_getLayoutMetrics.js b/remote/cdp/test/browser/page/browser_getLayoutMetrics.js new file mode 100644 index 0000000000..db8b3e8f3c --- /dev/null +++ b/remote/cdp/test/browser/page/browser_getLayoutMetrics.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function documentSmallerThanViewport({ client }) { + const { Page } = client; + await loadURL(toDataURL("<div>Hello world")); + + const { contentSize, layoutViewport } = await Page.getLayoutMetrics(); + await checkContentSize(contentSize); + await checkLayoutViewport(layoutViewport); + + is( + contentSize.x, + layoutViewport.pageX, + "X position of content is equal to layout viewport" + ); + is( + contentSize.y, + layoutViewport.pageY, + "Y position of content is equal to layout viewport" + ); + ok( + contentSize.width <= layoutViewport.clientWidth, + "Width of content is smaller than the layout viewport" + ); + ok( + contentSize.height <= layoutViewport.clientHeight, + "Height of content is smaller than the layout viewport" + ); +}); + +add_task(async function documentLargerThanViewport({ client }) { + const { Page } = client; + await loadURL(toDataURL("<div style='margin: 150vh 0 0 150vw'>Hello world")); + + const { contentSize, layoutViewport } = await Page.getLayoutMetrics(); + await checkContentSize(contentSize); + await checkLayoutViewport(layoutViewport, { scrollbars: true }); + + is( + contentSize.x, + layoutViewport.pageX, + "X position of content is equal to layout viewport" + ); + is( + contentSize.y, + layoutViewport.pageY, + "Y position of content is equal to layout viewport" + ); + ok( + contentSize.width > layoutViewport.clientWidth, + "Width of content is larger than the layout viewport" + ); + ok( + contentSize.height > layoutViewport.clientHeight, + "Height of content is larger than the layout viewport" + ); +}); + +add_task(async function documentLargerThanViewportScrolledXY({ client }) { + const { Page } = client; + await loadURL(toDataURL("<div style='margin: 150vh 0 0 150vw'>Hello world")); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.scrollTo(50, 100); + }); + + const { contentSize, layoutViewport } = await Page.getLayoutMetrics(); + await checkContentSize(contentSize); + await checkLayoutViewport(layoutViewport, { scrollbars: true }); + + is( + layoutViewport.pageX, + contentSize.x + 50, + "X position of content is equal to layout viewport" + ); + is( + layoutViewport.pageY, + contentSize.y + 100, + "Y position of content is equal to layout viewport" + ); + ok( + contentSize.width > layoutViewport.clientWidth, + "Width of content is larger than the layout viewport" + ); + ok( + contentSize.height > layoutViewport.clientHeight, + "Height of content is larger than the layout viewport" + ); +}); + +async function checkContentSize(rect) { + const expected = await getContentSize(); + + is(rect.x, expected.x, "Expected x position returned"); + is(rect.y, expected.y, "Expected y position returned"); + is(rect.width, expected.width, "Expected width returned"); + is(rect.height, expected.height, "Expected height returned"); +} + +async function checkLayoutViewport(viewport, options = {}) { + const { scrollbars = false } = options; + + const expected = await getViewportSize(); + + if (scrollbars) { + const { width, height } = await getScrollbarSize(); + expected.width -= width; + expected.height -= height; + } + + is(viewport.pageX, expected.x, "Expected x position returned"); + is(viewport.pageY, expected.y, "Expected y position returned"); + is(viewport.clientWidth, expected.width, "Expected width returned"); + is(viewport.clientHeight, expected.height, "Expected height returned"); +} diff --git a/remote/cdp/test/browser/page/browser_getNavigationHistory.js b/remote/cdp/test/browser/page/browser_getNavigationHistory.js new file mode 100644 index 0000000000..1067629828 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_getNavigationHistory.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function singleEntry({ client }) { + const { Page } = client; + + const data = generateHistoryData(1); + for (const entry of data) { + await loadURL(entry.userTypedURL); + } + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, 0); +}); + +add_task(async function multipleEntriesWithLastIndex({ client }) { + const { Page } = client; + + const data = generateHistoryData(3); + for (const entry of data) { + await loadURL(entry.userTypedURL); + } + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, data.length - 1); +}); + +add_task(async function multipleEntriesWithFirstIndex({ client }) { + const { Page } = client; + + const data = generateHistoryData(3); + for (const entry of data) { + await loadURL(entry.userTypedURL); + } + + await gotoHistoryIndex(0); + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, 0); +}); + +add_task(async function locationRedirect({ client }) { + const { Page } = client; + + const pageEmptyURL = + "https://example.com/browser/remote/cdp/test/browser/page/doc_empty.html"; + const sjsURL = + "https://example.com/browser/remote/cdp/test/browser/page/sjs_redirect.sjs"; + const redirectURL = `${sjsURL}?${pageEmptyURL}`; + + const data = [ + { + url: pageEmptyURL, + userTypedURL: redirectURL, + title: "Empty page", + }, + ]; + + await loadURL(redirectURL, pageEmptyURL); + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, 0); +}); diff --git a/remote/cdp/test/browser/page/browser_javascriptDialog_alert.js b/remote/cdp/test/browser/page/browser_javascriptDialog_alert.js new file mode 100644 index 0000000000..40c5e4898a --- /dev/null +++ b/remote/cdp/test/browser/page/browser_javascriptDialog_alert.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test a browser alert is detected via Page.javascriptDialogOpening and can be +// closed with Page.handleJavaScriptDialog +add_task(async function ({ client }) { + const { Page } = client; + + info("Enable the page domain"); + await Page.enable(); + + info("Set window.alertIsClosed to false in the content page"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + // This boolean will be flipped after closing the dialog + content.alertIsClosed = false; + }); + + info("Create an alert dialog again"); + const { message, type } = await createAlertDialog(Page); + is(type, "alert", "dialog event contains the correct type"); + is(message, "test-1234", "dialog event contains the correct text"); + + info("Close the dialog with accept:false"); + await Page.handleJavaScriptDialog({ accept: false }); + + info("Retrieve the alertIsClosed boolean on the content window"); + let alertIsClosed = await getContentProperty("alertIsClosed"); + ok(alertIsClosed, "The content process is no longer blocked on the alert"); + + info("Reset window.alertIsClosed to false in the content page"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.alertIsClosed = false; + }); + + info("Create an alert dialog again"); + await createAlertDialog(Page); + + info("Close the dialog with accept:true"); + await Page.handleJavaScriptDialog({ accept: true }); + + alertIsClosed = await getContentProperty("alertIsClosed"); + ok(alertIsClosed, "The content process is no longer blocked on the alert"); +}); + +function createAlertDialog(Page) { + const onDialogOpen = Page.javascriptDialogOpening(); + + info("Trigger an alert in the test page"); + SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.alert("test-1234"); + // Flip a boolean in the content page to check if the content process resumed + // after the alert was opened. + content.alertIsClosed = true; + }); + + return onDialogOpen; +} diff --git a/remote/cdp/test/browser/page/browser_javascriptDialog_beforeunload.js b/remote/cdp/test/browser/page/browser_javascriptDialog_beforeunload.js new file mode 100644 index 0000000000..7cab579e2c --- /dev/null +++ b/remote/cdp/test/browser/page/browser_javascriptDialog_beforeunload.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test beforeunload dialog events. +add_task(async function ({ client, tab }) { + info("Allow to trigger onbeforeunload without user interaction"); + await new Promise(resolve => { + const options = { + set: [["dom.require_user_interaction_for_beforeunload", false]], + }; + SpecialPowers.pushPrefEnv(options, resolve); + }); + + const { Page } = client; + + info("Enable the page domain"); + await Page.enable(); + + info("Attach a valid onbeforeunload handler"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.onbeforeunload = () => true; + }); + + info("Trigger the beforeunload again but reject the prompt"); + const { type } = await triggerBeforeUnload(Page, tab, false); + is(type, "beforeunload", "dialog event contains the correct type"); + + info("Trigger the beforeunload again and accept the prompt"); + const onTabClose = BrowserTestUtils.waitForEvent(tab, "TabClose"); + await triggerBeforeUnload(Page, tab, true); + + info("Wait for the TabClose event"); + await onTabClose; +}); + +function triggerBeforeUnload(Page, tab, accept) { + // We use then here because after clicking on the close button, nothing + // in the main block of the function will be executed until the prompt + // is accepted or rejected. Attaching a then to this promise still works. + + const onDialogOpen = Page.javascriptDialogOpening().then( + async dialogEvent => { + await Page.handleJavaScriptDialog({ accept }); + return dialogEvent; + } + ); + + info("Click on the tab close icon"); + tab.closeButton.click(); + + return onDialogOpen; +} diff --git a/remote/cdp/test/browser/page/browser_javascriptDialog_confirm.js b/remote/cdp/test/browser/page/browser_javascriptDialog_confirm.js new file mode 100644 index 0000000000..eec7e9828d --- /dev/null +++ b/remote/cdp/test/browser/page/browser_javascriptDialog_confirm.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for window.confirm(). Check that the dialog is correctly detected and that it can +// be rejected or accepted. +add_task(async function ({ client }) { + const { Page } = client; + + info("Enable the page domain"); + await Page.enable(); + + info("Create a confirm dialog to open"); + const { message, type } = await createConfirmDialog(Page); + + is(type, "confirm", "dialog event contains the correct type"); + is(message, "confirm-1234?", "dialog event contains the correct text"); + + info("Accept the dialog"); + await Page.handleJavaScriptDialog({ accept: true }); + let isConfirmed = await getContentProperty("isConfirmed"); + ok(isConfirmed, "The confirm dialog was accepted"); + + await createConfirmDialog(Page); + info("Trigger another confirm in the test page"); + + info("Reject the dialog"); + await Page.handleJavaScriptDialog({ accept: false }); + isConfirmed = await getContentProperty("isConfirmed"); + ok(!isConfirmed, "The confirm dialog was rejected"); +}); + +function createConfirmDialog(Page) { + const onDialogOpen = Page.javascriptDialogOpening(); + + info("Trigger a confirm in the test page"); + SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.isConfirmed = content.confirm("confirm-1234?"); + }); + + return onDialogOpen; +} diff --git a/remote/cdp/test/browser/page/browser_javascriptDialog_otherTarget.js b/remote/cdp/test/browser/page/browser_javascriptDialog_otherTarget.js new file mode 100644 index 0000000000..c57f5d5151 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_javascriptDialog_otherTarget.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +// Test that javascript dialog events are emitted by the page domain only if +// the dialog is created for the window of the target. +add_task(async function ({ client }) { + const { Page } = client; + + info("Enable the page domain"); + await Page.enable(); + + // Add a listener for dialogs on the test page. + Page.javascriptDialogOpening(() => { + ok(false, "Should never receive this event"); + }); + + info("Open another tab"); + const otherTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + toDataURL("test-page") + ); + is(gBrowser.selectedTab, otherTab, "Selected tab is now the new tab"); + + // Create a promise that resolve when dialog prompt is created. + // It will also take care of closing the dialog. + let onOtherPageDialog = PromptTestUtils.handleNextPrompt( + gBrowser.selectedBrowser, + { modalType: Services.prompt.MODAL_TYPE_CONTENT, promptType: "alert" }, + { buttonNumClick: 0 } + ); + + info("Trigger an alert in the second page"); + SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.alert("test"); + }); + + info("Wait for the alert to be detected and closed"); + await onOtherPageDialog; + + info("Call bringToFront on the test page to make sure we received"); + await Page.bringToFront(); + + BrowserTestUtils.removeTab(otherTab); +}); diff --git a/remote/cdp/test/browser/page/browser_javascriptDialog_prompt.js b/remote/cdp/test/browser/page/browser_javascriptDialog_prompt.js new file mode 100644 index 0000000000..c3678e9295 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_javascriptDialog_prompt.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for window.prompt(). Check that the dialog is correctly detected and that it can +// be rejected or accepted, with a custom prompt text. +add_task(async function ({ client }) { + const { Page } = client; + + info("Enable the page domain"); + await Page.enable(); + + info("Create a prompt dialog to open"); + const { message, type } = await createPromptDialog(Page); + + is(type, "prompt", "dialog event contains the correct type"); + is(message, "prompt-1234", "dialog event contains the correct text"); + + info("Accept the prompt"); + await Page.handleJavaScriptDialog({ accept: true, promptText: "some-text" }); + + let promptResult = await getContentProperty("promptResult"); + is(promptResult, "some-text", "The prompt text was correctly applied"); + + await createPromptDialog(Page); + info("Trigger another prompt in the test page"); + + info("Reject the prompt"); + await Page.handleJavaScriptDialog({ accept: false, promptText: "new-text" }); + + promptResult = await getContentProperty("promptResult"); + ok(!promptResult, "The prompt dialog was rejected"); +}); + +function createPromptDialog(Page) { + const onDialogOpen = Page.javascriptDialogOpening(); + + info("Trigger a prompt in the test page"); + SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.promptResult = content.prompt("prompt-1234"); + }); + + return onDialogOpen; +} diff --git a/remote/cdp/test/browser/page/browser_lifecycleEvent.js b/remote/cdp/test/browser/page/browser_lifecycleEvent.js new file mode 100644 index 0000000000..da5f5da9e8 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_lifecycleEvent.js @@ -0,0 +1,191 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +add_task(async function noEventsWhenPageDomainDisabled({ client }) { + await runPageLifecycleTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.disable(); + + await runPageLifecycleTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventWhenLifeCycleDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + + await runPageLifecycleTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventAfterLifeCycleDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.setLifecycleEventsEnabled({ enabled: true }); + await Page.setLifecycleEventsEnabled({ enabled: false }); + + await runPageLifecycleTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function eventsWhenNavigatingWithNoFrames({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.setLifecycleEventsEnabled({ enabled: true }); + + await runPageLifecycleTest(client, 1, async () => { + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventsWhenNavigatingToURLWithNoFrames({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.setLifecycleEventsEnabled({ enabled: true }); + + await runPageLifecycleTest(client, 1, async () => { + info("Navigate to a URL with no frames"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventsWhenNavigatingToSameURLWithNoFrames({ client }) { + const { Page } = client; + + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + + await Page.enable(); + await Page.setLifecycleEventsEnabled({ enabled: true }); + + await runPageLifecycleTest(client, 1, async () => { + info("Navigate to the same page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventsWhenReloadingSameURLWithNoFrames({ client }) { + const { Page } = client; + + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + + await Page.enable(); + await Page.setLifecycleEventsEnabled({ enabled: true }); + + await runPageLifecycleTest(client, 1, async () => { + info("Reload page with no iframes"); + const pageLoaded = Page.loadEventFired(); + await Page.reload(); + await pageLoaded; + }); +}); + +add_task(async function eventsWhenNavigatingWithFrames({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.setLifecycleEventsEnabled({ enabled: true }); + + await runPageLifecycleTest(client, 3, async () => { + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + }); +}); + +add_task(async function eventsWhenNavigatingWithNestedFrames({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.setLifecycleEventsEnabled({ enabled: true }); + + await runPageLifecycleTest(client, 4, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +async function runPageLifecycleTest(client, expectedEventSets, callback) { + const { Page } = client; + + const LIFECYCLE = "Page.lifecycleEvent"; + const LIFECYCLE_EVENTS = ["init", "DOMContentLoaded", "load"]; + + const expectedEventCount = expectedEventSets * LIFECYCLE_EVENTS.length; + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.lifecycleEvent, + eventName: LIFECYCLE, + messageFn: payload => { + return ( + `Received "${payload.name}" ${LIFECYCLE} ` + + `for frame id ${payload.frameId}` + ); + }, + }); + + await callback(); + + const flattenedFrameTree = await getFlattenedFrameTree(client); + + const lifecycleEvents = await history.record(); + is( + lifecycleEvents.length, + expectedEventCount, + "Got expected amount of lifecycle events" + ); + + if (expectedEventCount == 0) { + return; + } + + // Check lifecycle events for each frame + for (const frame of flattenedFrameTree.values()) { + info(`Check frame id ${frame.id}`); + + const frameEvents = lifecycleEvents.filter(({ payload }) => { + return payload.frameId == frame.id; + }); + + Assert.deepEqual( + frameEvents.map(event => event.payload.name), + LIFECYCLE_EVENTS, + "Received various lifecycle events in the expected order" + ); + + // Check data as exposed by each of these events + let lastTimestamp = frameEvents[0].payload.timestamp; + frameEvents.forEach(({ payload }, index) => { + ok( + payload.timestamp >= lastTimestamp, + "timestamp succeeds the one from the former event" + ); + lastTimestamp = payload.timestamp; + + is(payload.loaderId, frame.loaderId, `event has expected loaderId`); + }); + } +} diff --git a/remote/cdp/test/browser/page/browser_loadEventFired.js b/remote/cdp/test/browser/page/browser_loadEventFired.js new file mode 100644 index 0000000000..e85b298feb --- /dev/null +++ b/remote/cdp/test/browser/page/browser_loadEventFired.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noEventWhenPageDomainDisabled({ client }) { + await runLoadEventFiredTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.disable(); + + await runLoadEventFiredTest(client, 0, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNoFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runLoadEventFiredTest(client, 1, async () => { + info("Navigate to a page with no iframes"); + await loadURL(PAGE_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runLoadEventFiredTest(client, 1, async () => { + info("Navigate to a page with iframes"); + await loadURL(FRAMESET_MULTI_URL); + }); +}); + +add_task(async function eventWhenNavigatingWithNestedFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await runLoadEventFiredTest(client, 1, async () => { + info("Navigate to a page with nested iframes"); + await loadURL(FRAMESET_NESTED_URL); + }); +}); + +async function runLoadEventFiredTest(client, expectedEventCount, callback) { + const { Page } = client; + + if (![0, 1].includes(expectedEventCount)) { + throw new Error(`Invalid value for expectedEventCount`); + } + + const LOAD_EVENT_FIRED = "Page.loadEventFired"; + + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.loadEventFired, + eventName: LOAD_EVENT_FIRED, + messageFn: payload => { + return `Received ${LOAD_EVENT_FIRED} at time ${payload.timestamp}`; + }, + }); + + const timeBefore = Date.now() / 1000; + await callback(); + const loadEventFiredEvents = await history.record(); + const timeAfter = Date.now() / 1000; + + is( + loadEventFiredEvents.length, + expectedEventCount, + "Got expected amount of loadEventFired events" + ); + if (expectedEventCount == 0) { + return; + } + + const timestamp = loadEventFiredEvents[0].payload.timestamp; + ok( + timestamp >= timeBefore && timestamp <= timeAfter, + `Timestamp ${timestamp} in expected range [${timeBefore} - ${timeAfter}]` + ); +} diff --git a/remote/cdp/test/browser/page/browser_navigate.js b/remote/cdp/test/browser/page/browser_navigate.js new file mode 100644 index 0000000000..8dff31e911 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_navigate.js @@ -0,0 +1,302 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testBasicNavigation({ client }) { + const { Page, Network } = client; + await Page.enable(); + await Network.enable(); + const loadEventFired = Page.loadEventFired(); + const requestEvent = Network.requestWillBeSent(); + const { frameId, loaderId, errorText } = await Page.navigate({ + url: PAGE_URL, + }); + const { loaderId: requestLoaderId } = await requestEvent; + + ok(!!loaderId, "Page.navigate returns loaderId"); + is( + loaderId, + requestLoaderId, + "Page.navigate returns same loaderId as corresponding request" + ); + is(errorText, undefined, "No errorText on a successful navigation"); + + await loadEventFired; + const currentFrame = await getTopFrame(client); + is(frameId, currentFrame.id, "Page.navigate returns expected frameId"); + + is(gBrowser.selectedBrowser.currentURI.spec, PAGE_URL, "Expected URL loaded"); +}); + +add_task(async function testTwoNavigations({ client }) { + const { Page, Network } = client; + await Page.enable(); + await Network.enable(); + let requestEvent = Network.requestWillBeSent(); + let loadEventFired = Page.loadEventFired(); + const { frameId, loaderId, errorText } = await Page.navigate({ + url: PAGE_URL, + }); + const { loaderId: requestLoaderId } = await requestEvent; + await loadEventFired; + is(gBrowser.selectedBrowser.currentURI.spec, PAGE_URL, "Expected URL loaded"); + + loadEventFired = Page.loadEventFired(); + requestEvent = Network.requestWillBeSent(); + const { + frameId: frameId2, + loaderId: loaderId2, + errorText: errorText2, + } = await Page.navigate({ + url: PAGE_URL, + }); + const { loaderId: requestLoaderId2 } = await requestEvent; + ok(!!loaderId, "Page.navigate returns loaderId"); + ok(!!loaderId2, "Page.navigate returns loaderId"); + isnot(loaderId, loaderId2, "Page.navigate returns different loaderIds"); + is( + loaderId, + requestLoaderId, + "Page.navigate returns same loaderId as corresponding request" + ); + is( + loaderId2, + requestLoaderId2, + "Page.navigate returns same loaderId as corresponding request" + ); + is(errorText, undefined, "No errorText on a successful navigation"); + is(errorText2, undefined, "No errorText on a successful navigation"); + is(frameId, frameId2, "Page.navigate return same frameId"); + + await loadEventFired; + is(gBrowser.selectedBrowser.currentURI.spec, PAGE_URL, "Expected URL loaded"); +}); + +add_task(async function testRedirect({ client }) { + const { Page, Network } = client; + const sjsURL = + "https://example.com/browser/remote/cdp/test/browser/page/sjs_redirect.sjs"; + const redirectURL = `${sjsURL}?${PAGE_URL}`; + await Page.enable(); + await Network.enable(); + const requestEvent = Network.requestWillBeSent(); + const loadEventFired = Page.loadEventFired(); + + const { frameId, loaderId, errorText } = await Page.navigate({ + url: redirectURL, + }); + const { loaderId: requestLoaderId } = await requestEvent; + ok(!!loaderId, "Page.navigate returns loaderId"); + is( + loaderId, + requestLoaderId, + "Page.navigate returns same loaderId as original request" + ); + is(errorText, undefined, "No errorText on a successful navigation"); + ok(!!frameId, "Page.navigate returns frameId"); + + await loadEventFired; + is(gBrowser.selectedBrowser.currentURI.spec, PAGE_URL, "Expected URL loaded"); +}); + +add_task(async function testUnknownHost({ client }) { + const { Page } = client; + const { frameId, loaderId, errorText } = await Page.navigate({ + url: "https://example-does-not-exist.com", + }); + ok(!!frameId, "Page.navigate returns frameId"); + ok(!!loaderId, "Page.navigate returns loaderId"); + is(errorText, "NS_ERROR_UNKNOWN_HOST", "Failed navigation returns errorText"); +}); + +add_task(async function testExpiredCertificate({ client }) { + const { Page } = client; + const { frameId, loaderId, errorText } = await Page.navigate({ + url: "https://expired.example.com", + }); + ok(!!frameId, "Page.navigate returns frameId"); + ok(!!loaderId, "Page.navigate returns loaderId"); + is( + errorText, + "SEC_ERROR_EXPIRED_CERTIFICATE", + "Failed navigation returns errorText" + ); +}); + +add_task(async function testUnknownCertificate({ client }) { + const { Page, Network } = client; + await Network.enable(); + const requestEvent = Network.requestWillBeSent(); + const { frameId, loaderId, errorText } = await Page.navigate({ + url: "https://self-signed.example.com", + }); + const { loaderId: requestLoaderId } = await requestEvent; + ok(!!frameId, "Page.navigate returns frameId"); + ok(!!loaderId, "Page.navigate returns loaderId"); + is( + loaderId, + requestLoaderId, + "Page.navigate returns same loaderId as original request" + ); + is(errorText, "SSL_ERROR_UNKNOWN", "Failed navigation returns errorText"); +}); + +add_task(async function testNotFound({ client }) { + const { Page } = client; + const { frameId, loaderId, errorText } = await Page.navigate({ + url: "https://example.com/browser/remote/doesnotexist.html", + }); + ok(!!frameId, "Page.navigate returns frameId"); + ok(!!loaderId, "Page.navigate returns loaderId"); + is(errorText, undefined, "No errorText on a 404"); +}); + +add_task(async function testInvalidURL({ client }) { + const { Page } = client; + + for (let url of ["blah.com", "foo", "https\n//", "http", ""]) { + await Assert.rejects( + Page.navigate({ url }), + err => err.message.includes("invalid URL"), + `Invalid url ${url} causes error` + ); + } + + for (let url of [2, {}, true]) { + await Assert.rejects( + Page.navigate({ url }), + err => err.message.includes("string value expected"), + `Invalid url ${url} causes error` + ); + } +}); + +add_task(async function testDataURL({ client }) { + const { Page } = client; + const url = toDataURL("first"); + await Page.enable(); + const loadEventFired = Page.loadEventFired(); + const frameNavigatedFired = Page.frameNavigated(); + const { frameId, loaderId, errorText } = await Page.navigate({ url }); + is(errorText, undefined, "No errorText on a successful navigation"); + ok(!!loaderId, "Page.navigate returns loaderId"); + + await loadEventFired; + const { frame } = await frameNavigatedFired; + is(frame.loaderId, loaderId, "Page.navigate returns expected loaderId"); + const currentFrame = await getTopFrame(client); + is(frameId, currentFrame.id, "Page.navigate returns expected frameId"); + is(gBrowser.selectedBrowser.currentURI.spec, url, "Expected URL loaded"); +}); + +add_task(async function testFileURL({ client }) { + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + if (AppConstants.DEBUG) { + // Bug 1634695 Navigating to a file URL forces the TabSession to destroy + // abruptly and content domains are not properly destroyed, which creates + // window leaks and fails the test in DEBUG mode. + return; + } + + const { Page } = client; + const dir = getChromeDir(getResolvedURI(gTestPath)); + dir.append("doc_empty.html"); + + // The file can be a symbolic link on local build. Normalize it to make sure + // the path matches to the actual URI opened in the new tab. + dir.normalize(); + const url = Services.io.newFileURI(dir).spec; + const browser = gBrowser.selectedTab.linkedBrowser; + const loaded = BrowserTestUtils.browserLoaded(browser, false, url); + + const { /* frameId, */ loaderId, errorText } = await Page.navigate({ url }); + is(errorText, undefined, "No errorText on a successful navigation"); + ok(!!loaderId, "Page.navigate returns loaderId"); + + // Bug 1634693 Page.loadEventFired isn't emitted after file: navigation + await loaded; + is(browser.currentURI.spec, url, "Expected URL loaded"); + // Bug 1634695 Navigating to file: returns wrong frame id and hangs + // content page domain methods + // const currentFrame = await getTopFrame(client); + // ok(frameId === currentFrame.id, "Page.navigate returns expected frameId"); +}); + +add_task(async function testAbout({ client }) { + const { Page } = client; + await Page.enable(); + let loadEventFired = Page.loadEventFired(); + let frameNavigatedFired = Page.frameNavigated(); + const { frameId, loaderId, errorText } = await Page.navigate({ + url: "about:blank", + }); + ok(!!loaderId, "Page.navigate returns loaderId"); + is(errorText, undefined, "No errorText on a successful navigation"); + + await loadEventFired; + const { frame } = await frameNavigatedFired; + is(frame.loaderId, loaderId, "Page.navigate returns expected loaderId"); + const currentFrame = await getTopFrame(client); + is(frameId, currentFrame.id, "Page.navigate returns expected frameId"); + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:blank", + "Expected URL loaded" + ); +}); + +add_task(async function testSameDocumentNavigation({ client }) { + const { Page } = client; + const { frameId, loaderId } = await Page.navigate({ + url: PAGE_URL, + }); + ok(!!loaderId, "Page.navigate returns loaderId"); + + await Page.enable(); + const navigatedWithinDocument = Page.navigatedWithinDocument(); + + info("Check that Page.navigate can navigate to an anchor"); + const sameDocumentURL = `${PAGE_URL}#hash`; + const { frameId: sameDocumentFrameId, loaderId: sameDocumentLoaderId } = + await Page.navigate({ url: sameDocumentURL }); + ok( + !sameDocumentLoaderId, + "Page.navigate does not return a loaderId for same document navigation" + ); + is( + sameDocumentFrameId, + frameId, + "Page.navigate returned the expected frame id" + ); + + const { frameId: navigatedFrameId, url } = await navigatedWithinDocument; + is( + frameId, + navigatedFrameId, + "navigatedWithinDocument returns the expected frameId" + ); + is(url, sameDocumentURL, "navigatedWithinDocument returns the expected url"); + is( + gBrowser.selectedBrowser.currentURI.spec, + sameDocumentURL, + "Expected URL loaded" + ); + + info("Check that navigating to the same hash URL does not timeout"); + const { frameId: sameHashFrameId, loaderId: sameHashLoaderId } = + await Page.navigate({ url: sameDocumentURL }); + ok( + !sameHashLoaderId, + "Page.navigate does not return a loaderId for same document navigation" + ); + is(sameHashFrameId, frameId, "Page.navigate returned the expected frame id"); +}); + +async function getTopFrame(client) { + const frames = await getFlattenedFrameTree(client); + return Array.from(frames.values())[0]; +} diff --git a/remote/cdp/test/browser/page/browser_navigateToHistoryEntry.js b/remote/cdp/test/browser/page/browser_navigateToHistoryEntry.js new file mode 100644 index 0000000000..8a7f3cb21a --- /dev/null +++ b/remote/cdp/test/browser/page/browser_navigateToHistoryEntry.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function toUnknownEntryId({ client }) { + const { Page } = client; + + const { entries } = await Page.getNavigationHistory(); + const ids = entries.map(entry => entry.id); + + await Assert.rejects( + Page.navigateToHistoryEntry({ entryId: Math.max(...ids) + 1 }), + /No entry with passed id/, + "Unknown entry id raised error" + ); +}); + +add_task(async function toSameEntry({ client }) { + const { Page } = client; + + const data = generateHistoryData(1); + for (const entry of data) { + await loadURL(entry.userTypedURL); + } + + const { currentIndex, entries } = await Page.getNavigationHistory(); + await Page.navigateToHistoryEntry({ entryId: entries[currentIndex].id }); + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, 0); + + is( + gBrowser.selectedBrowser.currentURI.spec, + data[0].url, + "Expected URL loaded" + ); +}); + +add_task(async function oneEntryBackInHistory({ client }) { + const { Page } = client; + + const data = generateHistoryData(3); + for (const entry of data) { + await loadURL(entry.userTypedURL); + } + + const { currentIndex, entries } = await Page.getNavigationHistory(); + await Page.navigateToHistoryEntry({ entryId: entries[currentIndex - 1].id }); + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, currentIndex - 1); + + is( + gBrowser.selectedBrowser.currentURI.spec, + data[currentIndex - 1].url, + "Expected URL loaded" + ); +}); + +add_task(async function oneEntryForwardInHistory({ client }) { + const { Page } = client; + + const data = generateHistoryData(3); + for (const entry of data) { + await loadURL(entry.userTypedURL); + } + + await gotoHistoryIndex(0); + + const { currentIndex, entries } = await Page.getNavigationHistory(); + await Page.navigateToHistoryEntry({ entryId: entries[currentIndex + 1].id }); + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, currentIndex + 1); + + is( + gBrowser.selectedBrowser.currentURI.spec, + data[currentIndex + 1].url, + "Expected URL loaded" + ); +}); + +add_task(async function toFirstEntryInHistory({ client }) { + const { Page } = client; + + const data = generateHistoryData(3); + for (const entry of data) { + await loadURL(entry.userTypedURL); + } + + const { entries } = await Page.getNavigationHistory(); + await Page.navigateToHistoryEntry({ entryId: entries[0].id }); + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, 0); + + is( + gBrowser.selectedBrowser.currentURI.spec, + data[0].url, + "Expected URL loaded" + ); +}); + +add_task(async function toLastEntryInHistory({ client }) { + const { Page } = client; + + const data = generateHistoryData(3); + for (const entry of data) { + await loadURL(entry.userTypedURL); + } + + await gotoHistoryIndex(0); + + const { entries } = await Page.getNavigationHistory(); + await Page.navigateToHistoryEntry({ + entryId: entries[entries.length - 1].id, + }); + + const history = await Page.getNavigationHistory(); + assertHistoryEntries(history, data, data.length - 1); + + is( + gBrowser.selectedBrowser.currentURI.spec, + data[data.length - 1].url, + "Expected URL loaded" + ); +}); diff --git a/remote/cdp/test/browser/page/browser_navigatedWithinDocument.js b/remote/cdp/test/browser/page/browser_navigatedWithinDocument.js new file mode 100644 index 0000000000..13bab69387 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_navigatedWithinDocument.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noEventWhenPageDomainDisabled({ client }) { + await loadURL(PAGE_URL); + await runNavigatedWithinDocumentTest(client, 0, async () => { + info("Navigate to the '#hash' anchor in the page"); + await navigateToAnchor(PAGE_URL, "hash"); + }); +}); + +add_task(async function noEventAfterPageDomainDisabled({ client }) { + const { Page } = client; + + await Page.enable(); + await Page.disable(); + + await loadURL(PAGE_URL); + + await runNavigatedWithinDocumentTest(client, 0, async () => { + info("Navigate to the '#hash' anchor in the page"); + await navigateToAnchor(PAGE_URL, "hash"); + }); +}); + +add_task(async function eventWhenNavigatingToHash({ client }) { + const { Page } = client; + + await Page.enable(); + + await loadURL(PAGE_URL); + + await runNavigatedWithinDocumentTest(client, 1, async () => { + info("Navigate to the '#hash' anchor in the page"); + await navigateToAnchor(PAGE_URL, "hash"); + }); +}); + +add_task(async function eventWhenNavigatingToDifferentHash({ client }) { + const { Page } = client; + + await Page.enable(); + + await navigateToAnchor(PAGE_URL, "hash"); + + await runNavigatedWithinDocumentTest(client, 1, async () => { + info("Navigate to the '#hash' anchor in the page"); + await navigateToAnchor(PAGE_URL, "other-hash"); + }); +}); + +add_task(async function eventWhenNavigatingToHashInFrames({ client }) { + const { Page } = client; + + await Page.enable(); + + await loadURL(FRAMESET_NESTED_URL); + + await runNavigatedWithinDocumentTest(client, 1, async () => { + info("Navigate to the '#hash' anchor in the first iframe"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const iframe = content.frames[0]; + const baseUrl = iframe.location.href; + iframe.location.href = baseUrl + "#hash-first-frame"; + }); + }); + + await runNavigatedWithinDocumentTest(client, 2, async () => { + info("Navigate to the '#hash' anchor in the nested iframes"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const nestedFrame1 = content.frames[0].frames[0]; + const baseUrl1 = nestedFrame1.location.href; + nestedFrame1.location.href = baseUrl1 + "#hash-nested-frame-1"; + const nestedFrame2 = content.frames[0].frames[0]; + const baseUrl2 = nestedFrame2.location.href; + nestedFrame2.location.href = baseUrl2 + "#hash-nested-frame-2"; + }); + }); +}); + +async function runNavigatedWithinDocumentTest( + client, + expectedEventCount, + callback +) { + const { Page } = client; + + const NAVIGATED = "Page.navigatedWithinDocument"; + + const history = new RecordEvents(expectedEventCount); + history.addRecorder({ + event: Page.navigatedWithinDocument, + eventName: NAVIGATED, + messageFn: payload => { + return `Received ${NAVIGATED} for frame id ${payload.frameId}`; + }, + }); + + await callback(); + + const navigatedWithinDocumentEvents = await history.record(); + + is( + navigatedWithinDocumentEvents.length, + expectedEventCount, + "Got expected amount of navigatedWithinDocument events" + ); + if (expectedEventCount == 0) { + return; + } + + const frames = await getFlattenedFrameTree(client); + + navigatedWithinDocumentEvents.forEach(({ payload }) => { + const { frameId, url } = payload; + + const frame = frames.get(frameId); + ok(frame, "Returned a valid frame id"); + is(url, frame.url, "Returned the expectedUrl"); + }); +} + +function navigateToAnchor(baseUrl, hash) { + const url = `${baseUrl}#${hash}`; + const onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + url + ); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); + return onLocationChange; +} diff --git a/remote/cdp/test/browser/page/browser_navigationEvents.js b/remote/cdp/test/browser/page/browser_navigationEvents.js new file mode 100644 index 0000000000..1e589a3970 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_navigationEvents.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the Page navigation events + +const RANDOM_ID_PAGE_URL = toDataURL( + `<script>window.randomId = Math.random() + "-" + Date.now();</script>` +); + +const promises = new Set(); +const resolutions = new Map(); + +add_task(async function pageWithoutFrame({ client }) { + await loadURL(PAGE_URL); + + const { Page } = client; + + // turn on navigation related events, such as DOMContentLoaded et al. + await Page.enable(); + info("Page domain has been enabled"); + + const { frameTree } = await Page.getFrameTree(); + + // Save the given `promise` resolution into the `promises` global Set + function recordPromise(name, promise) { + promise.then(event => { + info(`Received Page.${name}`); + resolutions.set(name, event); + }); + promises.add(promise); + } + // Record all Page events that we assert in this test + function recordPromises() { + recordPromise("frameStartedLoading", Page.frameStartedLoading()); + recordPromise("frameNavigated", Page.frameNavigated()); + recordPromise("domContentEventFired", Page.domContentEventFired()); + recordPromise("loadEventFired", Page.loadEventFired()); + recordPromise("frameStoppedLoading", Page.frameStoppedLoading()); + } + + info("Test Page.navigate"); + recordPromises(); + + let navigatedWithinDocumentResolved = false; + Page.navigatedWithinDocument().finally( + () => (navigatedWithinDocumentResolved = true) + ); + + const url = RANDOM_ID_PAGE_URL; + const { frameId } = await Page.navigate({ url }); + info("A new page has been requested"); + + ok(frameId, "Page.navigate returned a frameId"); + is( + frameId, + frameTree.frame.id, + "The Page.navigate's frameId is the same than getFrameTree's one" + ); + + await assertNavigationEvents({ url, frameId }); + + const randomId1 = await getTestTabRandomId(); + ok(!!randomId1, "Test tab has a valid randomId"); + + info("Test Page.reload"); + recordPromises(); + + await Page.reload(); + info("The page has been reloaded"); + + await assertNavigationEvents({ url, frameId }); + + const randomId2 = await getTestTabRandomId(); + ok(!!randomId2, "Test tab has a valid randomId"); + isnot( + randomId2, + randomId1, + "Test tab randomId has been updated after reload" + ); + + info("Test Page.navigate with the same URL still reloads the current page"); + recordPromises(); + + await Page.navigate({ url }); + info("The page has been reloaded"); + + await assertNavigationEvents({ url, frameId }); + + const randomId3 = await getTestTabRandomId(); + ok(!!randomId3, "Test tab has a valid randomId"); + isnot( + randomId3, + randomId2, + "Test tab randomId has been updated after reload" + ); + + ok( + !navigatedWithinDocumentResolved, + "navigatedWithinDocument never resolved during the test" + ); +}); + +add_task(async function pageWithSingleFrame({ client }) { + const { Page } = client; + + await Page.enable(); + + // Store all frameNavigated events in an array + const frameNavigatedEvents = []; + Page.frameNavigated(e => frameNavigatedEvents.push(e)); + + info("Navigate to a page containing an iframe"); + const onStoppedLoading = Page.frameStoppedLoading(); + const { frameId } = await Page.navigate({ url: FRAMESET_SINGLE_URL }); + await onStoppedLoading; + + is(frameNavigatedEvents.length, 2, "Received 2 frameNavigated events"); + is( + frameNavigatedEvents[0].frame.id, + frameId, + "Received the correct frameId for the frameNavigated event" + ); +}); + +add_task(async function sameDocumentNavigation({ client }) { + await loadURL(PAGE_URL); + + const { Page } = client; + + // turn on navigation related events, such as DOMContentLoaded et al. + await Page.enable(); + info("Page domain has been enabled"); + + const { frameTree } = await Page.getFrameTree(); + + info("Test Page.navigate for a same document navigation"); + const onNavigatedWithinDocument = Page.navigatedWithinDocument(); + + let unexpectedEventResolved = false; + Promise.race([ + Page.frameStartedLoading(), + Page.frameNavigated(), + Page.domContentEventFired(), + Page.loadEventFired(), + Page.frameStoppedLoading(), + ]).then(() => (unexpectedEventResolved = true)); + + const url = `${PAGE_URL}#some-hash`; + const { frameId } = await Page.navigate({ url }); + ok(frameId, "Page.navigate returned a frameId"); + is( + frameId, + frameTree.frame.id, + "The Page.navigate's frameId is the same than getFrameTree's one" + ); + + const event = await onNavigatedWithinDocument; + is( + event.frameId, + frameId, + "The navigatedWithinDocument frameId is the same as in Page.navigate" + ); + is(event.url, url, "The navigatedWithinDocument url is the expected url"); + ok(!unexpectedEventResolved, "No unexpected navigation event resolved."); +}); + +async function assertNavigationEvents({ url, frameId }) { + // Wait for all the promises to resolve + await Promise.all(promises); + + // Assert the order in which they resolved + const expectedResolutions = [ + "frameStartedLoading", + "frameNavigated", + "domContentEventFired", + "loadEventFired", + "frameStoppedLoading", + ]; + Assert.deepEqual( + [...resolutions.keys()], + expectedResolutions, + "Received various Page navigation events in the expected order" + ); + + // Now assert the data exposed by each of these events + const frameStartedLoading = resolutions.get("frameStartedLoading"); + is( + frameStartedLoading.frameId, + frameId, + "frameStartedLoading frameId is the same one" + ); + + const frameNavigated = resolutions.get("frameNavigated"); + ok( + !frameNavigated.frame.parentId, + "frameNavigated is for the top level document and has a null parentId" + ); + is(frameNavigated.frame.id, frameId, "frameNavigated id is the right one"); + is( + frameNavigated.frame.name, + undefined, + "frameNavigated name isn't implemented yet" + ); + is(frameNavigated.frame.url, url, "frameNavigated url is the right one"); + + const frameStoppedLoading = resolutions.get("frameStoppedLoading"); + is( + frameStoppedLoading.frameId, + frameId, + "frameStoppedLoading frameId is the same one" + ); + + promises.clear(); + resolutions.clear(); +} + +async function getTestTabRandomId() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.randomId; + }); +} diff --git a/remote/cdp/test/browser/page/browser_printToPDF.js b/remote/cdp/test/browser/page/browser_printToPDF.js new file mode 100644 index 0000000000..fed1a6162e --- /dev/null +++ b/remote/cdp/test/browser/page/browser_printToPDF.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const DOC = toDataURL("<div style='background-color: green'>Hello world</div>"); + +add_task(async function transferModes({ client }) { + const { IO, Page } = client; + await loadURL(DOC); + + // as base64 encoded data + const base64 = await Page.printToPDF({ transferMode: "ReturnAsBase64" }); + is(base64.stream, null, "No stream handle is returned"); + ok(!!base64.data, "Base64 encoded data is returned"); + verifyPDF(atob(base64.data).trimEnd()); + + // defaults to base64 encoded data + const defaults = await Page.printToPDF(); + is(defaults.stream, null, "By default no stream handle is returned"); + ok(!!defaults.data, "By default base64 encoded data is returned"); + verifyPDF(atob(defaults.data).trimEnd()); + + // unknown transfer modes default to base64 + const fallback = await Page.printToPDF({ transferMode: "ReturnAsFoo" }); + is(fallback.stream, null, "Unknown mode doesn't return a stream"); + ok(!!fallback.data, "Unknown mode defaults to base64 encoded data"); + verifyPDF(atob(fallback.data).trimEnd()); + + // as stream handle + const stream = await Page.printToPDF({ transferMode: "ReturnAsStream" }); + ok(!!stream.stream, "Stream handle is returned"); + is(stream.data, null, "No base64 encoded data is returned"); + let streamData = ""; + + while (true) { + const { data, base64Encoded, eof } = await IO.read({ + handle: stream.stream, + }); + streamData += base64Encoded ? atob(data) : data; + if (eof) { + await IO.close({ handle: stream.stream }); + break; + } + } + + verifyPDF(streamData.trimEnd()); +}); + +function verifyPDF(data) { + is(data.slice(0, 5), "%PDF-", "Decoded data starts with the PDF signature"); + is(data.slice(-5), "%%EOF", "Decoded data ends with the EOF flag"); +} diff --git a/remote/cdp/test/browser/page/browser_reload.js b/remote/cdp/test/browser/page/browser_reload.js new file mode 100644 index 0000000000..0872337551 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_reload.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testReload({ client }) { + const { Page } = client; + await loadURL(toDataURL("halløj")); + + info("Reloading document"); + await Page.enable(); + const loaded = Page.loadEventFired(); + await Page.reload(); + await loaded; + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + ok(!content.docShell.isForceReloading, "Document is not force-reloaded"); + }); +}); + +add_task(async function testReloadIgnoreCache({ client }) { + const { Page } = client; + await loadURL(toDataURL("halløj")); + + info("Force-reloading document"); + await Page.enable(); + const loaded = Page.loadEventFired(); + await Page.reload({ ignoreCache: true }); + await loaded; + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + ok(content.docShell.isForceReloading, "Document is force-reloaded"); + }); +}); diff --git a/remote/cdp/test/browser/page/browser_runtimeEvents.js b/remote/cdp/test/browser/page/browser_runtimeEvents.js new file mode 100644 index 0000000000..7f6d7ec926 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_runtimeEvents.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Assert the order of Runtime.executionContextDestroyed, +// Page.frameNavigated, and Runtime.executionContextCreated + +add_task(async function testCDP({ client }) { + await loadURL(PAGE_URL); + + const { Page, Runtime } = client; + + const events = []; + function assertReceivedEvents(expected, message) { + Assert.deepEqual(events, expected, message); + // Empty the list of received events + events.splice(0); + } + Page.frameNavigated(() => { + events.push("frameNavigated"); + }); + Runtime.executionContextCreated(() => { + events.push("executionContextCreated"); + }); + Runtime.executionContextDestroyed(() => { + events.push("executionContextDestroyed"); + }); + + // turn on navigation related events, such as DOMContentLoaded et al. + await Page.enable(); + info("Page domain has been enabled"); + + const onExecutionContextCreated = Runtime.executionContextCreated(); + await Runtime.enable(); + info("Runtime domain has been enabled"); + + // Runtime.enable will dispatch `executionContextCreated` for the existing document + let { context } = await onExecutionContextCreated; + ok(!!context.id, `The execution context has an id ${context.id}`); + ok(context.auxData.isDefault, "The execution context is the default one"); + ok(!!context.auxData.frameId, "The execution context has a frame id set"); + + assertReceivedEvents( + ["executionContextCreated"], + "Received only executionContextCreated event after Runtime.enable call" + ); + + const { frameTree } = await Page.getFrameTree(); + is( + frameTree.frame.id, + context.auxData.frameId, + "getFrameTree and executionContextCreated refers about the same frame Id" + ); + + const onFrameNavigated = Page.frameNavigated(); + const onExecutionContextDestroyed = Runtime.executionContextDestroyed(); + const onExecutionContextCreated2 = Runtime.executionContextCreated(); + const url = toDataURL("test-page"); + const { frameId } = await Page.navigate({ url }); + info("A new page has been requested"); + ok(frameId, "Page.navigate returned a frameId"); + is( + frameId, + frameTree.frame.id, + "The Page.navigate's frameId is the same than getFrameTree's one" + ); + + const frameNavigated = await onFrameNavigated; + ok( + !frameNavigated.frame.parentId, + "frameNavigated is for the top level document and has a null parentId" + ); + is( + frameNavigated.frame.id, + frameId, + "frameNavigated id is the same than the one returned by Page.navigate" + ); + is( + frameNavigated.frame.name, + undefined, + "frameNavigated name isn't implemented yet" + ); + is( + frameNavigated.frame.url, + url, + "frameNavigated url is the same being given to Page.navigate" + ); + + const { executionContextId } = await onExecutionContextDestroyed; + ok(executionContextId, "The destroyed event reports an id"); + is( + executionContextId, + context.id, + "The destroyed event is for the first reported execution context" + ); + + ({ context } = await onExecutionContextCreated2); + ok(!!context.id, "The execution context has an id"); + ok(context.auxData.isDefault, "The execution context is the default one"); + is( + context.auxData.frameId, + frameId, + "The execution context frame id is the same " + + "the one returned by Page.navigate" + ); + + isnot( + executionContextId, + context.id, + "The destroyed id is different from the created one" + ); + + assertReceivedEvents( + ["executionContextDestroyed", "frameNavigated", "executionContextCreated"], + "Received frameNavigated between the two execution context events during navigation to another URL" + ); +}); diff --git a/remote/cdp/test/browser/page/browser_scriptToEvaluateOnNewDocument.js b/remote/cdp/test/browser/page/browser_scriptToEvaluateOnNewDocument.js new file mode 100644 index 0000000000..274119ffd4 --- /dev/null +++ b/remote/cdp/test/browser/page/browser_scriptToEvaluateOnNewDocument.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test Page.addScriptToEvaluateOnNewDocument and Page.removeScriptToEvaluateOnNewDocument +// +// TODO Bug 1601695 - Schedule script evaluation and check for correct frame id + +const WORLD = "testWorld"; + +add_task(async function uniqueIdForAddedScripts({ client }) { + const { Page, Runtime } = client; + + await loadURL(PAGE_URL); + + const { identifier: id1 } = await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + }); + is(typeof id1, "string", "Script id should be a string"); + ok(id1.length, "Script id is non-empty"); + + const { identifier: id2 } = await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + }); + ok(id2.length, "Script id is non-empty"); + isnot(id1, id2, "Two scripts should have different ids"); + + await Runtime.enable(); + + // flush event for PAGE_URL default context + await Runtime.executionContextCreated(); + await checkIsolatedContextAfterLoad(client, PAGE_FRAME_URL, []); +}); + +add_task(async function addScriptAfterNavigation({ client }) { + const { Page } = client; + + await loadURL(PAGE_URL); + + const { identifier: id1 } = await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + }); + is(typeof id1, "string", "Script id should be a string"); + ok(id1.length, "Script id is non-empty"); + + await loadURL(PAGE_FRAME_URL); + + const { identifier: id2 } = await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 2;", + }); + ok(id2.length, "Script id is non-empty"); + isnot(id1, id2, "Two scripts should have different ids"); +}); + +add_task(async function addWithIsolatedWorldAndNavigate({ client }) { + const { Page, Runtime } = client; + + await Page.enable(); + await Runtime.enable(); + + const contextsCreated = recordContextCreated(Runtime, 3); + + const loadEventFired = Page.loadEventFired(); + const { frameId } = await Page.navigate({ url: PAGE_URL }); + await loadEventFired; + + // flush context-created events for the steps above + await contextsCreated; + + await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + worldName: WORLD, + }); + + const isolatedId = await Page.createIsolatedWorld({ + frameId, + worldName: WORLD, + grantUniversalAccess: true, + }); + + const contexts = await checkIsolatedContextAfterLoad(client, PAGE_FRAME_URL); + isnot(contexts[1].id, isolatedId, "The context has a new id"); +}); + +add_task(async function addWithIsolatedWorldNavigateTwice({ client }) { + const { Page, Runtime } = client; + + await Runtime.enable(); + + await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + worldName: WORLD, + }); + + await checkIsolatedContextAfterLoad(client, PAGE_URL); + await checkIsolatedContextAfterLoad(client, PAGE_FRAME_URL); +}); + +add_task(async function addTwoScriptsWithIsolatedWorld({ client }) { + const { Page, Runtime } = client; + + await Runtime.enable(); + + const names = [WORLD, "A_whole_new_world"]; + await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + worldName: names[0], + }); + await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 8;", + worldName: names[1], + }); + + await checkIsolatedContextAfterLoad(client, PAGE_URL, names); +}); + +function recordContextCreated(Runtime, expectedCount) { + return new Promise(resolve => { + const ctx = []; + const unsubscribe = Runtime.executionContextCreated(payload => { + ctx.push(payload.context); + info( + `Runtime.executionContextCreated: ${payload.context.auxData.type} ` + + `(${payload.context.origin})` + ); + if (ctx.length > expectedCount) { + unsubscribe(); + resolve(ctx); + } + }); + timeoutPromise(1000).then(() => { + unsubscribe(); + resolve(ctx); + }); + }); +} + +async function checkIsolatedContextAfterLoad(client, url, names = [WORLD]) { + const { Page, Runtime } = client; + + await Page.enable(); + + // At least the default context will get created + const expected = names.length + 1; + + const contextsCreated = recordContextCreated(Runtime, expected); + const frameNavigated = Page.frameNavigated(); + const { frameId } = await Page.navigate({ url }); + await frameNavigated; + const contexts = await contextsCreated; + + is(contexts.length, expected, "Expected number of contexts got created"); + is(contexts[0].auxData.frameId, frameId, "Expected frame id found"); + is(contexts[0].auxData.isDefault, true, "Got default context"); + is(contexts[0].auxData.type, "default", "Got default context"); + is(contexts[0].name, "", "Get context with empty name"); + + names.forEach((name, index) => { + is(contexts[index + 1].name, name, "Get context with expected name"); + is(contexts[index + 1].auxData.frameId, frameId, "Expected frame id found"); + is(contexts[index + 1].auxData.isDefault, false, "Got isolated context"); + is(contexts[index + 1].auxData.type, "isolated", "Got isolated context"); + }); + + return contexts; +} diff --git a/remote/cdp/test/browser/page/doc_empty.html b/remote/cdp/test/browser/page/doc_empty.html new file mode 100644 index 0000000000..e59d2d8901 --- /dev/null +++ b/remote/cdp/test/browser/page/doc_empty.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Empty page</title> +</head> +<body> +</body> +</html> diff --git a/remote/cdp/test/browser/page/doc_frame.html b/remote/cdp/test/browser/page/doc_frame.html new file mode 100644 index 0000000000..e2efd61554 --- /dev/null +++ b/remote/cdp/test/browser/page/doc_frame.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Frame page</title> +</head> +<body> +</body> +</html> diff --git a/remote/cdp/test/browser/page/doc_frameset_multi.html b/remote/cdp/test/browser/page/doc_frameset_multi.html new file mode 100644 index 0000000000..dd59a60431 --- /dev/null +++ b/remote/cdp/test/browser/page/doc_frameset_multi.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Frameset with multiple frames</title> +</head> +<body> + <iframe src="doc_empty.html"></iframe> + <iframe src="doc_frame.html"></iframe> +</body> +</html> diff --git a/remote/cdp/test/browser/page/doc_frameset_nested.html b/remote/cdp/test/browser/page/doc_frameset_nested.html new file mode 100644 index 0000000000..bd0b4b48c9 --- /dev/null +++ b/remote/cdp/test/browser/page/doc_frameset_nested.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Frameset with nested frames</title> +</head> +<body> + <iframe src="doc_frameset_multi.html"></iframe> +</body> +</html> diff --git a/remote/cdp/test/browser/page/doc_frameset_single.html b/remote/cdp/test/browser/page/doc_frameset_single.html new file mode 100644 index 0000000000..2ad56a140e --- /dev/null +++ b/remote/cdp/test/browser/page/doc_frameset_single.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Frameset with a single frame</title> +</head> +<body> + <iframe src="doc_frame.html"></iframe> +</body> +</html> diff --git a/remote/cdp/test/browser/page/head.js b/remote/cdp/test/browser/page/head.js new file mode 100644 index 0000000000..46a4bdc21b --- /dev/null +++ b/remote/cdp/test/browser/page/head.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); + +const { PollPromise } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Sync.sys.mjs" +); + +const BASE_ORIGIN = "https://example.com"; +const BASE_PATH = `${BASE_ORIGIN}/browser/remote/cdp/test/browser/page`; +const FRAMESET_MULTI_URL = `${BASE_PATH}/doc_frameset_multi.html`; +const FRAMESET_NESTED_URL = `${BASE_PATH}/doc_frameset_nested.html`; +const FRAMESET_SINGLE_URL = `${BASE_PATH}/doc_frameset_single.html`; +const PAGE_FRAME_URL = `${BASE_PATH}/doc_frame.html`; +const PAGE_URL = `${BASE_PATH}/doc_empty.html`; + +const TIMEOUT_SET_HISTORY_INDEX = 1000; + +function assertHistoryEntries(history, expectedData, expectedIndex) { + const { currentIndex, entries } = history; + + is(currentIndex, expectedIndex, "Got expected current index"); + is( + entries.length, + expectedData.length, + "Found expected count of history entries" + ); + + entries.forEach((entry, index) => { + ok(!!entry.id, "History entry has an id set"); + is( + entry.url, + expectedData[index].url, + "History entry has the correct URL set" + ); + is( + entry.userTypedURL, + expectedData[index].userTypedURL, + "History entry has the correct user typed URL set" + ); + is( + entry.title, + expectedData[index].title, + "History entry has the correct title set" + ); + }); +} + +function generateHistoryData(count) { + const data = []; + + for (let index = 0; index < count; index++) { + const url = toDataURL(`<head><title>Test ${index + 1}</title></head>`); + data.push({ + url, + userTypedURL: url, + title: `Test ${index + 1}`, + }); + } + + return data; +} + +async function getContentSize() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const docEl = content.document.documentElement; + + return { + x: 0, + y: 0, + width: docEl.scrollWidth, + height: docEl.scrollHeight, + }; + }); +} + +async function getViewportSize() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return { + x: content.pageXOffset, + y: content.pageYOffset, + width: content.innerWidth, + height: content.innerHeight, + }; + }); +} + +function getCurrentHistoryIndex() { + return new Promise(resolve => { + SessionStore.getSessionHistory(window.gBrowser.selectedTab, history => { + resolve(history.index); + }); + }); +} + +async function gotoHistoryIndex(index) { + gBrowser.gotoIndex(index); + + // On some platforms the requested index isn't set immediately. + await PollPromise( + async (resolve, reject) => { + const currentIndex = await getCurrentHistoryIndex(); + if (currentIndex == index) { + resolve(); + } else { + reject(); + } + }, + { timeout: TIMEOUT_SET_HISTORY_INDEX } + ); +} diff --git a/remote/cdp/test/browser/page/sjs_redirect.sjs b/remote/cdp/test/browser/page/sjs_redirect.sjs new file mode 100644 index 0000000000..b3dbf44f53 --- /dev/null +++ b/remote/cdp/test/browser/page/sjs_redirect.sjs @@ -0,0 +1,7 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 301, "Moved Permanently"); + response.setHeader("Location", request.queryString, false); +} diff --git a/remote/cdp/test/browser/runtime/browser.toml b/remote/cdp/test/browser/runtime/browser.toml new file mode 100644 index 0000000000..8632b7fad2 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser.toml @@ -0,0 +1,48 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "doc_console_events.html", + "doc_console_events_onload.html", + "doc_empty.html", + "doc_frame.html", + "doc_frameset_single.html", + "head.js", +] + +["browser_callFunctionOn.js"] + +["browser_callFunctionOn_awaitPromise.js"] + +["browser_callFunctionOn_returnByValue.js"] + +["browser_consoleAPICalled.js"] +https_first_disabled = true + +["browser_evaluate.js"] + +["browser_evaluate_awaitPromise.js"] + +["browser_evaluate_returnByValue.js"] + +["browser_exceptionThrown.js"] +https_first_disabled = true + +["browser_executionContextEvents.js"] + +["browser_getProperties.js"] + +["browser_remoteObjects.js"] diff --git a/remote/cdp/test/browser/runtime/browser_callFunctionOn.js b/remote/cdp/test/browser/runtime/browser_callFunctionOn.js new file mode 100644 index 0000000000..965f9f9267 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_callFunctionOn.js @@ -0,0 +1,285 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_DOC = toDataURL("default-test-page"); + +add_task(async function FunctionDeclarationMissing({ client }) { + const { Runtime } = client; + await Assert.rejects( + Runtime.callFunctionOn(), + err => err.message.includes("functionDeclaration: string value expected"), + "functionDeclaration: string value expected" + ); +}); + +add_task(async function functionDeclarationInvalidTypes({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + for (const functionDeclaration of [null, true, 1, [], {}]) { + await Assert.rejects( + Runtime.callFunctionOn({ functionDeclaration, executionContextId }), + err => err.message.includes("functionDeclaration: string value expected"), + "functionDeclaration: string value expected" + ); + } +}); + +add_task(async function functionDeclarationGetCurrentLocation({ client }) { + const { Runtime } = client; + + await loadURL(TEST_DOC); + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => location.href", + executionContextId, + }); + is(result.value, TEST_DOC, "Works against the test page"); +}); + +add_task(async function argumentsInvalidTypes({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + for (const args of [null, true, 1, "foo", {}]) { + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration: "", + arguments: args, + executionContextId, + }), + err => err.message.includes("arguments: array value expected"), + "arguments: array value expected" + ); + } +}); + +add_task(async function argumentsPrimitiveTypes({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + for (const args of [null, true, 1, "foo", {}]) { + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration: "", + arguments: args, + executionContextId, + }), + err => err.message.includes("arguments: array value expected"), + "arguments: array value expected" + ); + } +}); + +add_task(async function executionContextIdNorObjectIdSpecified({ client }) { + const { Runtime } = client; + + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration: "", + }), + err => + err.message.includes( + "Either objectId or executionContextId must be specified" + ), + "Either objectId or executionContextId must be specified" + ); +}); + +add_task(async function executionContextIdInvalidTypes({ client }) { + const { Runtime } = client; + + for (const executionContextId of [null, true, "foo", [], {}]) { + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration: "", + executionContextId, + }), + err => err.message.includes("executionContextId: number value expected"), + "executionContextId: number value expected" + ); + } +}); + +add_task(async function executionContextIdInvalidValue({ client }) { + const { Runtime } = client; + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration: "", + executionContextId: -1, + }), + err => err.message.includes("Cannot find context with specified id"), + "Cannot find context with specified id" + ); +}); + +add_task(async function objectIdInvalidTypes({ client }) { + const { Runtime } = client; + + for (const objectId of [null, true, 1, [], {}]) { + await Assert.rejects( + Runtime.callFunctionOn({ functionDeclaration: "", objectId }), + err => err.message.includes("objectId: string value expected"), + "objectId: string value expected" + ); + } +}); + +add_task(async function objectId({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + // First create an object + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => ({ foo: 42 })", + executionContextId, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, undefined, "The subtype is undefined for objects"); + ok(!!result.objectId, "Got an object id"); + + // Then apply a method on this object + const { result: result2 } = await Runtime.callFunctionOn({ + functionDeclaration: "function () { return this.foo; }", + executionContextId, + objectId: result.objectId, + }); + + is(result2.type, "number", "The type is correct"); + is(result2.subtype, undefined, "The subtype is undefined for numbers"); + is(result2.value, 42, "Expected value returned"); +}); + +add_task(async function objectIdArgumentReference({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + // First create a remote JS object + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => ({ foo: 1 })", + executionContextId, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, undefined, "The subtype is undefined for objects"); + ok(!!result.objectId, "Got an object id"); + + // Then increment the `foo` attribute of this JS object, + // while returning this attribute value + const { result: result2 } = await Runtime.callFunctionOn({ + functionDeclaration: "arg => ++arg.foo", + arguments: [{ objectId: result.objectId }], + executionContextId, + }); + + Assert.deepEqual( + result2, + { + type: "number", + value: 2, + }, + "The result has the expected type and value" + ); + + // Finally, try to pass this JS object and get it back. Ensure that it + // returns the same object id. Also increment the attribute again. + const { result: result3 } = await Runtime.callFunctionOn({ + functionDeclaration: "arg => { arg.foo++; return arg; }", + arguments: [{ objectId: result.objectId }], + executionContextId, + }); + + is(result3.type, "object", "The type is correct"); + is(result3.subtype, undefined, "The subtype is undefined for objects"); + // Remote objects don't have unique ids. So you may have multiple object ids + // that reference the same remote object + ok(!!result3.objectId, "Got an object id"); + isnot(result3.objectId, result.objectId, "The object id is different"); + + // Assert that we can still access this object and that its foo attribute + // has been incremented. Use the second object id we got from previous call + // to callFunctionOn. + const { result: result4 } = await Runtime.callFunctionOn({ + functionDeclaration: "arg => arg.foo", + arguments: [{ objectId: result3.objectId }], + executionContextId, + }); + + Assert.deepEqual( + result4, + { + type: "number", + value: 3, + }, + "The result has the expected type and value" + ); +}); + +add_task(async function exceptionDetailsJavascriptError({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { exceptionDetails } = await Runtime.callFunctionOn({ + functionDeclaration: "doesNotExists()", + executionContextId, + }); + + Assert.deepEqual( + exceptionDetails, + { + text: "doesNotExists is not defined", + }, + "Javascript error is passed to the client" + ); +}); + +add_task(async function exceptionDetailsThrowError({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { exceptionDetails } = await Runtime.callFunctionOn({ + functionDeclaration: "() => { throw new Error('foo') }", + executionContextId, + }); + + Assert.deepEqual( + exceptionDetails, + { + text: "foo", + }, + "Exception details are passed to the client" + ); +}); + +add_task(async function exceptionDetailsThrowValue({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { exceptionDetails } = await Runtime.callFunctionOn({ + functionDeclaration: "() => { throw 'foo' }", + executionContextId, + }); + + Assert.deepEqual( + exceptionDetails, + { + exception: { + type: "string", + value: "foo", + }, + }, + "Exception details are passed as a RemoteObject" + ); +}); diff --git a/remote/cdp/test/browser/runtime/browser_callFunctionOn_awaitPromise.js b/remote/cdp/test/browser/runtime/browser_callFunctionOn_awaitPromise.js new file mode 100644 index 0000000000..a0f96d9b33 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_callFunctionOn_awaitPromise.js @@ -0,0 +1,179 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function awaitPromiseInvalidTypes({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + for (const awaitPromise of [null, 1, "foo", [], {}]) { + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration: "", + awaitPromise, + executionContextId, + }), + err => err.message.includes("awaitPromise: boolean value expected"), + "awaitPromise: boolean value expected" + ); + } +}); + +add_task(async function awaitPromiseResolve({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => Promise.resolve(42)", + awaitPromise: true, + executionContextId, + }); + + is(result.type, "number", "The type is correct"); + is(result.subtype, undefined, "The subtype is undefined for numbers"); + is(result.value, 42, "The result is the promise's resolution"); +}); + +add_task(async function awaitPromiseDelayedResolve({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => new Promise(r => setTimeout(() => r(42), 0))", + awaitPromise: true, + executionContextId, + }); + is(result.type, "number", "The type is correct"); + is(result.subtype, undefined, "The subtype is undefined for numbers"); + is(result.value, 42, "The result is the promise's resolution"); +}); + +add_task(async function awaitPromiseReject({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { exceptionDetails } = await Runtime.callFunctionOn({ + functionDeclaration: "() => Promise.reject(42)", + awaitPromise: true, + executionContextId, + }); + // TODO: Implement all values for exceptionDetails (bug 1548480) + is( + exceptionDetails.exception.value, + 42, + "The result is the promise's rejection" + ); +}); + +add_task(async function awaitPromiseDelayedReject({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { exceptionDetails } = await Runtime.callFunctionOn({ + functionDeclaration: + "() => new Promise((_,r) => setTimeout(() => r(42), 0))", + awaitPromise: true, + executionContextId, + }); + is( + exceptionDetails.exception.value, + 42, + "The result is the promise's rejection" + ); +}); + +add_task(async function awaitPromiseDelayedRejectError({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { exceptionDetails } = await Runtime.callFunctionOn({ + functionDeclaration: + "() => new Promise((_,r) => setTimeout(() => r(new Error('foo')), 0))", + awaitPromise: true, + executionContextId, + }); + + Assert.deepEqual( + exceptionDetails, + { + text: "foo", + }, + "Exception details are passed to the client" + ); +}); + +add_task(async function awaitPromiseResolveWithoutWait({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => Promise.resolve(42)", + awaitPromise: false, + executionContextId, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, "promise", "The subtype is promise"); + ok(!!result.objectId, "We got the object id for the promise"); + ok(!result.value, "We do not receive any value"); +}); + +add_task(async function awaitPromiseDelayedResolveWithoutWait({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => new Promise(r => setTimeout(() => r(42), 0))", + awaitPromise: false, + executionContextId, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, "promise", "The subtype is promise"); + ok(!!result.objectId, "We got the object id for the promise"); + ok(!result.value, "We do not receive any value"); +}); + +add_task(async function awaitPromiseRejectWithoutWait({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => Promise.reject(42)", + awaitPromise: false, + executionContextId, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, "promise", "The subtype is promise"); + ok(!!result.objectId, "We got the object id for the promise"); + ok(!result.exceptionDetails, "We do not receive any exception"); +}); + +add_task(async function awaitPromiseDelayedRejectWithoutWait({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: + "() => new Promise((_,r) => setTimeout(() => r(42), 0))", + awaitPromise: false, + executionContextId, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, "promise", "The subtype is promise"); + ok(!!result.objectId, "We got the object id for the promise"); + ok(!result.exceptionDetails, "We do not receive any exception"); +}); diff --git a/remote/cdp/test/browser/runtime/browser_callFunctionOn_returnByValue.js b/remote/cdp/test/browser/runtime/browser_callFunctionOn_returnByValue.js new file mode 100644 index 0000000000..8ad34e9a56 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_callFunctionOn_returnByValue.js @@ -0,0 +1,395 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function returnAsObjectTypes({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const expressions = [ + { expression: "({foo:true})", type: "object", subtype: undefined }, + { expression: "Symbol('foo')", type: "symbol", subtype: undefined }, + { expression: "new Promise(()=>{})", type: "object", subtype: "promise" }, + { expression: "new Int8Array(8)", type: "object", subtype: "typedarray" }, + { expression: "new WeakMap()", type: "object", subtype: "weakmap" }, + { expression: "new WeakSet()", type: "object", subtype: "weakset" }, + { expression: "new Map()", type: "object", subtype: "map" }, + { expression: "new Set()", type: "object", subtype: "set" }, + { expression: "/foo/", type: "object", subtype: "regexp" }, + { expression: "[1, 2]", type: "object", subtype: "array" }, + { expression: "new Proxy({}, {})", type: "object", subtype: "proxy" }, + { expression: "new Date()", type: "object", subtype: "date" }, + { + expression: "document", + type: "object", + subtype: "node", + className: "HTMLDocument", + description: "#document", + }, + { + expression: `{{ + const div = document.createElement('div'); + div.id = "foo"; + return div; + }}`, + type: "object", + subtype: "node", + className: "HTMLDivElement", + description: "div#foo", + }, + ]; + + for (const entry of expressions) { + const { expression, type, subtype, className, description } = entry; + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: `() => ${expression}`, + executionContextId, + }); + + is(result.type, type, "The type is correct"); + is(result.subtype, subtype, "The subtype is correct"); + ok(!!result.objectId, "Got an object id"); + if (className) { + is(result.className, className, "The className is correct"); + } + if (description) { + is(result.description, description, "The description is correct"); + } + } +}); + +add_task(async function returnAsObjectDifferentObjectIds({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const expressions = [{}, "document"]; + for (const expression of expressions) { + const { result: result1 } = await Runtime.callFunctionOn({ + functionDeclaration: `() => ${JSON.stringify(expression)}`, + executionContextId, + }); + const { result: result2 } = await Runtime.callFunctionOn({ + functionDeclaration: `() => ${JSON.stringify(expression)}`, + executionContextId, + }); + is( + result1.objectId, + result2.objectId, + `Different object ids returned for ${expression}` + ); + } +}); + +add_task(async function returnAsObjectPrimitiveTypes({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const expressions = [42, "42", true, 4.2]; + for (const expression of expressions) { + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: `() => ${JSON.stringify(expression)}`, + executionContextId, + }); + is(result.value, expression, `Evaluating primitive '${expression}' works`); + is(result.type, typeof expression, `${expression} type is correct`); + } +}); + +add_task(async function returnAsObjectNotSerializable({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const notSerializableNumbers = { + number: ["-0", "NaN", "Infinity", "-Infinity"], + bigint: ["42n"], + }; + + for (const type in notSerializableNumbers) { + for (const expression of notSerializableNumbers[type]) { + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: `() => ${expression}`, + executionContextId, + }); + Assert.deepEqual( + result, + { + type, + unserializableValue: expression, + description: expression, + }, + `Evaluating unserializable '${expression}' works` + ); + } + } +}); + +// `null` is special as it has its own subtype, is of type 'object' +// but is returned as a value, without an `objectId` attribute +add_task(async function returnAsObjectNull({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => null", + executionContextId, + }); + Assert.deepEqual( + result, + { + type: "object", + subtype: "null", + value: null, + }, + "Null type is correct" + ); +}); + +// undefined doesn't work with JSON.stringify, so test it independently +add_task(async function returnAsObjectUndefined({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => undefined", + executionContextId, + }); + Assert.deepEqual( + result, + { + type: "undefined", + }, + "Undefined type is correct" + ); +}); + +add_task(async function returnByValueInvalidTypes({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + for (const returnByValue of [null, 1, "foo", [], {}]) { + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration: "", + executionContextId, + returnByValue, + }), + err => err.message.includes("returnByValue: boolean value expected"), + "returnByValue: boolean value expected" + ); + } +}); + +add_task(async function returnByValueCyclicValue({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const functionDeclarations = [ + "() => { const b = { a: 1}; b.b = b; return b; }", + "() => window", + ]; + + for (const functionDeclaration of functionDeclarations) { + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration, + executionContextId, + returnByValue: true, + }), + err => err.message.includes("Object reference chain is too long"), + "Object reference chain is too long" + ); + } +}); + +add_task(async function returnByValueNotPossible({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const functionDeclarations = [ + "() => Symbol('foo')", + "() => [Symbol('foo')]", + "() => { return {a: Symbol('foo')}; }", + ]; + + for (const functionDeclaration of functionDeclarations) { + await Assert.rejects( + Runtime.callFunctionOn({ + functionDeclaration, + executionContextId, + returnByValue: true, + }), + err => err.message.includes("Object couldn't be returned by value"), + "Object couldn't be returned by value" + ); + } +}); + +add_task(async function returnByValue({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const values = [ + null, + 42, + 42.0, + "42", + true, + false, + { foo: true }, + { foo: { bar: 42, str: "str", array: [1, 2, 3] } }, + [42, "42", true], + [{ foo: true }], + ]; + + for (const value of values) { + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: `() => (${JSON.stringify(value)})`, + executionContextId, + returnByValue: true, + }); + + Assert.deepEqual( + result, + { + type: typeof value, + value, + description: value != null ? value.toString() : value, + }, + "The returned value is the same than the input value" + ); + } +}); + +add_task(async function returnByValueNotSerializable({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const notSerializableNumbers = { + number: ["-0", "NaN", "Infinity", "-Infinity"], + bigint: ["42n"], + }; + + for (const type in notSerializableNumbers) { + for (const unserializableValue of notSerializableNumbers[type]) { + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: `() => (${unserializableValue})`, + executionContextId, + returnByValue: true, + }); + + Assert.deepEqual( + result, + { + type, + unserializableValue, + description: unserializableValue, + }, + "The returned value is the same than the input value" + ); + } + } +}); + +// Test undefined individually as JSON.stringify doesn't return a string +add_task(async function returnByValueUndefined({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "() => {}", + executionContextId, + returnByValue: true, + }); + + Assert.deepEqual( + result, + { + type: "undefined", + }, + "Undefined type is correct" + ); +}); + +add_task(async function returnByValueArguments({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const values = [ + 42, + 42.0, + "42", + true, + false, + null, + { foo: true }, + { foo: { bar: 42, str: "str", array: [1, 2, 3] } }, + [42, "42", true], + [{ foo: true }], + ]; + + for (const value of values) { + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "a => a", + arguments: [{ value }], + executionContextId, + returnByValue: true, + }); + + Assert.deepEqual( + result, + { + type: typeof value, + value, + description: value != null ? value.toString() : value, + }, + "The returned value is the same than the input value" + ); + } +}); + +add_task(async function returnByValueArgumentsNotSerializable({ client }) { + const { Runtime } = client; + + const { id: executionContextId } = await enableRuntime(client); + + const notSerializableNumbers = { + number: ["-0", "NaN", "Infinity", "-Infinity"], + bigint: ["42n"], + }; + + for (const type in notSerializableNumbers) { + for (const unserializableValue of notSerializableNumbers[type]) { + const { result } = await Runtime.callFunctionOn({ + functionDeclaration: "a => a", + arguments: [{ unserializableValue }], + executionContextId, + returnByValue: true, + }); + + Assert.deepEqual( + result, + { + type, + unserializableValue, + description: unserializableValue, + }, + "The returned value is the same than the input value" + ); + } + } +}); diff --git a/remote/cdp/test/browser/runtime/browser_consoleAPICalled.js b/remote/cdp/test/browser/runtime/browser_consoleAPICalled.js new file mode 100644 index 0000000000..eac774bd28 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_consoleAPICalled.js @@ -0,0 +1,380 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Request a longer timeout as we have many tests which are longer +requestLongerTimeout(2); + +const PAGE_CONSOLE_EVENTS = + "https://example.com/browser/remote/cdp/test/browser/runtime/doc_console_events.html"; +const PAGE_CONSOLE_EVENTS_ONLOAD = + "https://example.com/browser/remote/cdp/test/browser/runtime/doc_console_events_onload.html"; + +add_task(async function noEventsWhenRuntimeDomainDisabled({ client }) { + await runConsoleTest(client, 0, async () => { + SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => + content.console.log("foo") + ); + }); +}); + +add_task(async function noEventsAfterRuntimeDomainDisabled({ client }) { + const { Runtime } = client; + + await Runtime.enable(); + await Runtime.disable(); + + await runConsoleTest(client, 0, async () => { + SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => + content.console.log("foo") + ); + }); +}); + +add_task(async function noEventsForJavascriptErrors({ client }) { + await loadURL(PAGE_CONSOLE_EVENTS); + const context = await enableRuntime(client); + + await runConsoleTest(client, 0, async () => { + evaluate(client, context.id, () => { + document.getElementById("js-error").click(); + }); + }); +}); + +add_task(async function consoleAPI({ client }) { + const context = await enableRuntime(client); + + const events = await runConsoleTest(client, 1, async () => { + await evaluate(client, context.id, () => { + console.log("foo"); + }); + }); + + is(events[0].type, "log", "Got expected type"); + is(events[0].args[0].value, "foo", "Got expected argument value"); + is( + events[0].executionContextId, + context.id, + "Got event from current execution context" + ); +}); + +add_task(async function consoleAPIBeforeEnable({ client }) { + const { Runtime } = client; + const timeBefore = Date.now(); + + const check = async () => { + const events = await runConsoleTest( + client, + 1, + async () => { + await Runtime.enable(); + }, + // Set custom before timestamp as the event is before our callback + { timeBefore } + ); + + is(events[0].type, "log", "Got expected type"); + is(events[0].args[0].value, "foo", "Got expected argument value"); + }; + + // Load the page which runs a log on load + await loadURL(PAGE_CONSOLE_EVENTS_ONLOAD); + await check(); + + // Disable and re-enable Runtime domain, should send event again + await Runtime.disable(); + await check(); +}); + +add_task(async function consoleAPITypes({ client }) { + const expectedEvents = ["dir", "error", "log", "timeEnd", "trace", "warning"]; + const levels = ["dir", "error", "log", "time", "timeEnd", "trace", "warn"]; + + const context = await enableRuntime(client); + + const events = await runConsoleTest( + client, + expectedEvents.length, + async () => { + for (const level of levels) { + await evaluate( + client, + context.id, + level => { + console[level]("foo"); + }, + level + ); + } + } + ); + + events.forEach((event, index) => { + console.log(`Check for event type "${expectedEvents[index]}"`); + is(event.type, expectedEvents[index], "Got expected type"); + }); +}); + +add_task(async function consoleAPIArgumentsCount({ client }) { + const argumentList = [[], ["foo"], ["foo", "bar", "cheese"]]; + + const context = await enableRuntime(client); + + const events = await runConsoleTest(client, argumentList.length, async () => { + for (const args of argumentList) { + await evaluate( + client, + context.id, + args => { + console.log(...args); + }, + args + ); + } + }); + + events.forEach((event, index) => { + console.log(`Check for event args "${argumentList[index]}"`); + + const argValues = event.args.map(arg => arg.value); + Assert.deepEqual(argValues, argumentList[index], "Got expected args"); + }); +}); + +add_task(async function consoleAPIArgumentsAsRemoteObject({ client }) { + const context = await enableRuntime(client); + + const events = await runConsoleTest(client, 1, async () => { + await evaluate(client, context.id, () => { + console.log("foo", Symbol("foo")); + }); + }); + + Assert.equal(events[0].args.length, 2, "Got expected amount of arguments"); + + is(events[0].args[0].type, "string", "Got expected argument type"); + is(events[0].args[0].value, "foo", "Got expected argument value"); + + is(events[0].args[1].type, "symbol", "Got expected argument type"); + is( + events[0].args[1].description, + "Symbol(foo)", + "Got expected argument description" + ); + ok(!!events[0].args[1].objectId, "Got objectId for argument"); +}); + +add_task(async function consoleAPIByContentInteraction({ client }) { + await loadURL(PAGE_CONSOLE_EVENTS); + const context = await enableRuntime(client); + + const events = await runConsoleTest(client, 1, async () => { + evaluate(client, context.id, () => { + document.getElementById("console-error").click(); + }); + }); + + is(events[0].type, "error", "Got expected type"); + is(events[0].args[0].value, "foo", "Got expected argument value"); + is( + events[0].executionContextId, + context.id, + "Got event from current execution context" + ); + + const { callFrames } = events[0].stackTrace; + is(callFrames.length, 1, "Got expected amount of call frames"); + + is(callFrames[0].functionName, "", "Got expected call frame function name"); + is(callFrames[0].lineNumber, 0, "Got expected call frame line number"); + is(callFrames[0].columnNumber, 8, "Got expected call frame column number"); + is( + callFrames[0].url, + "javascript:console.error('foo')", + "Got expected call frame URL" + ); +}); + +add_task(async function consoleAPIByScript({ client }) { + const context = await enableRuntime(client); + + const events = await runConsoleTest(client, 1, async () => { + await evaluate(client, context.id, function runLog() { + console.trace("foo"); + }); + }); + + const { callFrames } = events[0].stackTrace; + is(callFrames.length, 1, "Got expected amount of call frames"); + + is( + callFrames[0].functionName, + "runLog", + "Got expected call frame function name" + ); + is(callFrames[0].lineNumber, 1, "Got expected call frame line number"); + is(callFrames[0].columnNumber, 14, "Got expected call frame column number"); + is(callFrames[0].url, "debugger eval code", "Got expected call frame URL"); +}); + +add_task(async function consoleAPIByScriptSubstack({ client }) { + await loadURL(PAGE_CONSOLE_EVENTS); + const context = await enableRuntime(client); + + const events = await runConsoleTest(client, 1, async () => { + await evaluate(client, context.id, () => { + document.getElementById("log-wrapper").click(); + }); + }); + + const { callFrames } = events[0].stackTrace; + is(callFrames.length, 5, "Got expected amount of call frames"); + + is( + callFrames[0].functionName, + "runLogCaller", + "Got expected call frame function name (frame 1)" + ); + is( + callFrames[0].lineNumber, + 13, + "Got expected call frame line number (frame 1)" + ); + is( + callFrames[0].columnNumber, + 16, + "Got expected call frame column number (frame 1)" + ); + is( + callFrames[0].url, + PAGE_CONSOLE_EVENTS, + "Got expected call frame UR (frame 1)" + ); + + is( + callFrames[1].functionName, + "runLogChild", + "Got expected call frame function name (frame 2)" + ); + is( + callFrames[1].lineNumber, + 17, + "Got expected call frame line number (frame 2)" + ); + is( + callFrames[1].columnNumber, + 8, + "Got expected call frame column number (frame 2)" + ); + is( + callFrames[1].url, + PAGE_CONSOLE_EVENTS, + "Got expected call frame URL (frame 2)" + ); + + is( + callFrames[2].functionName, + "runLogParent", + "Got expected call frame function name (frame 3)" + ); + is( + callFrames[2].lineNumber, + 20, + "Got expected call frame line number (frame 3)" + ); + is( + callFrames[2].columnNumber, + 6, + "Got expected call frame column number (frame 3)" + ); + is( + callFrames[2].url, + PAGE_CONSOLE_EVENTS, + "Got expected call frame URL (frame 3)" + ); + + is( + callFrames[3].functionName, + "onclick", + "Got expected call frame function name (frame 4)" + ); + is( + callFrames[3].lineNumber, + 0, + "Got expected call frame line number (frame 4)" + ); + is( + callFrames[3].columnNumber, + 0, + "Got expected call frame column number (frame 4)" + ); + is( + callFrames[3].url, + PAGE_CONSOLE_EVENTS, + "Got expected call frame URL (frame 4)" + ); + + is( + callFrames[4].functionName, + "", + "Got expected call frame function name (frame 5)" + ); + is( + callFrames[4].lineNumber, + 1, + "Got expected call frame line number (frame 5)" + ); + is( + callFrames[4].columnNumber, + 45, + "Got expected call frame column number (frame 5)" + ); + is( + callFrames[4].url, + "debugger eval code", + "Got expected call frame URL (frame 5)" + ); +}); + +async function runConsoleTest(client, eventCount, callback, options = {}) { + let { timeBefore } = options; + + const { Runtime } = client; + + const EVENT_CONSOLE_API_CALLED = "Runtime.consoleAPICalled"; + + const history = new RecordEvents(eventCount); + history.addRecorder({ + event: Runtime.consoleAPICalled, + eventName: EVENT_CONSOLE_API_CALLED, + messageFn: payload => + `Received ${EVENT_CONSOLE_API_CALLED} for ${payload.type}`, + }); + + timeBefore ??= Date.now(); + await callback(); + + const consoleAPIentries = await history.record(); + is(consoleAPIentries.length, eventCount, "Got expected amount of events"); + + if (eventCount == 0) { + return []; + } + + const timeAfter = Date.now(); + + // Check basic details for consoleAPICalled events + consoleAPIentries.forEach(({ payload }) => { + const timestamp = payload.timestamp; + + ok( + timestamp >= timeBefore && timestamp <= timeAfter, + `Timestamp ${timestamp} in expected range [${timeBefore} - ${timeAfter}]` + ); + }); + + return consoleAPIentries.map(event => event.payload); +} diff --git a/remote/cdp/test/browser/runtime/browser_evaluate.js b/remote/cdp/test/browser/runtime/browser_evaluate.js new file mode 100644 index 0000000000..871462439e --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_evaluate.js @@ -0,0 +1,254 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_DOC = toDataURL("default-test-page"); + +add_task(async function contextIdInvalidValue({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + await Assert.rejects( + Runtime.evaluate({ expression: "", contextId: -1 }), + err => err.message.includes("Cannot find context with specified id"), + "Cannot find context with specified id" + ); +}); + +add_task(async function contextIdNotSpecified({ client }) { + const { Runtime } = client; + + await loadURL(TEST_DOC); + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ expression: "location.href" }); + is(result.value, TEST_DOC, "Works against the current document"); +}); + +add_task(async function contextIdSpecified({ client }) { + const { Runtime } = client; + + await loadURL(TEST_DOC); + const { id: contextId } = await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "location.href", + contextId, + }); + is(result.value, TEST_DOC, "Works against the targetted document"); +}); + +add_task(async function returnAsObjectTypes({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const expressions = [ + { expression: "({foo:true})", type: "object", subtype: undefined }, + { expression: "Symbol('foo')", type: "symbol", subtype: undefined }, + { expression: "new Promise(()=>{})", type: "object", subtype: "promise" }, + { expression: "new Int8Array(8)", type: "object", subtype: "typedarray" }, + { expression: "new WeakMap()", type: "object", subtype: "weakmap" }, + { expression: "new WeakSet()", type: "object", subtype: "weakset" }, + { expression: "new Map()", type: "object", subtype: "map" }, + { expression: "new Set()", type: "object", subtype: "set" }, + { expression: "/foo/", type: "object", subtype: "regexp" }, + { expression: "[1, 2]", type: "object", subtype: "array" }, + { expression: "new Proxy({}, {})", type: "object", subtype: "proxy" }, + { expression: "new Date()", type: "object", subtype: "date" }, + { + expression: "document", + type: "object", + subtype: "node", + className: "HTMLDocument", + description: "#document", + }, + { + expression: `(() => {{ + const div = document.createElement('div'); + div.id = "foo"; + return div; + }})()`, + type: "object", + subtype: "node", + className: "HTMLDivElement", + description: "div#foo", + }, + ]; + + for (const entry of expressions) { + const { expression, type, subtype, className, description } = entry; + + const { result } = await Runtime.evaluate({ expression }); + + is(result.type, type, "The type is correct"); + is(result.subtype, subtype, "The subtype is correct"); + ok(!!result.objectId, "Got an object id"); + if (className) { + is(result.className, className, "The className is correct"); + } + if (description) { + is(result.description, description, "The description is correct"); + } + } +}); + +add_task(async function returnAsObjectDifferentObjectIds({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const expressions = [{}, "document"]; + for (const expression of expressions) { + const { result: result1 } = await Runtime.evaluate({ + expression: JSON.stringify(expression), + }); + const { result: result2 } = await Runtime.evaluate({ + expression: JSON.stringify(expression), + }); + is( + result1.objectId, + result2.objectId, + `Different object ids returned for ${expression}` + ); + } +}); + +add_task(async function returnAsObjectPrimitiveTypes({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const expressions = [42, "42", true, 4.2]; + for (const expression of expressions) { + const { result } = await Runtime.evaluate({ + expression: JSON.stringify(expression), + }); + is(result.value, expression, `Evaluating primitive '${expression}' works`); + is(result.type, typeof expression, `${expression} type is correct`); + } +}); + +add_task(async function returnAsObjectNotSerializable({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const notSerializableNumbers = { + number: ["-0", "NaN", "Infinity", "-Infinity"], + bigint: ["42n"], + }; + + for (const type in notSerializableNumbers) { + for (const expression of notSerializableNumbers[type]) { + const { result } = await Runtime.evaluate({ expression }); + Assert.deepEqual( + result, + { + type, + unserializableValue: expression, + description: expression, + }, + `Evaluating unserializable '${expression}' works` + ); + } + } +}); + +// `null` is special as it has its own subtype, is of type 'object' +// but is returned as a value, without an `objectId` attribute +add_task(async function returnAsObjectNull({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "null", + }); + Assert.deepEqual( + result, + { + type: "object", + subtype: "null", + value: null, + }, + "Null type is correct" + ); +}); + +// undefined doesn't work with JSON.stringify, so test it independently +add_task(async function returnAsObjectUndefined({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "undefined", + }); + Assert.deepEqual( + result, + { + type: "undefined", + }, + "Undefined type is correct" + ); +}); + +add_task(async function exceptionDetailsJavascriptError({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { exceptionDetails } = await Runtime.evaluate({ + expression: "doesNotExists()", + }); + + Assert.deepEqual( + exceptionDetails, + { + text: "doesNotExists is not defined", + }, + "Javascript error is passed to the client" + ); +}); + +add_task(async function exceptionDetailsThrowError({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { exceptionDetails } = await Runtime.evaluate({ + expression: "throw new Error('foo')", + }); + + Assert.deepEqual( + exceptionDetails, + { + text: "foo", + }, + "Exception details are passed to the client" + ); +}); + +add_task(async function exceptionDetailsThrowValue({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { exceptionDetails } = await Runtime.evaluate({ + expression: "throw 'foo'", + }); + + Assert.deepEqual( + exceptionDetails, + { + exception: { + type: "string", + value: "foo", + }, + }, + "Exception details are passed as a RemoteObject" + ); +}); diff --git a/remote/cdp/test/browser/runtime/browser_evaluate_awaitPromise.js b/remote/cdp/test/browser/runtime/browser_evaluate_awaitPromise.js new file mode 100644 index 0000000000..2b1c87c2d3 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_evaluate_awaitPromise.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function awaitPromiseInvalidTypes({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + for (const awaitPromise of [null, 1, "foo", [], {}]) { + await Assert.rejects( + Runtime.evaluate({ + expression: "", + awaitPromise, + }), + err => err.message.includes("awaitPromise: boolean value expected"), + "awaitPromise: boolean value expected" + ); + } +}); + +add_task(async function awaitPromiseResolve({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "Promise.resolve(42)", + awaitPromise: true, + }); + + is(result.type, "number", "The type is correct"); + is(result.subtype, undefined, "The subtype is undefined for numbers"); + is(result.value, 42, "The result is the promise's resolution"); +}); + +add_task(async function awaitPromiseReject({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { exceptionDetails } = await Runtime.evaluate({ + expression: "Promise.reject(42)", + awaitPromise: true, + }); + // TODO: Implement all values for exceptionDetails (bug 1548480) + is( + exceptionDetails.exception.value, + 42, + "The result is the promise's rejection" + ); +}); + +add_task(async function awaitPromiseDelayedResolve({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "new Promise(r => setTimeout(() => r(42), 0))", + awaitPromise: true, + }); + is(result.type, "number", "The type is correct"); + is(result.subtype, undefined, "The subtype is undefined for numbers"); + is(result.value, 42, "The result is the promise's resolution"); +}); + +add_task(async function awaitPromiseDelayedReject({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { exceptionDetails } = await Runtime.evaluate({ + expression: "new Promise((_,r) => setTimeout(() => r(42), 0))", + awaitPromise: true, + }); + is( + exceptionDetails.exception.value, + 42, + "The result is the promise's rejection" + ); +}); + +add_task(async function awaitPromiseDelayedRejectError({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { exceptionDetails } = await Runtime.evaluate({ + expression: + "new Promise((_,r) => setTimeout(() => r(new Error('foo')), 0))", + awaitPromise: true, + }); + + Assert.deepEqual( + exceptionDetails, + { + text: "foo", + }, + "Exception details are passed to the client" + ); +}); + +add_task(async function awaitPromiseResolveWithoutWait({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "Promise.resolve(42)", + awaitPromise: false, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, "promise", "The subtype is promise"); + ok(!!result.objectId, "We got the object id for the promise"); + ok(!result.value, "We do not receive any value"); +}); + +add_task(async function awaitPromiseDelayedResolveWithoutWait({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "new Promise(r => setTimeout(() => r(42), 0))", + awaitPromise: false, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, "promise", "The subtype is promise"); + ok(!!result.objectId, "We got the object id for the promise"); + ok(!result.value, "We do not receive any value"); +}); + +add_task(async function awaitPromiseRejectWithoutWait({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "Promise.reject(42)", + awaitPromise: false, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, "promise", "The subtype is promise"); + ok(!!result.objectId, "We got the object id for the promise"); + ok(!result.exceptionDetails, "We do not receive any exception"); +}); + +add_task(async function awaitPromiseDelayedRejectWithoutWait({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "new Promise((_,r) => setTimeout(() => r(42), 0))", + awaitPromise: false, + }); + + is(result.type, "object", "The type is correct"); + is(result.subtype, "promise", "The subtype is promise"); + ok(!!result.objectId, "We got the object id for the promise"); + ok(!result.exceptionDetails, "We do not receive any exception"); +}); diff --git a/remote/cdp/test/browser/runtime/browser_evaluate_returnByValue.js b/remote/cdp/test/browser/runtime/browser_evaluate_returnByValue.js new file mode 100644 index 0000000000..762d280b31 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_evaluate_returnByValue.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function returnByValueInvalidTypes({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + for (const returnByValue of [null, 1, "foo", [], {}]) { + await Assert.rejects( + Runtime.evaluate({ + expression: "", + returnByValue, + }), + err => err.message.includes("returnByValue: boolean value expected"), + "returnByValue: boolean value expected" + ); + } +}); + +add_task(async function returnByValueCyclicValue({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const expressions = ["const b = { a: 1}; b.b = b; b", "window"]; + + for (const expression of expressions) { + await Assert.rejects( + Runtime.evaluate({ + expression, + returnByValue: true, + }), + err => err.message.includes("Object reference chain is too long"), + "Object reference chain is too long" + ); + } +}); + +add_task(async function returnByValueNotPossible({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const expressions = ["Symbol(42)", "[Symbol(42)]", "{a: Symbol(42)}"]; + + for (const expression of expressions) { + await Assert.rejects( + Runtime.evaluate({ + expression, + returnByValue: true, + }), + err => err.message.includes("Object couldn't be returned by value"), + "Object couldn't be returned by value" + ); + } +}); + +add_task(async function returnByValue({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const values = [ + null, + 42, + 42.0, + "42", + true, + false, + { foo: true }, + { foo: { bar: 42, str: "str", array: [1, 2, 3] } }, + [42, "42", true], + [{ foo: true }], + ]; + + for (const value of values) { + const { result } = await Runtime.evaluate({ + expression: `(${JSON.stringify(value)})`, + returnByValue: true, + }); + + Assert.deepEqual( + result, + { + type: typeof value, + value, + description: value != null ? value.toString() : value, + }, + `Returned expected value for ${JSON.stringify(value)}` + ); + } +}); + +add_task(async function returnByValueNotSerializable({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const notSerializableNumbers = { + number: ["-0", "NaN", "Infinity", "-Infinity"], + bigint: ["42n"], + }; + + for (const type in notSerializableNumbers) { + for (const unserializableValue of notSerializableNumbers[type]) { + const { result } = await Runtime.evaluate({ + expression: `(${unserializableValue})`, + returnByValue: true, + }); + + Assert.deepEqual( + result, + { + type, + unserializableValue, + description: unserializableValue, + }, + `Returned expected value for ${JSON.stringify(unserializableValue)}` + ); + } + } +}); + +// Test undefined individually as JSON.stringify doesn't return a string +add_task(async function returnByValueUndefined({ client }) { + const { Runtime } = client; + + await enableRuntime(client); + + const { result } = await Runtime.evaluate({ + expression: "undefined", + returnByValue: true, + }); + + Assert.deepEqual( + result, + { + type: "undefined", + }, + "Undefined type is correct" + ); +}); diff --git a/remote/cdp/test/browser/runtime/browser_exceptionThrown.js b/remote/cdp/test/browser/runtime/browser_exceptionThrown.js new file mode 100644 index 0000000000..23e6dc2de9 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_exceptionThrown.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_CONSOLE_EVENTS = + "https://example.com/browser/remote/cdp/test/browser/runtime/doc_console_events.html"; + +add_task(async function noEventsWhenRuntimeDomainDisabled({ client }) { + await runExceptionThrownTest(client, 0, async () => { + await throwScriptError({ text: "foo" }); + }); +}); + +add_task(async function noEventsAfterRuntimeDomainDisabled({ client }) { + const { Runtime } = client; + + await Runtime.enable(); + await Runtime.disable(); + + await runExceptionThrownTest(client, 0, async () => { + await throwScriptError({ text: "foo" }); + }); +}); + +add_task(async function noEventsForScriptErrorWithoutException({ client }) { + const { Runtime } = client; + + await Runtime.enable(); + + await runExceptionThrownTest(client, 0, async () => { + await throwScriptError({ text: "foo" }); + }); +}); + +add_task(async function eventsForScriptErrorWithException({ client }) { + await loadURL(PAGE_CONSOLE_EVENTS); + + const context = await enableRuntime(client); + + const events = await runExceptionThrownTest(client, 1, async () => { + evaluate(client, context.id, () => { + document.getElementById("js-error").click(); + }); + }); + + is( + typeof events[0].exceptionId, + "number", + "Got expected type for exception id" + ); + is( + events[0].text, + "TypeError: foo.click is not a function", + "Got expected text" + ); + is(events[0].lineNumber, 8, "Got expected line number"); + is(events[0].columnNumber, 10, "Got expected column number"); + is(events[0].url, PAGE_CONSOLE_EVENTS, "Got expected url"); + is( + events[0].executionContextId, + context.id, + "Got event from current execution context" + ); + + const callFrames = events[0].stackTrace.callFrames; + is(callFrames.length, 2, "Got expected amount of call frames"); + + is(callFrames[0].functionName, "throwError", "Got expected function name"); + is(typeof callFrames[0].scriptId, "string", "Got scriptId as string"); + is(callFrames[0].url, PAGE_CONSOLE_EVENTS, "Got expected url"); + is(callFrames[0].lineNumber, 8, "Got expected line number"); + is(callFrames[0].columnNumber, 10, "Got expected column number"); + + is(callFrames[1].functionName, "onclick", "Got expected function name"); + is(callFrames[1].url, PAGE_CONSOLE_EVENTS, "Got expected url"); +}); + +async function runExceptionThrownTest( + client, + eventCount, + callback, + options = {} +) { + const { Runtime } = client; + + const EVENT_EXCEPTION_THROWN = "Runtime.exceptionThrown"; + + const history = new RecordEvents(eventCount); + history.addRecorder({ + event: Runtime.exceptionThrown, + eventName: EVENT_EXCEPTION_THROWN, + messageFn: payload => `Received "${payload.name}"`, + }); + + const timeBefore = Date.now(); + await callback(); + + const exceptionThrownEvents = await history.record(); + is(exceptionThrownEvents.length, eventCount, "Got expected amount of events"); + + if (eventCount == 0) { + return []; + } + + const timeAfter = Date.now(); + + // Check basic details for entryAdded events + exceptionThrownEvents.forEach(event => { + const details = event.payload.exceptionDetails; + const timestamp = event.payload.timestamp; + + is(typeof details, "object", "Got expected 'exceptionDetails' property"); + ok( + timestamp >= timeBefore && timestamp <= timeAfter, + `Timestamp ${timestamp} in expected range [${timeBefore} - ${timeAfter}]` + ); + }); + + return exceptionThrownEvents.map(event => event.payload.exceptionDetails); +} diff --git a/remote/cdp/test/browser/runtime/browser_executionContextEvents.js b/remote/cdp/test/browser/runtime/browser_executionContextEvents.js new file mode 100644 index 0000000000..9e6230901a --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_executionContextEvents.js @@ -0,0 +1,332 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the Runtime execution context events + +const DESTROYED = "Runtime.executionContextDestroyed"; +const CREATED = "Runtime.executionContextCreated"; +const CLEARED = "Runtime.executionContextsCleared"; + +add_task(async function noEventsWhenRuntimeDomainDisabled({ client }) { + const { Runtime } = client; + + const history = recordContextEvents(Runtime, 0); + await loadURL(PAGE_FRAME_URL); + await assertEventOrder({ history, expectedEvents: [] }); +}); + +add_task(async function noEventsAfterRuntimeDomainDisabled({ client }) { + const { Runtime } = client; + + await Runtime.enable(); + await Runtime.disable(); + + const history = recordContextEvents(Runtime, 0); + await loadURL(PAGE_FRAME_URL); + await assertEventOrder({ history, expectedEvents: [] }); +}); + +add_task(async function eventsWhenNavigatingWithNoFrames({ client }) { + const { Page, Runtime } = client; + + const previousContext = await enableRuntime(client); + const history = recordContextEvents(Runtime, 3); + + const { frameId } = await Page.navigate({ url: PAGE_FRAME_URL }); + await assertEventOrder({ history }); + + const { executionContextId: destroyedId } = + history.findEvent(DESTROYED).payload; + is( + destroyedId, + previousContext.id, + "The destroyed event reports the previous context id" + ); + + const { context: contextCreated } = history.findEvent(CREATED).payload; + checkDefaultContext(contextCreated); + isnot( + contextCreated.id, + previousContext.id, + "The new execution context has a different id" + ); + is( + contextCreated.auxData.frameId, + frameId, + "The execution context frame id is the same " + + "than the one returned by Page.navigate" + ); +}); + +add_task(async function eventsWhenNavigatingFrameSet({ client }) { + const { Runtime } = client; + + const previousContext = await enableRuntime(client); + + // Check navigation to a frameset + const historyTo = recordContextEvents(Runtime, 4); + await loadURL(FRAMESET_SINGLE_URL); + await assertEventOrder({ + history: historyTo, + expectedEvents: [DESTROYED, CLEARED, CREATED, CREATED], + }); + + const { executionContextId: destroyedId } = + historyTo.findEvent(DESTROYED).payload; + is( + destroyedId, + previousContext.id, + "The destroyed event reports the previous context id" + ); + + const contexts = historyTo.findEvents(CREATED); + const createdTopContext = contexts[0].payload.context; + const createdFrameContext = contexts[1].payload.context; + + checkDefaultContext(createdTopContext); + isnot( + createdTopContext.id, + previousContext.id, + "The new execution context has a different id" + ); + is( + createdTopContext.origin, + BASE_ORIGIN, + "The execution context origin is the frameset" + ); + + checkDefaultContext(createdFrameContext); + isnot( + createdFrameContext.id, + createdTopContext.id, + "The new frame's execution context has a different id" + ); + is( + createdFrameContext.origin, + BASE_ORIGIN, + "The frame's execution context origin is the frame" + ); + + // Check navigation from a frameset + const historyFrom = recordContextEvents(Runtime, 4); + await loadURL(PAGE_FRAME_URL); + await assertEventOrder({ + history: historyFrom, + // Bug 1644657: The cleared event should come last but we emit destroy events + // for the top-level context and for frames afterward. Chrome only sends out + // the cleared event on navigation. + expectedEvents: [DESTROYED, CLEARED, CREATED, DESTROYED], + }); + + const destroyedContextIds = historyFrom.findEvents(DESTROYED); + is( + destroyedContextIds[0].payload.executionContextId, + createdTopContext.id, + "The destroyed event reports the previous context id" + ); + is( + destroyedContextIds[1].payload.executionContextId, + createdFrameContext.id, + "The destroyed event reports the previous frame's context id" + ); + + const { context: contextCreated } = historyFrom.findEvent(CREATED).payload; + checkDefaultContext(contextCreated); + isnot( + contextCreated.id, + createdTopContext.id, + "The new execution context has a different id" + ); + is( + contextCreated.origin, + BASE_ORIGIN, + "The execution context origin is not the frameset" + ); +}); + +add_task(async function eventsWhenNavigatingBackWithNoFrames({ client }) { + const { Runtime } = client; + + // Load an initial URL so that navigating back will work + await loadURL(PAGE_FRAME_URL); + const previousContext = await enableRuntime(client); + + const executionContextCreated = Runtime.executionContextCreated(); + await loadURL(PAGE_URL); + const { context: createdContext } = await executionContextCreated; + + const history = recordContextEvents(Runtime, 3); + gBrowser.selectedBrowser.goBack(); + await assertEventOrder({ history }); + + const { executionContextId: destroyedId } = + history.findEvent(DESTROYED).payload; + is( + destroyedId, + createdContext.id, + "The destroyed event reports the current context id" + ); + + const { context } = history.findEvent(CREATED).payload; + checkDefaultContext(context); + is( + context.origin, + previousContext.origin, + "The new execution context has the same origin as the previous one." + ); + isnot( + context.id, + previousContext.id, + "The new execution context has a different id" + ); + ok(context.auxData.isDefault, "The execution context is the default one"); + is( + context.auxData.frameId, + previousContext.auxData.frameId, + "The execution context frame id is always the same" + ); + is(context.auxData.type, "default", "Execution context has 'default' type"); + is(context.name, "", "The default execution context is named ''"); + + const { result } = await Runtime.evaluate({ + contextId: context.id, + expression: "location.href", + }); + is( + result.value, + PAGE_FRAME_URL, + "Runtime.evaluate works and is against the page we just navigated to" + ); +}); + +add_task(async function eventsWhenReloadingPageWithNoFrames({ client }) { + const { Page, Runtime } = client; + + // Load an initial URL so that reload will work + await loadURL(PAGE_FRAME_URL); + const previousContext = await enableRuntime(client); + + await Page.enable(); + + const history = recordContextEvents(Runtime, 3); + const frameNavigated = Page.frameNavigated(); + gBrowser.selectedBrowser.reload(); + await frameNavigated; + + await assertEventOrder({ history }); + + const { executionContextId } = history.findEvent(DESTROYED).payload; + is( + executionContextId, + previousContext.id, + "The destroyed event reports the previous context id" + ); + + const { context } = history.findEvent(CREATED).payload; + checkDefaultContext(context); + is( + context.auxData.frameId, + previousContext.auxData.frameId, + "The execution context frame id is the same as before reloading" + ); + + isnot( + executionContextId, + context.id, + "The destroyed id is different from the created one" + ); +}); + +add_task(async function eventsWhenNavigatingByLocationWithNoFrames({ client }) { + const { Runtime } = client; + + const previousContext = await enableRuntime(client); + const history = recordContextEvents(Runtime, 3); + + await Runtime.evaluate({ + contextId: previousContext.id, + expression: `window.location = '${PAGE_FRAME_URL}';`, + }); + await assertEventOrder({ history }); + + const { executionContextId: destroyedId } = + history.findEvent(DESTROYED).payload; + is( + destroyedId, + previousContext.id, + "The destroyed event reports the previous context id" + ); + + const { context: createdContext } = history.findEvent(CREATED).payload; + checkDefaultContext(createdContext); + is( + createdContext.auxData.frameId, + previousContext.auxData.frameId, + "The execution context frame id is identical " + + "to the one from before before setting the window's location" + ); + isnot( + destroyedId, + createdContext.id, + "The destroyed id is different from the created one" + ); +}); + +function recordContextEvents(Runtime, total) { + const history = new RecordEvents(total); + + history.addRecorder({ + event: Runtime.executionContextDestroyed, + eventName: DESTROYED, + messageFn: payload => { + return `Received ${DESTROYED} for id ${payload.executionContextId}`; + }, + }); + history.addRecorder({ + event: Runtime.executionContextCreated, + eventName: CREATED, + messageFn: ({ context }) => { + return ( + `Received ${CREATED} for id ${context.id}` + + ` type: ${context.auxData.type}` + + ` name: ${context.name}` + + ` origin: ${context.origin}` + ); + }, + }); + history.addRecorder({ + event: Runtime.executionContextsCleared, + eventName: CLEARED, + }); + + return history; +} + +async function assertEventOrder(options = {}) { + const { history, expectedEvents = [DESTROYED, CLEARED, CREATED] } = options; + const events = await history.record(); + const eventNames = events.map(item => item.eventName); + info(`Expected events: ${expectedEvents}`); + info(`Received events: ${eventNames}`); + + is( + events.length, + expectedEvents.length, + "Received expected number of Runtime context events" + ); + Assert.deepEqual( + events.map(item => item.eventName), + expectedEvents, + "Received Runtime context events in expected order" + ); +} + +function checkDefaultContext(context) { + ok(!!context.id, "The execution context has an id"); + ok(context.auxData.isDefault, "The execution context is the default one"); + is(context.auxData.type, "default", "Execution context has 'default' type"); + ok(!!context.origin, "The execution context has an origin"); + is(context.name, "", "The default execution context is named ''"); +} diff --git a/remote/cdp/test/browser/runtime/browser_getProperties.js b/remote/cdp/test/browser/runtime/browser_getProperties.js new file mode 100644 index 0000000000..f897c3dfcd --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_getProperties.js @@ -0,0 +1,184 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the Runtime remote object + +add_task(async function ({ client }) { + const firstContext = await testRuntimeEnable(client); + const contextId = firstContext.id; + + await testGetOwnSimpleProperties(client, contextId); + await testGetCustomProperty(client, contextId); + await testGetPrototypeProperties(client, contextId); + await testGetGetterSetterProperties(client, contextId); +}); + +async function testRuntimeEnable({ Runtime }) { + // Enable watching for new execution context + await Runtime.enable(); + info("Runtime domain has been enabled"); + + // Calling Runtime.enable will emit executionContextCreated for the existing contexts + const { context } = await Runtime.executionContextCreated(); + ok(!!context.id, "The execution context has an id"); + ok(context.auxData.isDefault, "The execution context is the default one"); + ok(!!context.auxData.frameId, "The execution context has a frame id set"); + + return context; +} + +async function testGetOwnSimpleProperties({ Runtime }, contextId) { + const { result } = await Runtime.evaluate({ + contextId, + expression: "({ bool: true, fun() {}, int: 1, object: {}, string: 'foo' })", + }); + is(result.subtype, undefined, "JS Object has no subtype"); + is(result.type, "object", "The type is correct"); + ok(!!result.objectId, "Got an object id"); + + const { result: result2 } = await Runtime.getProperties({ + objectId: result.objectId, + ownProperties: true, + }); + is( + result2.length, + 5, + "ownProperties=true allows to iterate only over direct object properties (i.e. ignore prototype)" + ); + result2.sort((a, b) => a.name > b.name); + is(result2[0].name, "bool"); + is(result2[0].configurable, true); + is(result2[0].enumerable, true); + is(result2[0].writable, true); + is(result2[0].value.type, "boolean"); + is(result2[0].value.value, true); + is(result2[0].isOwn, true); + + is(result2[1].name, "fun"); + is(result2[1].configurable, true); + is(result2[1].enumerable, true); + is(result2[1].writable, true); + is(result2[1].value.type, "function"); + ok(!!result2[1].value.objectId); + is(result2[1].isOwn, true); + + is(result2[2].name, "int"); + is(result2[2].configurable, true); + is(result2[2].enumerable, true); + is(result2[2].writable, true); + is(result2[2].value.type, "number"); + is(result2[2].value.value, 1); + is(result2[2].isOwn, true); + + is(result2[3].name, "object"); + is(result2[3].configurable, true); + is(result2[3].enumerable, true); + is(result2[3].writable, true); + is(result2[3].value.type, "object"); + ok(!!result2[3].value.objectId); + is(result2[3].isOwn, true); + + is(result2[4].name, "string"); + is(result2[4].configurable, true); + is(result2[4].enumerable, true); + is(result2[4].writable, true); + is(result2[4].value.type, "string"); + is(result2[4].value.value, "foo"); + is(result2[4].isOwn, true); +} + +async function testGetPrototypeProperties({ Runtime }, contextId) { + const { result } = await Runtime.evaluate({ + contextId, + expression: "({ foo: 42 })", + }); + is(result.subtype, undefined, "JS Object has no subtype"); + is(result.type, "object", "The type is correct"); + ok(!!result.objectId, "Got an object id"); + + const { result: result2 } = await Runtime.getProperties({ + objectId: result.objectId, + ownProperties: false, + }); + ok(result2.length > 1, "We have more properties than just the object one"); + const foo = result2.find(p => p.name == "foo"); + ok(foo, "The object property is described"); + ok(foo.isOwn, "and is reported as 'own' property"); + + const toString = result2.find(p => p.name == "toString"); + ok( + toString, + "Function from Object's prototype are also described like toString" + ); + ok(!toString.isOwn, "but are reported as not being an 'own' property"); +} + +async function testGetGetterSetterProperties({ Runtime }, contextId) { + const { result } = await Runtime.evaluate({ + contextId, + expression: + "({ get prop() { return this.x; }, set prop(v) { this.x = v; } })", + }); + is(result.subtype, undefined, "JS Object has no subtype"); + is(result.type, "object", "The type is correct"); + ok(!!result.objectId, "Got an object id"); + + const { result: result2 } = await Runtime.getProperties({ + objectId: result.objectId, + ownProperties: true, + }); + is(result2.length, 1); + + is(result2[0].name, "prop"); + is(result2[0].configurable, true); + is(result2[0].enumerable, true); + is( + result2[0].writable, + undefined, + "writable is only set for data properties" + ); + + is(result2[0].get.type, "function"); + ok(!!result2[0].get.objectId); + is(result2[0].set.type, "function"); + ok(!!result2[0].set.objectId); + + is(result2[0].isOwn, true); + + const { result: result3 } = await Runtime.callFunctionOn({ + executionContextId: contextId, + functionDeclaration: "(set, get) => { set(42); return get(); }", + arguments: [ + { objectId: result2[0].set.objectId }, + { objectId: result2[0].get.objectId }, + ], + }); + is(result3.type, "number", "The type is correct"); + is(result3.subtype, undefined, "The subtype is undefined for numbers"); + is(result3.value, 42, "The getter returned the value set by the setter"); +} + +async function testGetCustomProperty({ Runtime }, contextId) { + const { result } = await Runtime.evaluate({ + contextId, + expression: `const obj = {}; Object.defineProperty(obj, "prop", { value: 42 }); obj`, + }); + is(result.subtype, undefined, "JS Object has no subtype"); + is(result.type, "object", "The type is correct"); + ok(!!result.objectId, "Got an object id"); + + const { result: result2 } = await Runtime.getProperties({ + objectId: result.objectId, + ownProperties: true, + }); + is(result2.length, 1, "We only get the one object's property"); + is(result2[0].name, "prop"); + is(result2[0].configurable, false); + is(result2[0].enumerable, false); + is(result2[0].writable, false); + is(result2[0].value.type, "number"); + is(result2[0].value.value, 42); + is(result2[0].isOwn, true); +} diff --git a/remote/cdp/test/browser/runtime/browser_remoteObjects.js b/remote/cdp/test/browser/runtime/browser_remoteObjects.js new file mode 100644 index 0000000000..bfe412e966 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_remoteObjects.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the Runtime remote object + +add_task(async function ({ client }) { + const firstContext = await testRuntimeEnable(client); + const contextId = firstContext.id; + + await testObjectRelease(client, contextId); +}); + +async function testRuntimeEnable({ Runtime }) { + // Enable watching for new execution context + await Runtime.enable(); + info("Runtime domain has been enabled"); + + // Calling Runtime.enable will emit executionContextCreated for the existing contexts + const { context } = await Runtime.executionContextCreated(); + ok(!!context.id, "The execution context has an id"); + ok(context.auxData.isDefault, "The execution context is the default one"); + ok(!!context.auxData.frameId, "The execution context has a frame id set"); + + return context; +} + +async function testObjectRelease({ Runtime }, contextId) { + const { result } = await Runtime.evaluate({ + contextId, + expression: "({ foo: 42 })", + }); + is(result.subtype, undefined, "JS Object has no subtype"); + is(result.type, "object", "The type is correct"); + ok(!!result.objectId, "Got an object id"); + + const { result: result2 } = await Runtime.callFunctionOn({ + executionContextId: contextId, + functionDeclaration: "obj => JSON.stringify(obj)", + arguments: [{ objectId: result.objectId }], + }); + is(result2.type, "string", "The type is correct"); + is(result2.value, JSON.stringify({ foo: 42 }), "Got the object's JSON"); + + const { result: result3 } = await Runtime.callFunctionOn({ + objectId: result.objectId, + functionDeclaration: "function () { return this.foo; }", + }); + is(result3.type, "number", "The type is correct"); + is(result3.value, 42, "Got the object's foo attribute"); + + await Runtime.releaseObject({ + objectId: result.objectId, + }); + info("Object is released"); + + await Assert.rejects( + Runtime.callFunctionOn({ + executionContextId: contextId, + functionDeclaration: "() => {}", + arguments: [{ objectId: result.objectId }], + }), + err => err.message.includes("Could not find object with given id"), + "callFunctionOn throws on released argument" + ); + + await Assert.rejects( + Runtime.callFunctionOn({ + objectId: result.objectId, + functionDeclaration: "() => {}", + }), + err => err.message.includes("Cannot find context with specified id"), + "callFunctionOn throws on released target" + ); +} diff --git a/remote/cdp/test/browser/runtime/browser_withDefaultPrefs.js b/remote/cdp/test/browser/runtime/browser_withDefaultPrefs.js new file mode 100644 index 0000000000..20b8264f92 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_withDefaultPrefs.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function enableRuntime_noHangAfterNavigation({ client }) { + await loadURL(PAGE_URL); + await enableRuntime(client); +}); diff --git a/remote/cdp/test/browser/runtime/browser_with_default_prefs.toml b/remote/cdp/test/browser/runtime/browser_with_default_prefs.toml new file mode 100644 index 0000000000..cfb0a77352 --- /dev/null +++ b/remote/cdp/test/browser/runtime/browser_with_default_prefs.toml @@ -0,0 +1,15 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "doc_empty.html", + "head.js", +] + +["browser_withDefaultPrefs.js"] diff --git a/remote/cdp/test/browser/runtime/doc_console_events.html b/remote/cdp/test/browser/runtime/doc_console_events.html new file mode 100644 index 0000000000..f52fcfa555 --- /dev/null +++ b/remote/cdp/test/browser/runtime/doc_console_events.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Empty page</title> + <script> + function throwError() { + let foo = {}; + foo.click(); + } + + function runLogParent() { + function runLogCaller() { + console.trace("foo"); + } + + function runLogChild() { + runLogCaller(); + } + + runLogChild(); + } + </script> +</head> +<body> + <a id="console-log" href="javascript:console.log('foo')">console.log()</a><br/> + <a id="console-error" href="javascript:console.error('foo')">console.error()</a><br/> + <a id="js-error" onclick="throwError()">Javascript Error</a><br/> + <a id="log-wrapper" onclick="runLogParent()">console.log() in function wrappers</a><br/> +</body> +</html> diff --git a/remote/cdp/test/browser/runtime/doc_console_events_onload.html b/remote/cdp/test/browser/runtime/doc_console_events_onload.html new file mode 100644 index 0000000000..608a22991a --- /dev/null +++ b/remote/cdp/test/browser/runtime/doc_console_events_onload.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Console events onload</title> + <script> + console.log("foo"); + </script> +</head> +<body> +</body> +</html> diff --git a/remote/cdp/test/browser/runtime/doc_empty.html b/remote/cdp/test/browser/runtime/doc_empty.html new file mode 100644 index 0000000000..e59d2d8901 --- /dev/null +++ b/remote/cdp/test/browser/runtime/doc_empty.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Empty page</title> +</head> +<body> +</body> +</html> diff --git a/remote/cdp/test/browser/runtime/doc_frame.html b/remote/cdp/test/browser/runtime/doc_frame.html new file mode 100644 index 0000000000..e2efd61554 --- /dev/null +++ b/remote/cdp/test/browser/runtime/doc_frame.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Frame page</title> +</head> +<body> +</body> +</html> diff --git a/remote/cdp/test/browser/runtime/doc_frameset_single.html b/remote/cdp/test/browser/runtime/doc_frameset_single.html new file mode 100644 index 0000000000..2ad56a140e --- /dev/null +++ b/remote/cdp/test/browser/runtime/doc_frameset_single.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Frameset with a single frame</title> +</head> +<body> + <iframe src="doc_frame.html"></iframe> +</body> +</html> diff --git a/remote/cdp/test/browser/runtime/head.js b/remote/cdp/test/browser/runtime/head.js new file mode 100644 index 0000000000..8f05f225ec --- /dev/null +++ b/remote/cdp/test/browser/runtime/head.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); + +const BASE_ORIGIN = "https://example.com"; +const BASE_PATH = `${BASE_ORIGIN}/browser/remote/cdp/test/browser/runtime`; +const FRAMESET_SINGLE_URL = `${BASE_PATH}/doc_frameset_single.html`; +const PAGE_FRAME_URL = `${BASE_PATH}/doc_frame.html`; +const PAGE_URL = `${BASE_PATH}/doc_empty.html`; diff --git a/remote/cdp/test/browser/security/browser.toml b/remote/cdp/test/browser/security/browser.toml new file mode 100644 index 0000000000..d57fe6a61d --- /dev/null +++ b/remote/cdp/test/browser/security/browser.toml @@ -0,0 +1,21 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", +] + +["browser_setIgnoreCertificateErrors.js"] diff --git a/remote/cdp/test/browser/security/browser_setIgnoreCertificateErrors.js b/remote/cdp/test/browser/security/browser_setIgnoreCertificateErrors.js new file mode 100644 index 0000000000..d21e737ebe --- /dev/null +++ b/remote/cdp/test/browser/security/browser_setIgnoreCertificateErrors.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { STATE_IS_SECURE, STATE_IS_BROKEN, STATE_IS_INSECURE } = + Ci.nsIWebProgressListener; + +// from ../../../build/pgo/server-locations.txt +const NO_CERT = "https://nocert.example.com:443"; +const SELF_SIGNED = "https://self-signed.example.com:443"; +const UNTRUSTED = "https://untrusted.example.com:443"; +const EXPIRED = "https://expired.example.com:443"; +const MISMATCH_EXPIRED = "https://mismatch.expired.example.com:443"; +const MISMATCH_UNTRUSTED = "https://mismatch.untrusted.example.com:443"; +const UNTRUSTED_EXPIRED = "https://untrusted-expired.example.com:443"; +const MISMATCH_UNTRUSTED_EXPIRED = + "https://mismatch.untrusted-expired.example.com:443"; + +const BAD_CERTS = [ + NO_CERT, + SELF_SIGNED, + UNTRUSTED, + EXPIRED, + MISMATCH_EXPIRED, + MISMATCH_UNTRUSTED, + UNTRUSTED_EXPIRED, + MISMATCH_UNTRUSTED_EXPIRED, +]; + +function getConnectionState() { + // prevents items that are being lazy loaded causing issues + document.getElementById("identity-icon-box").click(); + gIdentityHandler.refreshIdentityPopup(); + return document.getElementById("identity-popup").getAttribute("connection"); +} + +/** + * Compares the security state of the page with what is expected. + * Returns one of "secure", "broken", "insecure", or "unknown". + */ +function isSecurityState(browser, expectedState) { + const ui = browser.securityUI; + if (!ui) { + ok(false, "No security UI to get the security state"); + return; + } + + const isSecure = ui.state & STATE_IS_SECURE; + const isBroken = ui.state & STATE_IS_BROKEN; + const isInsecure = ui.state & STATE_IS_INSECURE; + + let actualState; + if (isSecure && !(isBroken || isInsecure)) { + actualState = "secure"; + } else if (isBroken && !(isSecure || isInsecure)) { + actualState = "broken"; + } else if (isInsecure && !(isSecure || isBroken)) { + actualState = "insecure"; + } else { + actualState = "unknown"; + } + + is( + expectedState, + actualState, + `Expected state is ${expectedState} and actual state is ${actualState}` + ); +} + +add_task(async function testDefault({ Security }) { + for (const url of BAD_CERTS) { + info(`Navigating to ${url}`); + const loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); + await loaded; + + is( + getConnectionState(), + "cert-error-page", + "Security error page is present" + ); + isSecurityState(gBrowser, "insecure"); + } +}); + +add_task(async function testIgnore({ client }) { + const { Security } = client; + info("Enable security certificate override"); + await Security.setIgnoreCertificateErrors({ ignore: true }); + + for (const url of BAD_CERTS) { + info(`Navigating to ${url}`); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + is( + getConnectionState(), + "secure-cert-user-overridden", + "Security certificate was overridden by user" + ); + isSecurityState(gBrowser, "secure"); + } +}); + +add_task(async function testUnignore({ client }) { + const { Security } = client; + info("Disable security certificate override"); + await Security.setIgnoreCertificateErrors({ ignore: false }); + + for (const url of BAD_CERTS) { + info(`Navigating to ${url}`); + const loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); + await loaded; + + is( + getConnectionState(), + "cert-error-page", + "Security error page is present" + ); + isSecurityState(gBrowser, "insecure"); + } +}); + +// smoke test for unignored -> ignored -> unignored +add_task(async function testToggle({ client }) { + const { Security } = client; + let loaded; + + info("Enable security certificate override"); + await Security.setIgnoreCertificateErrors({ ignore: true }); + + info(`Navigating to ${UNTRUSTED} having set the override`); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, UNTRUSTED); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + is( + getConnectionState(), + "secure-cert-user-overridden", + "Security certificate was overridden by user" + ); + isSecurityState(gBrowser, "secure"); + + info("Disable security certificate override"); + await Security.setIgnoreCertificateErrors({ ignore: false }); + + info(`Navigating to ${UNTRUSTED} having unset the override`); + loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, UNTRUSTED); + await loaded; + + is( + getConnectionState(), + "cert-error-page", + "Security error page is present by default" + ); + isSecurityState(gBrowser, "insecure"); +}); diff --git a/remote/cdp/test/browser/security/head.js b/remote/cdp/test/browser/security/head.js new file mode 100644 index 0000000000..1a1c90fbf6 --- /dev/null +++ b/remote/cdp/test/browser/security/head.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); diff --git a/remote/cdp/test/browser/systemInfo/browser.toml b/remote/cdp/test/browser/systemInfo/browser.toml new file mode 100644 index 0000000000..8d814c91ee --- /dev/null +++ b/remote/cdp/test/browser/systemInfo/browser.toml @@ -0,0 +1,21 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", +] + +["browser_getProcessInfo.js"] diff --git a/remote/cdp/test/browser/systemInfo/browser_getProcessInfo.js b/remote/cdp/test/browser/systemInfo/browser_getProcessInfo.js new file mode 100644 index 0000000000..fb491e248f --- /dev/null +++ b/remote/cdp/test/browser/systemInfo/browser_getProcessInfo.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task( + async function getProcessInfoDetails({ client }) { + const { SystemInfo } = client; + + const processInfo = await SystemInfo.getProcessInfo(); + assertProcesses(processInfo); + }, + { createTab: false } +); + +add_task( + async function getProcessInfoMultipleTabs({ client }) { + const { SystemInfo, Target } = client; + + const { newTab: newTab1 } = await openTab(Target); + const { newTab: newTab2 } = await openTab(Target); + const { newTab: newTab3 } = await openTab(Target); + const { newTab: newTab4 } = await openTab(Target); + + const processInfo = await SystemInfo.getProcessInfo(); + assertProcesses(processInfo, [newTab1, newTab2, newTab3, newTab4]); + }, + { createTab: false } +); + +add_task( + async function getProcessInfoMultipleWindows({ client }) { + const { SystemInfo, Target } = client; + + const { newWindow: newWindow1 } = await openWindow(Target); + const { newWindow: newWindow2 } = await openWindow(Target); + + const processInfo = await SystemInfo.getProcessInfo(); + assertProcesses(processInfo, [ + ...newWindow1.gBrowser.tabs, + ...newWindow2.gBrowser.tabs, + ]); + + await BrowserTestUtils.closeWindow(newWindow1); + await BrowserTestUtils.closeWindow(newWindow2); + }, + { createTab: false } +); + +function assertProcesses(processInfo, tabs) { + ok(Array.isArray(processInfo), "Process info is an array"); + + for (const info of processInfo) { + ok(typeof info.id === "number", "Info has a numeric id"); + ok(typeof info.type === "string", "Info has a string type"); + ok(typeof info.cpuTime === "number", "Info has a numeric cpuTime"); + } + + const getByType = type => processInfo.filter(info => info.type === type); + + is( + getByType("browser").length, + 1, + "Got expected amount of browser processes" + ); + ok(!!getByType("renderer").length, "Got at least one renderer process"); + + if (tabs) { + const rendererPids = new Set( + processInfo.filter(info => info.type === "renderer").map(info => info.id) + ); + + for (const tab of tabs) { + const pid = tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid; + ok(rendererPids.has(pid), `Found process info for pid (${pid})`); + } + } +} diff --git a/remote/cdp/test/browser/systemInfo/head.js b/remote/cdp/test/browser/systemInfo/head.js new file mode 100644 index 0000000000..1a1c90fbf6 --- /dev/null +++ b/remote/cdp/test/browser/systemInfo/head.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); diff --git a/remote/cdp/test/browser/target/browser.toml b/remote/cdp/test/browser/target/browser.toml new file mode 100644 index 0000000000..2fe20cacf1 --- /dev/null +++ b/remote/cdp/test/browser/target/browser.toml @@ -0,0 +1,44 @@ +[DEFAULT] +tags = "cdp" +subsuite = "remote" +args = [ + "--remote-debugging-port", + "--remote-allow-origins=null", +] +prefs = [ # Bug 1600054: Make CDP Fission compatible + "fission.bfcacheInParent=false", + "fission.webContentIsolationStrategy=0", +] +skip-if = [ + "display == 'wayland'" # Bug 1861933: Timestamp unreliable due to worker setup +] +support-files = [ + "!/remote/cdp/test/browser/chrome-remote-interface.js", + "!/remote/cdp/test/browser/head.js", + "head.js", + "doc_test.html", +] + +["browser_activateTarget.js"] + +["browser_attachToTarget.js"] + +["browser_attachedToTarget.js"] +https_first_disabled = true + +["browser_browserContext.js"] + +["browser_closeTarget.js"] + +["browser_createTarget.js"] + +["browser_getTargets.js"] +https_first_disabled = true + +["browser_sendMessageToTarget.js"] + +["browser_setDiscoverTargets.js"] + +["browser_targetCreated.js"] + +["browser_targetDestroyed.js"] diff --git a/remote/cdp/test/browser/target/browser_activateTarget.js b/remote/cdp/test/browser/target/browser_activateTarget.js new file mode 100644 index 0000000000..03c5a96e07 --- /dev/null +++ b/remote/cdp/test/browser/target/browser_activateTarget.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function raisesWithoutArguments({ client, tab }) { + const { Target } = client; + + await Assert.rejects( + Target.activateTarget(), + err => err.message.includes(`Unable to find target with id`), + "activateTarget raised error without an argument" + ); +}); + +add_task(async function raisesWithUnknownTargetId({ client, tab }) { + const { Target } = client; + + await Assert.rejects( + Target.activateTarget({ targetId: "-1" }), + err => err.message.includes(`Unable to find target with id`), + "activateTarget raised error with unkown target id" + ); +}); + +add_task(async function selectTabInOtherWindow({ client, tab }) { + const { Target, target } = client; + + const currentTargetId = target.id; + const targets = await getDiscoveredTargets(Target); + const filtered_targets = targets.filter(target => { + return target.targetId == currentTargetId; + }); + is(filtered_targets.length, 1, "The current target has been found"); + const initialTarget = filtered_targets[0]; + + is(tab.ownerGlobal, getFocusedNavigator(), "Initial window is focused"); + + // open some more tabs in the initial window + await openTab(Target); + await openTab(Target); + const lastTabFirstWindow = await openTab(Target); + is( + gBrowser.selectedTab, + lastTabFirstWindow.newTab, + "Last openend tab in initial window is the selected tab" + ); + + const { newWindow } = await openWindow(Target); + + const lastTabSecondWindow = await openTab(Target); + is( + gBrowser.selectedTab, + lastTabSecondWindow.newTab, + "Last openend tab in new window is the selected tab" + ); + + try { + is(newWindow, getFocusedNavigator(), "The new window is focused"); + await Target.activateTarget({ + targetId: initialTarget.targetId, + }); + is( + tab.ownerGlobal, + getFocusedNavigator(), + "Initial window is focused again" + ); + is(gBrowser.selectedTab, tab, "Selected tab is the initial tab again"); + } finally { + await BrowserTestUtils.closeWindow(newWindow); + } +}); + +function getFocusedNavigator() { + return Services.wm.getMostRecentWindow("navigator:browser"); +} diff --git a/remote/cdp/test/browser/target/browser_attachToTarget.js b/remote/cdp/test/browser/target/browser_attachToTarget.js new file mode 100644 index 0000000000..944d7a9984 --- /dev/null +++ b/remote/cdp/test/browser/target/browser_attachToTarget.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function raisesWithoutArguments({ client, tab }) { + const { Target } = client; + + await Assert.rejects( + Target.attachToTarget(), + err => err.message.includes(`Unable to find target with id`), + "attachToTarget raised error without an argument" + ); +}); + +add_task(async function raisesWithUnknownTargetId({ client, tab }) { + const { Target } = client; + + await Assert.rejects( + Target.attachToTarget({ targetId: "-1" }), + err => err.message.includes(`Unable to find target with id`), + "attachToTarget raised error with unkown target id" + ); +}); + +add_task( + async function attachPageTarget({ client }) { + const { Target } = client; + const { targetInfo } = await openTab(Target); + + ok(!targetInfo.attached, "New target is not attached"); + + info("Attach new target"); + const { sessionId } = await Target.attachToTarget({ + targetId: targetInfo.targetId, + }); + + is( + typeof sessionId, + "string", + "attachToTarget returns the session id as string" + ); + + const { targetInfos } = await Target.getTargets(); + const listedTarget = targetInfos.find( + info => info.targetId === targetInfo.targetId + ); + + ok(listedTarget.attached, "New target is attached"); + }, + { createTab: false } +); diff --git a/remote/cdp/test/browser/target/browser_attachedToTarget.js b/remote/cdp/test/browser/target/browser_attachedToTarget.js new file mode 100644 index 0000000000..4f25868f2a --- /dev/null +++ b/remote/cdp/test/browser/target/browser_attachedToTarget.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_TEST = + "https://example.com/browser/remote/cdp/test/browser/target/doc_test.html"; + +add_task( + async function attachedPageTarget({ client }) { + const { Target } = client; + const { targetInfo } = await openTab(Target); + + ok( + !targetInfo.attached, + "Got expected target attached status before attaching" + ); + + await loadURL(PAGE_TEST); + + info("Attach new page target"); + const attachedToTarget = Target.attachedToTarget(); + const { sessionId } = await Target.attachToTarget({ + targetId: targetInfo.targetId, + }); + const { + targetInfo: eventTargetInfo, + sessionId: eventSessionId, + waitingForDebugger: eventWaitingForDebugger, + } = await attachedToTarget; + + is(eventTargetInfo.targetId, targetInfo.targetId, "Got expected target id"); + is(eventTargetInfo.type, "page", "Got expected target type"); + is(eventTargetInfo.title, "Test Page", "Got expected target title"); + is(eventTargetInfo.url, PAGE_TEST, "Got expected target URL"); + ok(eventTargetInfo.attached, "Got expected target attached status"); + + is( + eventSessionId, + sessionId, + "attachedToTarget and attachToTarget refer to the same session id" + ); + is( + typeof eventWaitingForDebugger, + "boolean", + "Got expected type for waitingForDebugger" + ); + }, + { createTab: false } +); diff --git a/remote/cdp/test/browser/target/browser_browserContext.js b/remote/cdp/test/browser/target/browser_browserContext.js new file mode 100644 index 0000000000..98c2ccee2e --- /dev/null +++ b/remote/cdp/test/browser/target/browser_browserContext.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function ({ CDP }) { + // Connect to the server + const { webSocketDebuggerUrl } = await CDP.Version(); + const client = await CDP({ target: webSocketDebuggerUrl }); + info("CDP client has been instantiated"); + + const { Target } = client; + await getDiscoveredTargets(Target); + + // Test if Target.getBrowserContexts is empty before creatinga ny + const { browserContextIds: browserContextIdsBefore } = + await Target.getBrowserContexts(); + + is( + browserContextIdsBefore.length, + 0, + "No browser context is open by default" + ); + + const { browserContextId } = await Target.createBrowserContext(); + + // Test if Target.getBrowserContexts includes the context we just created + const { browserContextIds } = await Target.getBrowserContexts(); + + is(browserContextIds.length, 1, "Got expected length of browser contexts"); + is( + browserContextIds[0], + browserContextId, + "Got expected browser context id from getBrowserContexts" + ); + + const targetCreated = Target.targetCreated(); + const { targetId } = await Target.createTarget({ + url: "about:blank", + browserContextId, + }); + ok(!!targetId, "Target.createTarget returns a non-empty target id"); + + const { targetInfo } = await targetCreated; + is( + targetId, + targetInfo.targetId, + "targetCreated refers to the same target id" + ); + is( + browserContextId, + targetInfo.browserContextId, + "targetCreated refers to the same browser context" + ); + is(targetInfo.type, "page", "The target is a page"); + + // Releasing the browser context is going to remove the tab opened when calling createTarget + await Target.disposeBrowserContext({ browserContextId }); + + // Test if Target.getBrowserContexts now is empty + const { browserContextIds: browserContextIdsAfter } = + await Target.getBrowserContexts(); + + is( + browserContextIdsAfter.length, + 0, + "After closing all browser contexts none is available anymore" + ); + + await client.close(); + info("The client is closed"); +}); diff --git a/remote/cdp/test/browser/target/browser_closeTarget.js b/remote/cdp/test/browser/target/browser_closeTarget.js new file mode 100644 index 0000000000..694994148b --- /dev/null +++ b/remote/cdp/test/browser/target/browser_closeTarget.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function raisesWithoutArguments({ client, tab }) { + const { Target } = client; + + await Assert.rejects( + Target.closeTarget(), + err => err.message.includes(`Unable to find target with id `), + "closeTarget raised error without an argument" + ); +}); + +add_task(async function raisesWithUnknownTargetId({ client, tab }) { + const { Target } = client; + + await Assert.rejects( + Target.closeTarget({ targetId: "-1" }), + err => err.message.includes(`Unable to find target with id `), + "closeTarget raised error with unkown target id" + ); +}); + +add_task(async function triggersTargetDestroyed({ client, tab }) { + const { Target } = client; + const { targetInfo, newTab } = await openTab(Target); + + const tabClosed = BrowserTestUtils.waitForEvent(newTab, "TabClose"); + const targetDestroyed = Target.targetDestroyed(); + + info("Closing the target"); + await Target.closeTarget({ targetId: targetInfo.targetId }); + + await tabClosed; + info("Tab was closed"); + + await targetDestroyed; + info("Received the Target.targetDestroyed event"); +}); diff --git a/remote/cdp/test/browser/target/browser_createTarget.js b/remote/cdp/test/browser/target/browser_createTarget.js new file mode 100644 index 0000000000..35fbe84b43 --- /dev/null +++ b/remote/cdp/test/browser/target/browser_createTarget.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_TEST = + "https://example.com/browser/remote/cdp/test/browser/target/doc_test.html"; + +add_task( + async function raisesWithoutArguments({ client }) { + const { Target } = client; + + await Assert.rejects( + Target.createTarget(), + err => err.message.includes("url: string value expected"), + "createTarget raised error without a URL" + ); + }, + { createTab: false } +); + +add_task( + async function raisesWithInvalidUrlType({ client }) { + const { Target } = client; + + for (const url of [null, true, 1, [], {}]) { + info(`Checking url with invalid value: ${url}`); + + await Assert.rejects( + Target.createTarget({ + url, + }), + /url: string value expected/, + `URL fails for invalid type: ${url}` + ); + } + }, + { createTab: false } +); + +add_task( + async function invalidUrlDefaults({ client }) { + const { Target } = client; + const expectedUrl = "about:blank"; + + for (const url of ["", "example.com", "https://example[.com", "https:"]) { + // Here we cannot wait for browserLoaded, because the tab might already + // be on about:blank when `createTarget` resolves. + const onNewTabLoaded = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:blank", + true + ); + const { targetId } = await Target.createTarget({ url }); + is(typeof targetId, "string", "Got expected type for target id"); + + // Wait for the load to be done before checking the URL. + const tab = await onNewTabLoaded; + const browser = tab.linkedBrowser; + is(browser.currentURI.spec, expectedUrl, "Expected URL loaded"); + } + }, + { createTab: false } +); + +add_task( + async function opensTabWithCorrectInfo({ client }) { + const { Target } = client; + + const url = PAGE_TEST; + const onNewTabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, url, true); + const { targetId } = await Target.createTarget({ url }); + + is(typeof targetId, "string", "Got expected type for target id"); + + const tab = await onNewTabLoaded; + const browser = tab.linkedBrowser; + is(browser.currentURI.spec, url, "Expected URL loaded"); + + const { targetInfos } = await Target.getTargets(); + const targetInfo = targetInfos.find(info => info.targetId === targetId); + ok(!!targetInfo, "Found target info with the same target id"); + is(targetInfo.url, url, "Target info refers to the same target URL"); + is( + targetInfo.type, + "page", + "Target info refers to the same target as page type" + ); + ok( + !targetInfo.attached, + "Target info refers to the same target as not attached" + ); + }, + { createTab: false } +); diff --git a/remote/cdp/test/browser/target/browser_getTargets.js b/remote/cdp/test/browser/target/browser_getTargets.js new file mode 100644 index 0000000000..0d56661fc2 --- /dev/null +++ b/remote/cdp/test/browser/target/browser_getTargets.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_TEST = + "https://example.com/browser/remote/cdp/test/browser/target/doc_test.html"; + +add_task( + async function getTargetsDetails({ client }) { + const { Target, target } = client; + + await loadURL(PAGE_TEST); + + const { targetInfos } = await Target.getTargets(); + + Assert.equal(targetInfos.length, 1, "Got expected amount of targets"); + + const targetInfo = targetInfos[0]; + is(targetInfo.id, target.id, "Got expected target id"); + is(targetInfo.type, "page", "Got expected target type"); + is(targetInfo.title, "Test Page", "Got expected target title"); + is(targetInfo.url, PAGE_TEST, "Got expected target URL"); + ok(targetInfo.attached, "Got expected target attached status"); + }, + { createTab: false } +); + +add_task( + async function getTargetsCount({ client }) { + const { Target, target } = client; + const { targetInfo: newTabTargetInfo } = await openTab(Target); + + await loadURL(PAGE_TEST); + + const { targetInfos } = await Target.getTargets(); + + Assert.equal(targetInfos.length, 2, "Got expected amount of targets"); + const targetIds = targetInfos.map(info => info.id); + ok(targetIds.includes(target.id), "Got expected original target id"); + ok(targetIds.includes(newTabTargetInfo.id), "Got expected new target id"); + }, + { createTab: false } +); + +add_task( + async function getTargetsAttached({ client }) { + const { Target } = client; + await openTab(Target); + + await loadURL(PAGE_TEST); + + const { targetInfos } = await Target.getTargets(); + + ok(targetInfos[0].attached, "Current target is attached"); + ok(!targetInfos[1].attached, "New tab target is detached"); + }, + { createTab: false } +); + +add_task( + async function getTargets_filterAllBlank({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + // Blank/all filter so all targets are returned, including main process + const { targetInfos } = await Target.getTargets({ + filter: [{}], + }); + + is( + targetInfos.length, + 2, + "Got expected amount of targets with all (blank) filter" + ); + + const pageTarget = targetInfos.find(info => info.type === "page"); + ok(!!pageTarget, "Found page target in targets with all (blank) filter"); + + const mainProcessTarget = targetInfos.find(info => info.type === "browser"); + ok( + !!mainProcessTarget, + "Found main process target in targets with all (blank) filter" + ); + }, + { createTab: false } +); + +add_task( + async function getTargets_filterAllExplicit({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + // Blank/all filter so all targets are returned, including main process + const { targetInfos } = await Target.getTargets({ + filter: [{ type: "browser" }, { type: "page" }], + }); + + is( + targetInfos.length, + 2, + "Got expected amount of targets with all (explicit) filter" + ); + + const pageTarget = targetInfos.find(info => info.type === "page"); + ok(!!pageTarget, "Found page target in targets with all (explicit) filter"); + + const mainProcessTarget = targetInfos.find(info => info.type === "browser"); + ok( + !!mainProcessTarget, + "Found main process target in targets with all (explicit) filter" + ); + }, + { createTab: false } +); + +add_task( + async function getTargets_filterPage({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + // Filter so only page targets are returned + // This returns same as default but pass our own custom filter to ensure + const { targetInfos } = await Target.getTargets({ + filter: [{ type: "page" }], + }); + + is( + targetInfos.length, + 1, + "Got expected amount of targets with page filter" + ); + is( + targetInfos[0].type, + "page", + "Got expected type 'page' of target from page filter" + ); + }, + { createTab: false } +); + +add_task( + async function getTargets_filterBrowser({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + // Filter so only main process target is returned + const { targetInfos } = await Target.getTargets({ + filter: [{ type: "browser" }], + }); + + is( + targetInfos.length, + 1, + "Got expected amount of targets with browser filter" + ); + is( + targetInfos[0].type, + "browser", + "Got expected type 'browser' of target from browser filter" + ); + }, + { createTab: false } +); + +add_task( + async function getTargets_filterExcludePage({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + // Filter so page targets are excluded (so only main process target is returned) + // A blank object ({}) means include everything else + const { targetInfos } = await Target.getTargets({ + filter: [{ type: "page", exclude: true }, {}], + }); + + is( + targetInfos.length, + 1, + "Got expected amount of targets with exclude page filter" + ); + is( + targetInfos[0].type, + "browser", + "Got expected type 'browser' of target from exclude page filter" + ); + }, + { createTab: false } +); + +add_task( + async function getTargets_filterExcludeBrowserIncludePage({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + // Filter so main process is excluded and only page types are returned explicitly + const { targetInfos } = await Target.getTargets({ + filter: [{ type: "browser", exclude: true }, { type: "page" }], + }); + + is( + targetInfos.length, + 1, + "Got expected amount of targets with exclude browser include page filter" + ); + is( + targetInfos[0].type, + "page", + "Got expected type 'page' of target from exclude browser include page filter" + ); + }, + { createTab: false } +); + +add_task( + async function getTargets_filterInvalid({ client }) { + const { Target } = client; + + for (const filter of [null, true, 1, "foo", {}]) { + info(`Checking filter with invalid value: ${filter}`); + + await Assert.rejects( + Target.getTargets({ + filter, + }), + /filter: array value expected/, + `Filter fails for invalid type: ${filter}` + ); + } + + for (const filterEntry of [null, true, 1, "foo", []]) { + info(`Checking filter entry with invalid value: ${filterEntry}`); + + await Assert.rejects( + Target.getTargets({ + filter: [filterEntry], + }), + /filter: object values expected in array/, + `Filter entry fails for invalid type: ${filterEntry}` + ); + } + + for (const type of [null, true, 1, [], {}]) { + info(`Checking filter entry with type as invalid value: ${type}`); + + await Assert.rejects( + Target.getTargets({ + filter: [{ type }], + }), + /filter: type: string value expected/, + `Filter entry type fails for invalid type: ${type}` + ); + } + + for (const exclude of [null, 1, "foo", [], {}]) { + info(`Checking filter entry with exclude as invalid value: ${exclude}`); + + await Assert.rejects( + Target.getTargets({ + filter: [{ exclude }], + }), + /filter: exclude: boolean value expected/, + `Filter entry exclude for invalid type: ${exclude}` + ); + } + }, + { createTab: false } +); diff --git a/remote/cdp/test/browser/target/browser_sendMessageToTarget.js b/remote/cdp/test/browser/target/browser_sendMessageToTarget.js new file mode 100644 index 0000000000..b440066178 --- /dev/null +++ b/remote/cdp/test/browser/target/browser_sendMessageToTarget.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function sendToAttachedTarget({ client }) { + const { Target } = client; + const { targetInfo } = await openTab(Target); + + const attachedToTarget = Target.attachedToTarget(); + const { sessionId } = await Target.attachToTarget({ + targetId: targetInfo.targetId, + }); + await attachedToTarget; + info("Target attached"); + + const id = 1; + const message = JSON.stringify({ + id, + method: "Page.navigate", + params: { + url: toDataURL("new-page"), + }, + }); + + info("Calling Target.sendMessageToTarget"); + const onResponse = Target.receivedMessageFromTarget(); + await Target.sendMessageToTarget({ sessionId, message }); + const response = await onResponse; + info("Message from target received"); + + ok(!!response, "The response is not empty"); + is(response.sessionId, sessionId, "The response is from the same session"); + + const responseMessage = JSON.parse(response.message); + is(responseMessage.id, id, "The response is from the same session"); + ok( + !!responseMessage.result.frameId, + "received the `frameId` out of `Page.navigate` request" + ); +}); diff --git a/remote/cdp/test/browser/target/browser_setDiscoverTargets.js b/remote/cdp/test/browser/target/browser_setDiscoverTargets.js new file mode 100644 index 0000000000..5a16115a4a --- /dev/null +++ b/remote/cdp/test/browser/target/browser_setDiscoverTargets.js @@ -0,0 +1,258 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// These tests are a near copy of the tests for Target.getTargets, but using +// the `setDiscoverTargets` method and `targetCreated` events instead. +// Calling `setDiscoverTargets` with `discover: true` will dispatch a +// `targetCreated` event for all already opened tabs and NOT the browser target +// with the default filter. + +const PAGE_TEST = + "https://example.com/browser/remote/cdp/test/browser/target/doc_test.html"; + +add_task( + async function discoverInvalidTypes({ client }) { + const { Target } = client; + + for (const discover of [null, undefined, 1, "foo", [], {}]) { + info(`Checking discover with invalid value: ${discover}`); + + await Assert.rejects( + Target.setDiscoverTargets({ discover }), + /discover: boolean value expected/, + `Discover fails for invalid type: ${discover}` + ); + } + }, + { createTab: false } +); + +add_task( + async function filterInvalid({ client }) { + const { Target } = client; + + for (const filter of [null, true, 1, "foo", {}]) { + info(`Checking filter with invalid value: ${filter}`); + + await Assert.rejects( + Target.setDiscoverTargets({ discover: true, filter }), + /filter: array value expected/, + `Filter fails for invalid type: ${filter}` + ); + } + + for (const filterEntry of [null, undefined, true, 1, "foo", []]) { + info(`Checking filter entry with invalid value: ${filterEntry}`); + + await Assert.rejects( + Target.setDiscoverTargets({ + discover: true, + filter: [filterEntry], + }), + /filter: object values expected in array/, + `Filter entry fails for invalid type: ${filterEntry}` + ); + } + + for (const type of [null, true, 1, [], {}]) { + info(`Checking filter entry with type as invalid value: ${type}`); + + await Assert.rejects( + Target.setDiscoverTargets({ + discover: true, + filter: [{ type }], + }), + /filter: type: string value expected/, + `Filter entry type fails for invalid type: ${type}` + ); + } + + for (const exclude of [null, 1, "foo", [], {}]) { + info(`Checking filter entry with exclude as invalid value: ${exclude}`); + + await Assert.rejects( + Target.setDiscoverTargets({ + discover: true, + filter: [{ exclude }], + }), + /filter: exclude: boolean value expected/, + `Filter entry exclude for invalid type: ${exclude}` + ); + } + }, + { createTab: false } +); + +add_task( + async function noFilterWithDiscoverFalse({ client }) { + const { Target } = client; + + // Check filter cannot be given with discover: false + + await Assert.rejects( + Target.setDiscoverTargets({ + discover: false, + filter: [{}], + }), + /filter: should not be present when discover is false/, + `Error throw when given filter with discover false` + ); + }, + { createTab: false } +); + +add_task( + async function noTargetsWithDiscoverFalse({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + const targets = await getDiscoveredTargets(Target, { discover: false }); + is(targets.length, 0, "Got 0 targets with discover false"); + }, + { createTab: false } +); + +add_task( + async function noEventsWithDiscoverFalse({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + const targets = []; + const unsubscribe = Target.targetCreated(target => { + targets.push(target.targetInfo); + }); + + await Target.setDiscoverTargets({ + discover: false, + }); + + // Cannot use openTab() helper as it relies on the event + await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Wait 1s for the event to possibly dispatch + await timeoutPromise(1000); + + unsubscribe(); + is(targets.length, 0, "Got 0 target created events with discover false"); + }, + { createTab: false } +); + +add_task( + async function targetInfoValues({ client }) { + const { Target, target } = client; + + await loadURL(PAGE_TEST); + + const targets = await getDiscoveredTargets(Target); + + Assert.equal(targets.length, 1, "Got expected amount of targets"); + + const targetInfo = targets[0]; + is(targetInfo.id, target.id, "Got expected target id"); + is(targetInfo.type, "page", "Got expected target type"); + is(targetInfo.title, "Test Page", "Got expected target title"); + is(targetInfo.url, PAGE_TEST, "Got expected target URL"); + }, + { createTab: false } +); + +add_task( + async function discoverEnabledAndMultipleTabs({ client }) { + const { Target, target } = client; + const { targetInfo: newTabTargetInfo } = await openTab(Target); + + await loadURL(PAGE_TEST); + + const targets = await getDiscoveredTargets(Target); + + Assert.equal(targets.length, 2, "Got expected amount of targets"); + const targetIds = targets.map(info => info.id); + ok(targetIds.includes(target.id), "Got expected original target id"); + ok(targetIds.includes(newTabTargetInfo.id), "Got expected new target id"); + }, + { createTab: false } +); + +add_task( + async function allFilters({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + for (const filter of [[{}], [{ type: "browser" }, { type: "page" }]]) { + // Blank/all filter so all targets are returned, including main process + const targets = await getDiscoveredTargets(Target, { filter }); + + is(targets.length, 2, "Got expected amount of targets with all filter"); + + const pageTarget = targets.find(info => info.type === "page"); + ok(!!pageTarget, "Found page target in targets with all filter"); + + const mainProcessTarget = targets.find(info => info.type === "browser"); + ok( + !!mainProcessTarget, + "Found main process target in targets with all filter" + ); + } + }, + { createTab: false } +); + +add_task( + async function pageOnlyFilters({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + for (const filter of [ + [{ type: "page" }], + [{ type: "browser", exclude: true }, { type: "page" }], + ]) { + // Filter so only page targets are returned + // This returns same as default but pass our own custom filter to ensure + // these filters still return what they should + const targets = await getDiscoveredTargets(Target, { filter }); + + is(targets.length, 1, "Got expected amount of targets with page filter"); + is( + targets[0].type, + "page", + "Got expected type 'page' of target from page filter" + ); + } + }, + { createTab: false } +); + +add_task( + async function browserOnlyFilters({ client }) { + const { Target } = client; + + await loadURL(PAGE_TEST); + + for (const filter of [ + [{ type: "browser" }], + [{ type: "page", exclude: true }, {}], + ]) { + // Filter so only main process target is returned + const targets = await getDiscoveredTargets(Target, { filter }); + + is( + targets.length, + 1, + "Got expected amount of targets with browser only filter" + ); + is( + targets[0].type, + "browser", + "Got expected type 'browser' of target from browser only filter" + ); + } + }, + { createTab: false } +); diff --git a/remote/cdp/test/browser/target/browser_targetCreated.js b/remote/cdp/test/browser/target/browser_targetCreated.js new file mode 100644 index 0000000000..bfb7287f87 --- /dev/null +++ b/remote/cdp/test/browser/target/browser_targetCreated.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function eventFiredWhenTabIsCreated({ client }) { + const { Target } = client; + + const targetCreated = Target.targetCreated(); + await BrowserTestUtils.openNewForegroundTab(gBrowser); + const { targetInfo } = await targetCreated; + + is(typeof targetInfo.targetId, "string", "Got expected type for target id"); + is(targetInfo.type, "page", "Got expected target type"); + is(targetInfo.title, "", "Got expected target title"); + is(targetInfo.url, "about:blank", "Got expected target URL"); + is(targetInfo.attached, false, "Got expected attached status"); +}); diff --git a/remote/cdp/test/browser/target/browser_targetDestroyed.js b/remote/cdp/test/browser/target/browser_targetDestroyed.js new file mode 100644 index 0000000000..2ad657b135 --- /dev/null +++ b/remote/cdp/test/browser/target/browser_targetDestroyed.js @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function eventFiredWhenTabIsClosed({ client, tab }) { + const { Target } = client; + const { newTab } = await openTab(Target); + + const tabClosed = BrowserTestUtils.waitForEvent(newTab, "TabClose"); + const targetDestroyed = Target.targetDestroyed(); + + info("Closing the tab"); + BrowserTestUtils.removeTab(newTab); + + await tabClosed; + info("Tab was closed"); + + await targetDestroyed; + info("Received the Target.targetDestroyed event"); +}); diff --git a/remote/cdp/test/browser/target/doc_test.html b/remote/cdp/test/browser/target/doc_test.html new file mode 100644 index 0000000000..14d377f07a --- /dev/null +++ b/remote/cdp/test/browser/target/doc_test.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test Page</title> +</head> +<body> +</body> +</html> diff --git a/remote/cdp/test/browser/target/head.js b/remote/cdp/test/browser/target/head.js new file mode 100644 index 0000000000..1a1c90fbf6 --- /dev/null +++ b/remote/cdp/test/browser/target/head.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/head.js", + this +); diff --git a/remote/cdp/test/xpcshell/test_CDPConnection.js b/remote/cdp/test/xpcshell/test_CDPConnection.js new file mode 100644 index 0000000000..848155633f --- /dev/null +++ b/remote/cdp/test/xpcshell/test_CDPConnection.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { splitMethod } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/CDPConnection.sys.mjs" +); + +add_task(function test_Connection_splitMethod() { + for (const t of [42, null, true, {}, [], undefined]) { + Assert.throws(() => splitMethod(t), /TypeError/, `${typeof t} throws`); + } + for (const s of ["", ".", "foo.", ".bar", "foo.bar.baz"]) { + Assert.throws( + () => splitMethod(s), + /Invalid method format: '.*'/, + `"${s}" throws` + ); + } + deepEqual(splitMethod("foo.bar"), { + domain: "foo", + command: "bar", + }); +}); diff --git a/remote/cdp/test/xpcshell/test_DomainCache.js b/remote/cdp/test/xpcshell/test_DomainCache.js new file mode 100644 index 0000000000..9ea53ccdd9 --- /dev/null +++ b/remote/cdp/test/xpcshell/test_DomainCache.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Domain } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/domains/Domain.sys.mjs" +); +const { DomainCache } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/domains/DomainCache.sys.mjs" +); + +class MockSession { + onEvent() {} +} + +const noopSession = new MockSession(); + +add_task(function test_DomainCache_constructor() { + new DomainCache(noopSession, {}); +}); + +add_task(function test_DomainCache_domainSupportsMethod() { + const modules = { + Foo: class extends Domain { + bar() {} + }, + }; + const domains = new DomainCache(noopSession, modules); + + ok(domains.domainSupportsMethod("Foo", "bar")); + ok(!domains.domainSupportsMethod("Foo", "baz")); + ok(!domains.domainSupportsMethod("foo", "bar")); +}); + +add_task(function test_DomainCache_get_invalidModule() { + Assert.throws(() => { + const domains = new DomainCache(noopSession, { Foo: undefined }); + domains.get("Foo"); + }, /UnknownMethodError/); +}); + +add_task(function test_DomainCache_get_missingConstructor() { + Assert.throws(() => { + const domains = new DomainCache(noopSession, { Foo: {} }); + domains.get("Foo"); + }, /TypeError/); +}); + +add_task(function test_DomainCache_get_superClassNotDomain() { + Assert.throws(() => { + const domains = new DomainCache(noopSession, { Foo: class {} }); + domains.get("Foo"); + }, /TypeError/); +}); + +add_task(function test_DomainCache_get_constructs() { + let eventFired; + class Session { + onEvent(event) { + eventFired = event; + } + } + + let constructed = false; + class Foo extends Domain { + constructor() { + super(); + constructed = true; + } + } + + const session = new Session(); + const domains = new DomainCache(session, { Foo }); + + const foo = domains.get("Foo"); + ok(constructed); + ok(foo instanceof Foo); + + const event = {}; + foo.emit(event); + equal(event, eventFired); +}); + +add_task(function test_DomainCache_size() { + class Foo extends Domain {} + const domains = new DomainCache(noopSession, { Foo }); + + equal(domains.size, 0); + domains.get("Foo"); + equal(domains.size, 1); +}); + +add_task(function test_DomainCache_clear() { + let dtorCalled = false; + class Foo extends Domain { + destructor() { + dtorCalled = true; + } + } + + const domains = new DomainCache(noopSession, { Foo }); + + equal(domains.size, 0); + domains.get("Foo"); + equal(domains.size, 1); + + domains.clear(); + equal(domains.size, 0); + ok(dtorCalled); +}); diff --git a/remote/cdp/test/xpcshell/test_Error.js b/remote/cdp/test/xpcshell/test_Error.js new file mode 100644 index 0000000000..1be7be18ed --- /dev/null +++ b/remote/cdp/test/xpcshell/test_Error.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +/* eslint-disable no-tabs */ + +const { RemoteAgentError, UnknownMethodError, UnsupportedError } = + ChromeUtils.importESModule("chrome://remote/content/cdp/Error.sys.mjs"); + +add_task(function test_RemoteAgentError_ctor() { + const e1 = new RemoteAgentError(); + equal(e1.name, "RemoteAgentError"); + equal(e1.message, ""); + equal(e1.cause, e1.message); + + const e2 = new RemoteAgentError("message"); + equal(e2.message, "message"); + equal(e2.cause, e2.message); + + const e3 = new RemoteAgentError("message", "cause"); + equal(e3.message, "message"); + equal(e3.cause, "cause"); +}); + +add_task(function test_RemoteAgentError_notify() { + // nothing much we can test, except test that it doesn't throw + new RemoteAgentError().notify(); +}); + +add_task(function test_RemoteAgentError_toString() { + const e = new RemoteAgentError("message"); + equal(e.toString(), RemoteAgentError.format(e)); + equal( + e.toString({ stack: true }), + RemoteAgentError.format(e, { stack: true }) + ); +}); + +add_task(function test_RemoteAgentError_format() { + const { format } = RemoteAgentError; + + equal(format({ name: "HippoError" }), "HippoError"); + equal(format({ name: "HorseError", message: "neigh" }), "HorseError: neigh"); + + const dog = { + name: "DogError", + message: "woof", + stack: " one\ntwo\nthree ", + }; + equal(format(dog), "DogError: woof"); + equal( + format(dog, { stack: true }), + `DogError: woof: + one + two + three` + ); + + const cat = { + name: "CatError", + message: "meow", + stack: "four\nfive\nsix", + cause: dog, + }; + equal(format(cat), "CatError: meow"); + equal( + format(cat, { stack: true }), + `CatError: meow: + four + five + six +caused by: DogError: woof: + one + two + three` + ); +}); + +add_task(function test_RemoteAgentError_fromJSON() { + const cdpErr = { + message: `TypeError: foo: + bar + baz`, + }; + const err = RemoteAgentError.fromJSON(cdpErr); + + equal(err.message, "TypeError: foo"); + equal(err.stack, "bar\nbaz"); + equal(err.cause, null); +}); + +add_task(function test_UnsupportedError() { + ok(new UnsupportedError() instanceof RemoteAgentError); +}); + +add_task(function test_UnknownMethodError() { + ok(new UnknownMethodError() instanceof RemoteAgentError); + ok(new UnknownMethodError("domain").message.endsWith("domain")); + ok( + new UnknownMethodError("domain", "command").message.endsWith( + "domain.command" + ) + ); +}); diff --git a/remote/cdp/test/xpcshell/test_Session.js b/remote/cdp/test/xpcshell/test_Session.js new file mode 100644 index 0000000000..3cd8adc7e3 --- /dev/null +++ b/remote/cdp/test/xpcshell/test_Session.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Session } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/sessions/Session.sys.mjs" +); + +const connection = { + registerSession: () => {}, + transport: { + on: () => {}, + }, +}; + +class MockTarget { + constructor() {} + + get browsingContext() { + return { id: 42 }; + } + + get mm() { + return { + addMessageListener() {}, + removeMessageListener() {}, + loadFrameScript() {}, + sendAsyncMessage() {}, + }; + } +} + +add_task(function test_Session_destructor() { + const session = new Session(connection, new MockTarget()); + session.domains.get("Browser"); + equal(session.domains.size, 1); + session.destructor(); + equal(session.domains.size, 0); +}); diff --git a/remote/cdp/test/xpcshell/test_StreamRegistry.js b/remote/cdp/test/xpcshell/test_StreamRegistry.js new file mode 100644 index 0000000000..329f33d275 --- /dev/null +++ b/remote/cdp/test/xpcshell/test_StreamRegistry.js @@ -0,0 +1,153 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Stream, StreamRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/StreamRegistry.sys.mjs" +); + +add_task(function test_constructor() { + const registry = new StreamRegistry(); + equal(registry.streams.size, 0); +}); + +add_task(async function test_destructor() { + const registry = new StreamRegistry(); + + const stream1 = await createFileStream("foo bar"); + const stream2 = await createFileStream("foo bar"); + + registry.add(stream1); + registry.add(stream2); + + equal(registry.streams.size, 2); + + await registry.destructor(); + equal(registry.streams.size, 0); + + ok(!(await IOUtils.exists(stream1.path)), "temporary file has been removed"); + ok(!(await IOUtils.exists(stream2.path)), "temporary file has been removed"); +}); + +add_task(async function test_addValidStreamType() { + const registry = new StreamRegistry(); + + const stream = await createFileStream("foo bar"); + const handle = registry.add(stream); + + equal(registry.streams.size, 1, "A single stream has been added"); + equal(typeof handle, "string", "Handle is of type string"); + ok(registry.streams.has(handle), "Handle has been found"); + + const rv = registry.streams.get(handle); + equal(rv, stream, "Expected stream found"); +}); + +add_task(async function test_addCreatesDifferentHandles() { + const registry = new StreamRegistry(); + const stream = await createFileStream("foo bar"); + + const handle1 = registry.add(stream); + equal(registry.streams.size, 1, "A single stream has been added"); + equal(typeof handle1, "string", "Handle is of type string"); + ok(registry.streams.has(handle1), "Handle has been found"); + equal(registry.streams.get(handle1), stream, "Expected stream found"); + + const handle2 = registry.add(stream); + equal(registry.streams.size, 2, "A single stream has been added"); + equal(typeof handle2, "string", "Handle is of type string"); + ok(registry.streams.has(handle2), "Handle has been found"); + equal(registry.streams.get(handle2), stream, "Expected stream found"); + + notEqual(handle1, handle2, "Different handles have been generated"); +}); + +add_task(async function test_addInvalidStreamType() { + const registry = new StreamRegistry(); + Assert.throws(() => registry.add(new Blob([])), /UnsupportedError/); +}); + +add_task(async function test_getForValidHandle() { + const registry = new StreamRegistry(); + const stream = await createFileStream("foo bar"); + const handle = registry.add(stream); + + equal(registry.streams.size, 1, "A single stream has been added"); + equal(registry.get(handle), stream, "Expected stream found"); +}); + +add_task(async function test_getForInvalidHandle() { + const registry = new StreamRegistry(); + const stream = await createFileStream("foo bar"); + registry.add(stream); + + equal(registry.streams.size, 1, "A single stream has been added"); + Assert.throws(() => registry.get("foo"), /TypeError/); +}); + +add_task(async function test_removeForValidHandle() { + const registry = new StreamRegistry(); + const stream1 = await createFileStream("foo bar"); + const stream2 = await createFileStream("foo bar"); + + const handle1 = registry.add(stream1); + const handle2 = registry.add(stream2); + + equal(registry.streams.size, 2); + + await registry.remove(handle1); + equal(registry.streams.size, 1); + equal(registry.get(handle2), stream2, "Second stream has not been closed"); + + ok( + !(await IOUtils.exists(stream1.path)), + "temporary file for first stream is removed" + ); + ok( + await IOUtils.exists(stream2.path), + "temporary file for second stream is not removed" + ); +}); + +add_task(async function test_removeForInvalidHandle() { + const registry = new StreamRegistry(); + const stream = await createFileStream("foo bar"); + registry.add(stream); + + equal(registry.streams.size, 1, "A single stream has been added"); + await Assert.rejects(registry.remove("foo"), /TypeError/); +}); + +/** + * Create a stream with the specified contents. + * + * @param {string} contents + * Contents of the file. + * @param {object} options + * @param {string=} options.path + * Path of the file. Defaults to the temporary directory. + * @param {boolean=} options.remove + * If true, automatically remove the file after the test. Defaults to true. + * + * @returns {Promise<Stream>} + */ +async function createFileStream(contents, options = {}) { + let { path = null, remove = true } = options; + + if (!path) { + path = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "remote-agent.txt" + ); + } + + await IOUtils.writeUTF8(path, contents); + + const stream = new Stream(path); + if (remove) { + registerCleanupFunction(() => stream.destroy()); + } + + return stream; +} diff --git a/remote/cdp/test/xpcshell/xpcshell.toml b/remote/cdp/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..e853f32778 --- /dev/null +++ b/remote/cdp/test/xpcshell/xpcshell.toml @@ -0,0 +1,11 @@ +[DEFAULT] + +["test_CDPConnection.js"] + +["test_DomainCache.js"] + +["test_Error.js"] + +["test_Session.js"] + +["test_StreamRegistry.js"] diff --git a/remote/components/Marionette.sys.mjs b/remote/components/Marionette.sys.mjs new file mode 100644 index 0000000000..87a53679e4 --- /dev/null +++ b/remote/components/Marionette.sys.mjs @@ -0,0 +1,308 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Deferred: "chrome://remote/content/shared/Sync.sys.mjs", + EnvironmentPrefs: "chrome://remote/content/marionette/prefs.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs", + RecommendedPreferences: + "chrome://remote/content/shared/RecommendedPreferences.sys.mjs", + TCPListener: "chrome://remote/content/marionette/server.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +ChromeUtils.defineLazyGetter(lazy, "textEncoder", () => new TextEncoder()); + +const NOTIFY_LISTENING = "marionette-listening"; + +// Complements -marionette flag for starting the Marionette server. +// We also set this if Marionette is running in order to start the server +// again after a Firefox restart. +const ENV_ENABLED = "MOZ_MARIONETTE"; + +// Besides starting based on existing prefs in a profile and a command +// line flag, we also support inheriting prefs out of an env var, and to +// start Marionette that way. +// +// This allows marionette prefs to persist when we do a restart into +// a different profile in order to test things like Firefox refresh. +// The environment variable itself, if present, is interpreted as a +// JSON structure, with the keys mapping to preference names in the +// "marionette." branch, and the values to the values of those prefs. So +// something like {"port": 4444} would result in the marionette.port +// pref being set to 4444. +const ENV_PRESERVE_PREFS = "MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS"; + +const isRemote = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + +class MarionetteParentProcess { + #browserStartupFinished; + + constructor() { + this.server = null; + this._activePortPath; + + this.classID = Components.ID("{786a1369-dca5-4adc-8486-33d23c88010a}"); + this.helpInfo = " --marionette Enable remote control server.\n"; + + // Initially set the enabled state based on the environment variable. + this.enabled = Services.env.exists(ENV_ENABLED); + + Services.ppmm.addMessageListener("Marionette:IsRunning", this); + + this.#browserStartupFinished = lazy.Deferred(); + } + + /** + * A promise that resolves when the initial application window has been opened. + * + * @returns {Promise} + * Promise that resolves when the initial application window is open. + */ + get browserStartupFinished() { + return this.#browserStartupFinished.promise; + } + + get enabled() { + return this._enabled; + } + + set enabled(value) { + // Return early if Marionette is already marked as being enabled. + // There is also no possibility to disable Marionette once it got enabled. + if (this._enabled || !value) { + return; + } + + this._enabled = value; + lazy.logger.info(`Marionette enabled`); + } + + get running() { + return !!this.server && this.server.alive; + } + + receiveMessage({ name }) { + switch (name) { + case "Marionette:IsRunning": + return this.running; + + default: + lazy.logger.warn("Unknown IPC message to parent process: " + name); + return null; + } + } + + handle(cmdLine) { + // `handle` is called too late in certain cases (eg safe mode, see comment + // above "command-line-startup"). So the marionette command line argument + // will always be processed in `observe`. + // However it still needs to be consumed by the command-line-handler API, + // to avoid issues on macos. + // TODO: remove after Bug 1724251 is fixed. + cmdLine.handleFlag("marionette", false); + } + + async observe(subject, topic) { + if (this.enabled) { + lazy.logger.trace(`Received observer notification ${topic}`); + } + + switch (topic) { + case "profile-after-change": + Services.obs.addObserver(this, "command-line-startup"); + break; + + // In safe mode the command line handlers are getting parsed after the + // safe mode dialog has been closed. To allow Marionette to start + // earlier, use the CLI startup observer notification for + // special-cased handlers, which gets fired before the dialog appears. + case "command-line-startup": + Services.obs.removeObserver(this, topic); + + this.enabled = subject.handleFlag("marionette", false); + + if (this.enabled) { + // Marionette needs to be initialized before any window is shown. + Services.obs.addObserver(this, "final-ui-startup"); + + // We want to suppress the modal dialog that's shown + // when starting up in safe-mode to enable testing. + if (Services.appinfo.inSafeMode) { + Services.obs.addObserver(this, "domwindowopened"); + } + + lazy.RecommendedPreferences.applyPreferences(); + + // Only set preferences to preserve in a new profile + // when Marionette is enabled. + for (let [pref, value] of lazy.EnvironmentPrefs.from( + ENV_PRESERVE_PREFS + )) { + switch (typeof value) { + case "string": + Services.prefs.setStringPref(pref, value); + break; + case "boolean": + Services.prefs.setBoolPref(pref, value); + break; + case "number": + Services.prefs.setIntPref(pref, value); + break; + default: + throw new TypeError(`Invalid preference type: ${typeof value}`); + } + } + } + break; + + case "domwindowopened": + Services.obs.removeObserver(this, topic); + this.suppressSafeModeDialog(subject); + break; + + case "final-ui-startup": + Services.obs.removeObserver(this, topic); + + Services.obs.addObserver(this, "browser-idle-startup-tasks-finished"); + Services.obs.addObserver(this, "mail-idle-startup-tasks-finished"); + Services.obs.addObserver(this, "quit-application"); + + await this.init(); + break; + + // Used to wait until the initial application window has been opened. + case "browser-idle-startup-tasks-finished": + case "mail-idle-startup-tasks-finished": + Services.obs.removeObserver( + this, + "browser-idle-startup-tasks-finished" + ); + Services.obs.removeObserver(this, "mail-idle-startup-tasks-finished"); + this.#browserStartupFinished.resolve(); + break; + + case "quit-application": + Services.obs.removeObserver(this, topic); + await this.uninit(); + break; + } + } + + suppressSafeModeDialog(win) { + win.addEventListener( + "load", + () => { + let dialog = win.document.getElementById("safeModeDialog"); + if (dialog) { + // accept the dialog to start in safe-mode + lazy.logger.trace("Safe mode detected, supressing dialog"); + win.setTimeout(() => { + dialog.getButton("accept").click(); + }); + } + }, + { once: true } + ); + } + + async init() { + if (!this.enabled || this.running) { + lazy.logger.debug( + `Init aborted (enabled=${this.enabled}, running=${this.running})` + ); + return; + } + + try { + this.server = new lazy.TCPListener(lazy.MarionettePrefs.port); + await this.server.start(); + } catch (e) { + lazy.logger.fatal("Marionette server failed to start", e); + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + return; + } + + Services.env.set(ENV_ENABLED, "1"); + Services.obs.notifyObservers(this, NOTIFY_LISTENING, true); + lazy.logger.debug("Marionette is listening"); + + // Write Marionette port to MarionetteActivePort file within the profile. + this._activePortPath = PathUtils.join( + PathUtils.profileDir, + "MarionetteActivePort" + ); + + const data = `${this.server.port}`; + try { + await IOUtils.write(this._activePortPath, lazy.textEncoder.encode(data)); + } catch (e) { + lazy.logger.warn( + `Failed to create ${this._activePortPath} (${e.message})` + ); + } + } + + async uninit() { + if (this.running) { + await this.server.stop(); + Services.obs.notifyObservers(this, NOTIFY_LISTENING); + lazy.logger.debug("Marionette stopped listening"); + + try { + await IOUtils.remove(this._activePortPath); + } catch (e) { + lazy.logger.warn( + `Failed to remove ${this._activePortPath} (${e.message})` + ); + } + } + } + + get QueryInterface() { + return ChromeUtils.generateQI([ + "nsICommandLineHandler", + "nsIMarionette", + "nsIObserver", + ]); + } +} + +class MarionetteContentProcess { + constructor() { + this.classID = Components.ID("{786a1369-dca5-4adc-8486-33d23c88010a}"); + } + + get running() { + let reply = Services.cpmm.sendSyncMessage("Marionette:IsRunning"); + if (!reply.length) { + lazy.logger.warn("No reply from parent process"); + return false; + } + return reply[0]; + } + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIMarionette"]); + } +} + +export var Marionette; +if (isRemote) { + Marionette = new MarionetteContentProcess(); +} else { + Marionette = new MarionetteParentProcess(); +} + +// This is used by the XPCOM codepath which expects a constructor +export const MarionetteFactory = function () { + return Marionette; +}; diff --git a/remote/components/RemoteAgent.sys.mjs b/remote/components/RemoteAgent.sys.mjs new file mode 100644 index 0000000000..c16b8c44b8 --- /dev/null +++ b/remote/components/RemoteAgent.sys.mjs @@ -0,0 +1,517 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CDP: "chrome://remote/content/cdp/CDP.sys.mjs", + Deferred: "chrome://remote/content/shared/Sync.sys.mjs", + HttpServer: "chrome://remote/content/server/httpd.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + WebDriverBiDi: "chrome://remote/content/webdriver-bidi/WebDriverBiDi.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +ChromeUtils.defineLazyGetter(lazy, "activeProtocols", () => { + const protocols = Services.prefs.getIntPref("remote.active-protocols"); + if (protocols < 1 || protocols > 3) { + throw Error(`Invalid remote protocol identifier: ${protocols}`); + } + + return protocols; +}); + +const WEBDRIVER_BIDI_ACTIVE = 0x1; +const CDP_ACTIVE = 0x2; + +const DEFAULT_HOST = "localhost"; +const DEFAULT_PORT = 9222; + +const isRemote = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + +class RemoteAgentParentProcess { + #allowHosts; + #allowOrigins; + #browserStartupFinished; + #classID; + #enabled; + #host; + #port; + #server; + + #cdp; + #webDriverBiDi; + + constructor() { + this.#allowHosts = null; + this.#allowOrigins = null; + this.#browserStartupFinished = lazy.Deferred(); + this.#classID = Components.ID("{8f685a9d-8181-46d6-a71d-869289099c6d}"); + this.#enabled = false; + + // Configuration for httpd.js + this.#host = DEFAULT_HOST; + this.#port = DEFAULT_PORT; + this.#server = null; + + // Supported protocols + this.#cdp = null; + this.#webDriverBiDi = null; + + Services.ppmm.addMessageListener("RemoteAgent:IsRunning", this); + } + + get allowHosts() { + if (this.#allowHosts !== null) { + return this.#allowHosts; + } + + if (this.#server) { + // If the server is bound to a hostname, not an IP address, return it as + // allowed host. + const hostUri = Services.io.newURI(`https://${this.#host}`); + if (!this.#isIPAddress(hostUri)) { + return [RemoteAgent.host]; + } + + // Following Bug 1220810 localhost is guaranteed to resolve to a loopback + // address (127.0.0.1 or ::1) unless network.proxy.allow_hijacking_localhost + // is set to true, which should not be the case. + const loopbackAddresses = ["127.0.0.1", "[::1]"]; + + // If the server is bound to an IP address and this IP address is a localhost + // loopback address, return localhost as allowed host. + if (loopbackAddresses.includes(this.#host)) { + return ["localhost"]; + } + } + + // Otherwise return an empty array. + return []; + } + + get allowOrigins() { + return this.#allowOrigins; + } + + /** + * A promise that resolves when the initial application window has been opened. + * + * @returns {Promise} + * Promise that resolves when the initial application window is open. + */ + get browserStartupFinished() { + return this.#browserStartupFinished.promise; + } + + get cdp() { + return this.#cdp; + } + + get debuggerAddress() { + if (!this.#server) { + return ""; + } + + return `${this.#host}:${this.#port}`; + } + + get enabled() { + return this.#enabled; + } + + get host() { + return this.#host; + } + + get port() { + return this.#port; + } + + get running() { + return !!this.#server && !this.#server.isStopped(); + } + + get scheme() { + return this.#server?.identity.primaryScheme; + } + + get server() { + return this.#server; + } + + get webDriverBiDi() { + return this.#webDriverBiDi; + } + + /** + * Check if the provided URI's host is an IP address. + * + * @param {nsIURI} uri + * The URI to check. + * @returns {boolean} + */ + #isIPAddress(uri) { + try { + // getBaseDomain throws an explicit error if the uri host is an IP address. + Services.eTLD.getBaseDomain(uri); + } catch (e) { + return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS; + } + return false; + } + + handle(cmdLine) { + // remote-debugging-port has to be consumed in nsICommandLineHandler:handle + // to avoid issues on macos. See Marionette.jsm::handle() for more details. + // TODO: remove after Bug 1724251 is fixed. + try { + cmdLine.handleFlagWithParam("remote-debugging-port", false); + } catch (e) { + cmdLine.handleFlag("remote-debugging-port", false); + } + } + + async #listen(port) { + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + throw Components.Exception( + "May only be instantiated in parent process", + Cr.NS_ERROR_LAUNCHED_CHILD_PROCESS + ); + } + + if (this.running) { + return; + } + + // Try to resolve localhost to an IPv4 and / or IPv6 address so that the + // server can be started on a given IP. Only fallback to use localhost if + // the hostname cannot be resolved. + // + // Note: This doesn't force httpd.js to use the dual stack support. + let isIPv4Host = false; + try { + const addresses = await this.#resolveHostname(DEFAULT_HOST); + lazy.logger.trace( + `Available local IP addresses: ${addresses.join(", ")}` + ); + + // Prefer IPv4 over IPv6 addresses. + const addressesIPv4 = addresses.filter(value => !value.includes(":")); + isIPv4Host = !!addressesIPv4.length; + if (isIPv4Host) { + this.#host = addressesIPv4[0]; + } else { + this.#host = addresses.length ? addresses[0] : DEFAULT_HOST; + } + } catch (e) { + this.#host = DEFAULT_HOST; + + lazy.logger.debug( + `Failed to resolve hostname "localhost" to IP address: ${e.message}` + ); + } + + // nsIServerSocket uses -1 for atomic port allocation + if (port === 0) { + port = -1; + } + + try { + // Bug 1783938: httpd.js refuses connections when started on a IPv4 + // address. As workaround start on localhost and add another identity + // for that IP address. + this.#server = new lazy.HttpServer(); + const host = isIPv4Host ? DEFAULT_HOST : this.#host; + this.server._start(port, host); + this.#port = this.server._port; + + if (isIPv4Host) { + this.server.identity.add("http", this.#host, this.#port); + } + + Services.obs.notifyObservers(null, "remote-listening", true); + + await Promise.all([this.#webDriverBiDi?.start(), this.#cdp?.start()]); + } catch (e) { + await this.#stop(); + lazy.logger.error(`Unable to start remote agent: ${e.message}`, e); + } + } + + /** + * Resolves a hostname to one or more IP addresses. + * + * @param {string} hostname + * + * @returns {Array<string>} + */ + #resolveHostname(hostname) { + return new Promise((resolve, reject) => { + let originalRequest; + + const onLookupCompleteListener = { + onLookupComplete(request, record, status) { + if (request === originalRequest) { + if (!Components.isSuccessCode(status)) { + reject({ message: ChromeUtils.getXPCOMErrorName(status) }); + return; + } + + record.QueryInterface(Ci.nsIDNSAddrRecord); + + const addresses = []; + while (record.hasMore()) { + let addr = record.getNextAddrAsString(); + if (addr.includes(":") && !addr.startsWith("[")) { + // Make sure that the IPv6 address is wrapped with brackets. + addr = `[${addr}]`; + } + if (!addresses.includes(addr)) { + // Sometimes there are duplicate records with the same IP. + addresses.push(addr); + } + } + + resolve(addresses); + } + }, + }; + + try { + originalRequest = Services.dns.asyncResolve( + hostname, + Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, + Ci.nsIDNSService.RESOLVE_BYPASS_CACHE, + null, + onLookupCompleteListener, + null, //Services.tm.mainThread, + {} /* defaultOriginAttributes */ + ); + } catch (e) { + reject({ message: e.message }); + } + }); + } + + async #stop() { + if (!this.running) { + return; + } + + // Stop each protocol before stopping the HTTP server. + await this.#cdp?.stop(); + await this.#webDriverBiDi?.stop(); + + try { + await this.#server.stop(); + this.#server = null; + Services.obs.notifyObservers(null, "remote-listening"); + } catch (e) { + // this function must never fail + lazy.logger.error("Unable to stop listener", e); + } + } + + /** + * Handle the --remote-debugging-port command line argument. + * + * @param {nsICommandLine} cmdLine + * Instance of the command line interface. + * + * @returns {boolean} + * Return `true` if the command line argument has been found. + */ + handleRemoteDebuggingPortFlag(cmdLine) { + let enabled = false; + + try { + // Catch cases when the argument, and a port have been specified. + const port = cmdLine.handleFlagWithParam("remote-debugging-port", false); + if (port !== null) { + enabled = true; + + // In case of an invalid port keep the default port + const parsed = Number(port); + if (!isNaN(parsed)) { + this.#port = parsed; + } + } + } catch (e) { + // If no port has been given check for the existence of the argument. + enabled = cmdLine.handleFlag("remote-debugging-port", false); + } + + return enabled; + } + + handleAllowHostsFlag(cmdLine) { + try { + const hosts = cmdLine.handleFlagWithParam("remote-allow-hosts", false); + return hosts.split(","); + } catch (e) { + return null; + } + } + + handleAllowOriginsFlag(cmdLine) { + try { + const origins = cmdLine.handleFlagWithParam( + "remote-allow-origins", + false + ); + return origins.split(","); + } catch (e) { + return null; + } + } + + async observe(subject, topic) { + if (this.#enabled) { + lazy.logger.trace(`Received observer notification ${topic}`); + } + + switch (topic) { + case "profile-after-change": + Services.obs.addObserver(this, "command-line-startup"); + break; + + case "command-line-startup": + Services.obs.removeObserver(this, topic); + + this.#enabled = this.handleRemoteDebuggingPortFlag(subject); + + if (this.#enabled) { + Services.obs.addObserver(this, "final-ui-startup"); + + this.#allowHosts = this.handleAllowHostsFlag(subject); + this.#allowOrigins = this.handleAllowOriginsFlag(subject); + + Services.obs.addObserver(this, "browser-idle-startup-tasks-finished"); + Services.obs.addObserver(this, "mail-idle-startup-tasks-finished"); + Services.obs.addObserver(this, "quit-application"); + + // With Bug 1717899 we will extend the lifetime of the Remote Agent to + // the whole Firefox session, which will be identical to Marionette. For + // now prevent logging if the component is not enabled during startup. + if ( + (lazy.activeProtocols & WEBDRIVER_BIDI_ACTIVE) === + WEBDRIVER_BIDI_ACTIVE + ) { + this.#webDriverBiDi = new lazy.WebDriverBiDi(this); + if (this.#enabled) { + lazy.logger.debug("WebDriver BiDi enabled"); + } + } + + if ((lazy.activeProtocols & CDP_ACTIVE) === CDP_ACTIVE) { + this.#cdp = new lazy.CDP(this); + if (this.#enabled) { + lazy.logger.debug("CDP enabled"); + } + } + } + break; + + case "final-ui-startup": + Services.obs.removeObserver(this, topic); + + try { + await this.#listen(this.#port); + } catch (e) { + throw Error(`Unable to start remote agent: ${e}`); + } + + break; + + // Used to wait until the initial application window has been opened. + case "browser-idle-startup-tasks-finished": + case "mail-idle-startup-tasks-finished": + Services.obs.removeObserver( + this, + "browser-idle-startup-tasks-finished" + ); + Services.obs.removeObserver(this, "mail-idle-startup-tasks-finished"); + this.#browserStartupFinished.resolve(); + break; + + // Listen for application shutdown to also shutdown the Remote Agent + // and a possible running instance of httpd.js. + case "quit-application": + Services.obs.removeObserver(this, topic); + this.#stop(); + break; + } + } + + receiveMessage({ name }) { + switch (name) { + case "RemoteAgent:IsRunning": + return this.running; + + default: + lazy.logger.warn("Unknown IPC message to parent process: " + name); + return null; + } + } + + // XPCOM + + get classID() { + return this.#classID; + } + + get helpInfo() { + return ` --remote-debugging-port [<port>] Start the Firefox Remote Agent, + which is a low-level remote debugging interface used for WebDriver + BiDi and CDP. Defaults to port 9222. + --remote-allow-hosts <hosts> Values of the Host header to allow for incoming requests. + Please read security guidelines at https://firefox-source-docs.mozilla.org/remote/Security.html + --remote-allow-origins <origins> Values of the Origin header to allow for incoming requests. + Please read security guidelines at https://firefox-source-docs.mozilla.org/remote/Security.html\n`; + } + + get QueryInterface() { + return ChromeUtils.generateQI([ + "nsICommandLineHandler", + "nsIObserver", + "nsIRemoteAgent", + ]); + } +} + +class RemoteAgentContentProcess { + #classID; + + constructor() { + this.#classID = Components.ID("{8f685a9d-8181-46d6-a71d-869289099c6d}"); + } + + get running() { + let reply = Services.cpmm.sendSyncMessage("RemoteAgent:IsRunning"); + if (!reply.length) { + lazy.logger.warn("No reply from parent process"); + return false; + } + return reply[0]; + } + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIRemoteAgent"]); + } +} + +export var RemoteAgent; +if (isRemote) { + RemoteAgent = new RemoteAgentContentProcess(); +} else { + RemoteAgent = new RemoteAgentParentProcess(); +} + +// This is used by the XPCOM codepath which expects a constructor +export var RemoteAgentFactory = function () { + return RemoteAgent; +}; diff --git a/remote/components/components.conf b/remote/components/components.conf new file mode 100644 index 0000000000..e518af3dd5 --- /dev/null +++ b/remote/components/components.conf @@ -0,0 +1,29 @@ +# 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/. + +Classes = [ + # Remote Agent + { + "cid": "{8f685a9d-8181-46d6-a71d-869289099c6d}", + "contract_ids": ["@mozilla.org/remote/agent;1"], + "categories": { + "command-line-handler": "m-remote", + "profile-after-change": "RemoteAgent", + }, + 'esModule': "chrome://remote/content/components/RemoteAgent.sys.mjs", + "constructor": "RemoteAgentFactory", + }, + + # Marionette + { + "cid": "{786a1369-dca5-4adc-8486-33d23c88010a}", + "contract_ids": ["@mozilla.org/remote/marionette;1"], + "categories": { + "command-line-handler": "m-marionette", + "profile-after-change": "Marionette", + }, + 'esModule': "chrome://remote/content/components/Marionette.sys.mjs", + "constructor": "MarionetteFactory", + }, +] diff --git a/remote/components/moz.build b/remote/components/moz.build new file mode 100644 index 0000000000..82b4891b6a --- /dev/null +++ b/remote/components/moz.build @@ -0,0 +1,17 @@ +# 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/. + +XPIDL_MODULE = "remote" + +XPIDL_SOURCES += [ + "nsIMarionette.idl", + "nsIRemoteAgent.idl", +] + +XPCOM_MANIFESTS += ["components.conf"] + +with Files("Marionette.sys.mjs"): + BUG_COMPONENT = ("Remote Protocol", "Marionette") +with Files("nsIMarionette.idl"): + BUG_COMPONENT = ("Remote Protocol", "Marionette") diff --git a/remote/components/nsIMarionette.idl b/remote/components/nsIMarionette.idl new file mode 100644 index 0000000000..c10bf4b17b --- /dev/null +++ b/remote/components/nsIMarionette.idl @@ -0,0 +1,17 @@ +/* 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/. */ + +#include "nsISupports.idl" + +%{C++ +#define NS_MARIONETTE_CONTRACTID "@mozilla.org/remote/marionette;1" +%} + +/** Interface for accessing the Marionette server instance. */ +[scriptable, uuid(13fa7d76-f976-4711-a00c-29ac9c1881e1)] +interface nsIMarionette : nsISupports +{ + /** Indicates whether Marionette is running. */ + readonly attribute boolean running; +}; diff --git a/remote/components/nsIRemoteAgent.idl b/remote/components/nsIRemoteAgent.idl new file mode 100644 index 0000000000..89d637bed2 --- /dev/null +++ b/remote/components/nsIRemoteAgent.idl @@ -0,0 +1,36 @@ +/* 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/. */ + +#include "nsISupports.idl" + +/** + * The Gecko remote agent is an RPC subsystem that exposes + * browser-internal interfaces and services to the surrounding + * system. + * + * Consumers, whether remote or browser-local, can interface with + * the browser through an assorted set of services ranging from + * document introspection and script evaluation, to instrumentation, + * user interaction simulation, and event subscription. + */ +[scriptable, uuid(8f685a9d-8181-46d6-a71d-869289099c6d)] +interface nsIRemoteAgent : nsISupports +{ + /** + * Address of the HTTP server under which the remote agent is reachable. + */ + readonly attribute AString debuggerAddress; + + /** + * Indicates whether the Remote Agent is running. + */ + readonly attribute boolean running; +}; + +%{C++ +#define NS_REMOTEAGENT_CONTRACTID "@mozilla.org/remote/agent;1" +#define NS_REMOTEAGENT_CID \ + { 0x8f685a9d, 0x8181, 0x46d6, \ + { 0xa7, 0x1d, x86, x92, x89, x09, x9c, x6d } } +%} diff --git a/remote/doc/Building.md b/remote/doc/Building.md new file mode 100644 index 0000000000..b8ddf5db8d --- /dev/null +++ b/remote/doc/Building.md @@ -0,0 +1,57 @@ +# Building + +The Remote Agent is included in the default Firefox build, but only +ships on the Firefox Nightly release channel: + +```shell +% ./mach run --remote-debugging-port +``` + +The source code can be found under [remote/ in central]. + +There are two build modes to choose from: + +## Full build mode + +The Remote Agent is included when you build in the usual way: + +```shell +% ./mach build +``` + +When you make changes to XPCOM component files you need to rebuild +in order for the changes to take effect. The most efficient way to +do this, provided you haven’t touched any compiled code (C++ or Rust): + +```shell +% ./mach build faster +``` + +Component files include the likes of components.conf, +RemoteAgent.manifest, moz.build files, and jar.mn. +All the JS modules (files ending with `.jsm`) are symlinked into +the build and can be changed without rebuilding. + +You may also opt out of building all the WebDriver specific components +([Marionette], and the Remote Agent) by setting the following flag in +your [mozconfig]: + +```make +ac_add_options --disable-webdriver +``` + +## Artifact mode + +You may also use [artifact builds] when working on the Remote Agent. +This fast build mode downloads pre-built components from the Mozilla +build servers, rendering local compilation unnecessary. To use +them, place this in your [mozconfig]: + +```make +ac_add_options --enable-artifact-builds +``` + +[remote/ in central]: https://searchfox.org/mozilla-central/source/remote +[mozconfig]: /build/buildsystem/mozconfigs.rst +[artifact builds]: /contributing/build/artifact_builds.rst +[Marionette]: /testing/marionette/index.rst diff --git a/remote/doc/CodeStyle.md b/remote/doc/CodeStyle.md new file mode 100644 index 0000000000..5b907e1599 --- /dev/null +++ b/remote/doc/CodeStyle.md @@ -0,0 +1,72 @@ +# Style guide + +Like other projects, we also have some guidelines to keep to the code. +For the overall Remote Agent project, a few rough rules are: + +* Make your code readable and sensible, and don’t try to be clever. + Prefer simple and easy solutions over more convoluted and foreign syntax. + +* Fixing style violations whilst working on a real change as a preparatory + clean-up step is good, but otherwise avoid useless code churn for the sake + of conforming to the style guide. + +* Code is mutable and not written in stone. Nothing that is checked in is + sacred and we encourage change to make this a pleasant ecosystem to work in. + +* We never land any code that is unnecessary or unused. + +## Documentation + +We keep our documentation (what you are reading right now!) in-tree +under [remote/doc]. Updates and minor changes to documentation should +ideally not be scrutinized to the same degree as code changes to +encourage frequent updates so that documentation does not go stale. +To that end, documentation changes with `r=me a=doc` from anyone +with commit access level 3 are permitted. + +Use fmt(1) or an equivalent editor specific mechanism (such as +`Meta-Q` in Emacs) to format paragraphs at a maximum of +75 columns with a goal of roughly 65. This is equivalent to `fmt +-w75 -g65`, which happens to the default on BSD and macOS. + +The documentation can be built locally this way: + +```shell +% ./mach doc remote +``` + +[remote/doc]: https://searchfox.org/mozilla-central/source/remote/doc + +## Linting + +The Remote Agent consists mostly of JavaScript code, and we lint that +using [mozlint], which is harmonizes different linters including [eslint]. + +To run the linter and get sensible output for modified files: + +```shell +% ./mach lint --outgoing --warning +``` + +For certain classes of style violations, eslint has an automatic +mode for fixing and formatting code. This is particularly useful +to keep to whitespace and indentation rules: + +```shell +% ./mach lint --outgoing --warning --fix +``` + +The linter is also run as a try job (shorthand `ES`) which means +any style violations will automatically block a patch from landing +(if using autoland) or cause your changeset to be backed out (if +landing directly on inbound). + +If you use git(1) you can [enable automatic linting] before +you push to a remote through a pre-push (or pre-commit) hook. +This will run the linters on the changed files before a push and +abort if there are any problems. This is convenient for avoiding +a try run failing due to a simple linting issue. + +[mozlint]: /code-quality/lint/mozlint.rst +[eslint]: /code-quality/lint/linters/eslint.rst +[enable automatic linting]: /code-quality/lint/usage.rst#using-a-vcs-hook diff --git a/remote/doc/Debugging.md b/remote/doc/Debugging.md new file mode 100644 index 0000000000..2cf03fcfca --- /dev/null +++ b/remote/doc/Debugging.md @@ -0,0 +1,54 @@ +# Debugging + +For other debugging resources, see also: Remote project [wiki] + +## Increasing the logging verbosity + +To increase the internal logging verbosity you can use the +`remote.log.level` [preference]. + +If you use mach to start Firefox: + +```shell +% ./mach run --setpref "remote.log.level=Trace" --remote-debugging-port +``` + +By default, long log lines are truncated. To print long lines in full, you +can set `remote.log.truncate` to false. + +## Enabling logging of emitted events + +To dump events produced by EventEmitter, +including CDP events produced by the Remote Agent, +you can use the `toolkit.dump.emit` [preference]: + +```shell +% ./mach run --setpref "toolkit.dump.emit=true" --remote-debugging-port +``` + +## Logging observer notifications + +Observer notifications are used extensively throughout the +code and it can sometimes be useful to log these to see what is +available and when they are fired. + +The `MOZ_LOG` environment variable controls the C++ logs and takes +the name of the subsystem along with a verbosity setting. See +[prlog.h] for more details. + +```shell +MOZ_LOG=ObserverService:5 +``` + +You can optionally redirect logs away from stdout to a file: + +```shell +MOZ_LOG_FILE=service.log +``` + +This enables `LogLevel::Debug` level information and places all +output in the file service.log in your current working directory. + +[preference]: Prefs.md +[prlog.h]: https://searchfox.org/mozilla-central/source/nsprpub/pr/include/prlog.h +[wiki]: https://wiki.mozilla.org/Remote/Developer_Resources diff --git a/remote/doc/Prefs.md b/remote/doc/Prefs.md new file mode 100644 index 0000000000..967f0fb136 --- /dev/null +++ b/remote/doc/Prefs.md @@ -0,0 +1,40 @@ +# Preferences + +There are a couple of preferences associated with the Remote Agent: + +## Configurable preferences + +### `remote.active-protocols` + +Defines the remote protocols that are active. Available protocols are, +WebDriver BiDi (`1`), and CDP (`2`). Multiple protocols can be activated +at the same time by using bitwise or with the values. Defaults to `3` (WebDriver +BiDi and CDP). + +### `remote.experimental.enabled` + +Defines if WebDriver BiDi experimental commands and events are available for usage. +Defaults to `true` in Nightly builds, and `false` otherwise. + +### `remote.log.level` + +Defines the verbosity of the internal logger. Available levels +are, in descending order of severity, `Trace`, `Debug`, `Config`, +`Info`, `Warn`, `Error`, and `Fatal`. Note that the value is +treated case-sensitively. + +### `remote.log.truncate` + +Defines whether long log messages should be truncated. Defaults to true. + +### `remote.prefs.recommended` + +By default remote protocols attempts to set a range of preferences deemed +suitable in automation when it starts. These include the likes of +disabling auto-updates, Telemetry, and first-run UX. Set this preference to +`false` to skip setting those preferences, which is mostly useful for internal +Firefox CI suites. + +The user preference file takes precedence over the recommended +preferences, meaning any user-defined preference value will not be +overridden. diff --git a/remote/doc/PuppeteerVendor.md b/remote/doc/PuppeteerVendor.md new file mode 100644 index 0000000000..e426b46ea1 --- /dev/null +++ b/remote/doc/PuppeteerVendor.md @@ -0,0 +1,108 @@ +# Vendoring Puppeteer + +As mentioned in the chapter on [Testing], we run the full [Puppeteer +test suite] on try. These tests are vendored in central under +_remote/test/puppeteer/_ and we have a script to pull in upstream changes. + +We periodically perform a manual two-way sync. Below is an outline of the +process interspersed with some tips. + +## Check for not-yet upstreamed changes + +Before vendoring a new Puppeteer release make sure that there are no Puppeteer +specific changes in mozilla-central repository that haven't been upstreamed yet +since the last vendor happened. Run one of the following commands and check the +listed bugs or related upstream code to verify: + +```shell +% hg log remote/test/puppeteer +% git log remote/test/puppeteer +``` + +If an upstream pull request is needed please see their [contributing.md]. +Typically, the changes we push to Puppeteer include unskipping newly passing +unit tests for Firefox along with minor fixes to the tests or +to Firefox-specific browser-fetching and launch code. + +Be sure to [run tests against both Chromium and Firefox] in the Puppeteer +repo. You can specify your local Firefox build when you do so: + +```shell +% BINARY=<path-to-objdir-binary> npm run test:firefox +% BINARY=<path-to-objdir-binary> npm run test:firefox:bidi +``` + +## Prepare the Puppeteer Repository + +Clone the Puppeteer git repository and checkout the release tag you want +to vendor into mozilla-central. + +```shell +% git checkout tags/puppeteer-%version% +``` + +You might want to [install the project] at this point and make sure unit tests pass. +Check the project's `package.json` for relevant testing commands. + +## Update the Puppeteer code in mozilla-central + +You can run the following mach command to copy over the Puppeteer branch you +just prepared. The mach command has flags to specify a local or remote +repository as well as a commit: + +```shell +% ./mach remote vendor-puppeteer --commitish puppeteer-%version% [--repository %path%] +``` + +By default, this command also installs the newly-pulled Puppeteer package in +order to generate a new `package-lock.json` file for the purpose of pinning +Puppeteer dependencies for our CI. There is a `--no-install` option if you want +to skip this step; for example, if you want to run installation separately at +a later point. + +Validate that newly created files and folders are required to be tracked by +version control. If that is not the case then update both the top-level +`.hgignore` and `remote/.gitignore` files for those paths. + +### Validate that the new code works + +Use `./mach puppeteer-test` (see [Testing]) to run Puppeteer tests against both +Chromium and Firefox in headless mode. Again, only running a subset of tests +against Firefox is fine -- at this point you just want to check that the +typescript compiles and the browser binaries are launched successfully. + +If something at this stage fails, you might want to check changes in +`remote/test/puppeteer/package.json` and update `remote/mach_commands.py` +with new npm scripts. + +### Verify the expectation meta data + +Next, you want to make sure that the expectation meta data is correct. Check +changes in [TestExpectations.json]. If there are +newly skipped tests for Firefox, you might need to update these expectations. +To do this, run the Puppeteer test job on try (see [Testing]). If these tests +are specific for Chrome or time out, we want to keep them skipped, if they fail +we want to have `FAIL` status for all platforms in the expectation meta data. +You can see, if the meta data needs to be updated, at the end of the log file. + +Examine the job logs and make sure the run didn't get interrupted early by a +crash or a hang, especially if you see a lot of `TEST-UNEXPECTED-MISSING` in +the Treeherder Failure Summary. You might have to fix some new bug in the unit +tests. This is the fun part. + +Some tests can also unexpectedly pass. Make sure it's correct, and if needed +update the expectation data by following the instructions at the end of the +log file. + +### Submit the code changes + +Once you are happy with the metadata and are ready to submit the sync patch +up for review, run the Puppeteer test job on try again with `--rebuild 10` +to check for stability. + +[Testing]: Testing.md +[Puppeteer test suite]: https://github.com/GoogleChrome/puppeteer/tree/master/test +[install the project]: https://github.com/puppeteer/puppeteer/blob/main/docs/contributing.md#getting-started +[run tests against both Chromium and Firefox]: https://github.com/puppeteer/puppeteer/blob/main/test/README.md#running-tests +[TestExpectations.json]: https://searchfox.org/mozilla-central/source/remote/test/puppeteer/test/TestExpectations.json +[contributing.md]: https://github.com/puppeteer/puppeteer/blob/main/docs/contributing.md diff --git a/remote/doc/Security.md b/remote/doc/Security.md new file mode 100644 index 0000000000..848a63fd45 --- /dev/null +++ b/remote/doc/Security.md @@ -0,0 +1,112 @@ +# Security aspects of the Remote Agent + +The Remote Agent is not a web-facing feature and as such has different +security characteristics than traditional web platform APIs. The +primary consumers are out-of-process programs that connect to the +agent via a remote protocol, but can theoretically be extended to +facilitate browser-local clients communicating over IPDL. + +## Design considerations + +The Remote Agent allows consumers to interface with Firefox through +an assorted set of domains for inspecting the state and controlling +execution of documents running in web content, injecting arbitrary +scripts to documents, do browser service instrumentation, simulation +of user interaction for automation purposes, and for subscribing +to updates in the browser such as network- and console logs. + +The remote interfaces are served over an HTTP wire protocol, by a +server listener hosted in the Firefox binary. This can only be +started by passing the `--remote-debugging-port` +flag. Connections are restricted to loopback devices +(such as localhost and 127.0.0.1). + +Since the Remote Agent is not an in-document web feature, the +security concerns we have for this feature are essentially different +to other web platform features. The primary concern is that the +HTTPD is not spun up without passing one of the command-line flags. +It is out perception that if a malicious user has the capability +to execute arbitrary shell commands, there is little we can do to +prevent the browser being turned into an evil listening device. + +## User privacy concerns + +There are no user privacy concerns beyond the fact that the offered +interfaces will give the client access to all browser internals, +and thereby follows all browser-internal secrets. + +## How the Remote Agent works + +When the `--remote-debugging-port` flag is used, +it spins up an HTTPD on the desired port, or defaults to +localhost:9222. The HTTPD serves WebSocket connections via +`nsIWebSocket.createServerWebSocket` that clients connect to in +order to give the agent remote instructions. Hereby the HTTPD only +accepts system-local loopback connections from clients: + +```javascript +if (!LOOPBACKS.includes(host)) { + throw new Error("Restricted to loopback devices"); +} +``` + +The Remote Agent implements a large subset of the Chrome DevTools +Protocol (CDP). This protocol allows a client to: + +* take control over the user session for automation purposes, for + example to simulate user interaction such as clicking and typing; + +* instrument the browser for analytical reasons, such as intercepting + network traffic; + +* and extract information from the user session, including cookies + and local storage. + +There are no web-exposed features in the Remote Agent whatsoever. + +## Security model + +It shares the same security model as DevTools and Marionette, in +that there is no other mechanism for enabling the Remote Agent than +by passing a command-line flag. + +It is our assumption that if an attacker has shell access to the +user account, there is little we can do to prevent secrets from +being accessed or leaked. + +The Remote Agent is available on all release channels. + +## Remote Hosts and Origins + +By default RemoteAgent only accepts connections with no `Origin` header and a +`Host` header set to an IP address or a localhost loopback address. + +Other `Host` or `Origin` headers can be allowed by starting Firefox with the +`--remote-allow-origins` and `--remote-allow-hosts` arguments: + +* `--remote-allow-hosts` expects a comma separated list of hostnames + +* `--remote-allow-origins` expects a comma separated list of origins + +Note: Users are strongly discouraged from using the Remote Agent in a way that +allows it to be accessed by untrusted hosts e.g. by binding it to a publicly +routeable interface. + +The Remote Agent does not provide message encryption, which means that all +protocol messages are subject to eavesdropping and tampering. It also does not +provide any authentication system. This is acceptable in an isolated test +environment, but not to be used on an untrusted network such as the internet. +People wishing to provide remote access to Firefox sessions via the Remote Agent +must provide their own encryption, authentication, and authorization. + +## Security reviews + +More details can be found in the security reviews conducted for Remote Agent and +WebDriver BiDi: + +* [Remote Agent security review] (November 2019) + +* [WebDriver BiDi security review] (April 2022) + +[Remote Agent security review]: https://bugzilla.mozilla.org/show_bug.cgi?id=1542229 +[WebDriver BiDi security review]: https://bugzilla.mozilla.org/show_bug.cgi?id=1753997 diff --git a/remote/doc/Testing.md b/remote/doc/Testing.md new file mode 100644 index 0000000000..d0db9dc159 --- /dev/null +++ b/remote/doc/Testing.md @@ -0,0 +1,181 @@ +# Testing + +The Remote Protocol has unit- and functional tests located under different folders: + +* CDP: `remote/cdp/` +* Marionette: `remote/marionette/`. +* Shared Modules: `remote/shared/` +* WebDriver BiDi: `remote/webdriver-bidi/` + +You may want to run all the tests under a particular subfolder locally like this: + +```shell +% ./mach test remote +``` + +## Unit tests + +Because tests are run in parallel and [xpcshell] itself is quite +chatty, it can sometimes be useful to run the tests in sequence: + +```shell +% ./mach xpcshell-test --sequential remote/cdp/test/unit/test_DomainCache.js +``` + +The unit tests will appear as part of the `X` (for _xpcshell_) jobs +on Treeherder. + +[xpcshell]: /testing/xpcshell/index.rst + +## Browser Chrome Mochitests + +We also have a set of functional browser-chrome mochitests located +under several components, ie. _remote/shared/messagehandler/test/browser_: + +```shell +% ./mach mochitest remote/shared/messagehandler/test/browser/browser_* +``` + +The functional tests will appear under the `M` (for _mochitest_) +category in the `remote` jobs on Treeherder. + +As the functional tests will sporadically pop up new Firefox +application windows, a helpful tip is to run them in headless +mode: + +```shell +% ./mach mochitest --headless remote/shared/messagehandler/test/browser +``` + +The `--headless` flag is equivalent to setting the `MOZ_HEADLESS` +environment variable. You can additionally use `MOZ_HEADLESS_WIDTH` +and `MOZ_HEADLESS_HEIGHT` to control the dimensions of the virtual +display. + +The `add_task()` function used for writing asynchronous tests is +replaced to provide some additional test setup and teardown useful +for writing tests against the Remote Agent and the targets. + +There are also specific browser-chrome tests for CDP. + +Before such a task is run, the `nsIRemoteAgent` listener is started +and a [CDP client] is connected. You will use this CDP client for +interacting with the agent just as any other CDP client would. + +Also target discovery is getting enabled, which means that targetCreated, +targetDestroyed, and targetInfoChanged events will be received by the client. + +The task function you provide in your test will be called with the +three arguments `client`, `CDP`, and `tab`: + +* `client` is the connection to the `nsIRemoteAgent` listener, + and it provides the a client CDP API + +* `CDP` is the CDP client class + +* `tab` is a fresh tab opened for each new test, and is automatically + removed after the test has run + +This is what it looks like all put together: + +```javascript +add_task(async function testName({client, CDP, tab}) { + // test tab is implicitly created for us + info("Current URL: " + tab.linkedBrowser.currentURI.spec); + + // manually connect to a specific target + const { mainProcessTarget } = RemoteAgent.cdp.targetList; + const target = mainProcessTarget.wsDebuggerURL; + const client = await CDP({ target }); + + // retrieve the Browser domain, and call getVersion() on it + const { Browser } = client; + const version = await Browser.getVersion(); + + await client.close(); + + // tab is implicitly removed +}); +``` + +You can control the tab creation behavior with the `createTab` +option to `add_task(taskFunction, options)`: + +```javascript +add_task(async function testName({client}) { + // tab is not implicitly created +}, { createTab: false }); +``` + +If you want to write an asynchronous test _without_ this implicit +setup you may instead use `add_plain_task()`, which works exactly like the +original `add_task()`. + +[CDP client]: https://github.com/cyrus-and/chrome-remote-interface + +## Puppeteer tests + +In addition to our own Firefox-specific tests, we run the upstream +[Puppeteer test suite] against our implementation to [track progress] +towards achieving full [Puppeteer support] in Firefox. The tests are written +in the behavior-driven testing framework [Mocha]. + +Puppeteer tests are vendored under _remote/test/puppeteer/_ and are +run locally like this: + +```shell +% ./mach puppeteer-test +``` + +You can also run them against Chrome as: + +```shell +% ./mach puppeteer-test --product=chrome +``` + +By default, Puppeteer will be configured to use the WebDriver BiDi protocol. You +can also force Puppeteer to use the CDP protocol with the `--cdp` option: + +```shell +% ./mach puppeteer-test --cdp +``` + +By default the mach command will automatically install Puppeteer but that's +only needed for the very first time, or when a new Puppeteer release has been +vendored in. To skip the install step use the `--no-install` option. + +To run only some specific tests from the whole test suite the appropriate +test files have to be updated first. To select specific tests or test +groups within a file define [exclusive tests] by adding the `.only` suffix +like `it.only()` or `describe.only()`. + +More customizations for [Mocha] can be found in its own documentation. + +Test expectation metadata is collected in _remote/test/puppeteer-expected.json_ +via log parsing and a custom Mocha reporter under +_remote/test/puppeteer/json-mocha-reporter.js_ + +Check the upstream [Puppeteer test suite] documentation for instructions on +how to skip tests, run only one test or a subsuite of tests. + +## Testing on Try + +To schedule all the Remote Protocol tests on try, you can use the +`remote-protocol` [try preset]: + +```shell +% ./mach try --preset remote-protocol +``` + +But you can also schedule tests by selecting relevant jobs yourself: + +```shell +% ./mach try fuzzy +``` + +[Puppeteer test suite]: https://github.com/puppeteer/puppeteer/blob/master/test/README.md +[Puppeteer support]: https://bugzilla.mozilla.org/show_bug.cgi?id=puppeteer +[Mocha]: https://mochajs.org/ +[exclusive tests]: https://mochajs.org/#exclusive-tests +[track progress]: https://puppeteer.github.io/ispuppeteerfirefoxready/ +[try preset]: /tools/try/presets diff --git a/remote/doc/cdp/Architecture.md b/remote/doc/cdp/Architecture.md new file mode 100644 index 0000000000..bda4956f84 --- /dev/null +++ b/remote/doc/cdp/Architecture.md @@ -0,0 +1,159 @@ +# Remote Agent overall architecture + +This document will cover the Remote Agent architecture by following the sequence of steps needed to start the agent, connect a client and debug a target. + +## Remote Agent startup + +Everything starts with the `RemoteAgent` implementation, which handles command line +arguments (--remote-debugging-port) to eventually +start a server listening on the TCP port 9222 (or the one specified by the command line). +The browser target websocket URL will be printed to stderr. +To do that this component glue together three main high level components: + +* `server/HTTPD` + This is a copy of httpd.js, from the `/netwerk/` folder. This is a JS + implementation of an HTTP server. This will be used to implement the various + HTTP endpoints of CDP. There is a few static URL implemented by `JSONHandler` + and one dynamic URL per target. + +* `cdp/JSONHandler` + This implements the following three static HTTP endpoints: + * `/json/version`: + Returns information about the runtime as well as the url of the browser target websocket url. + * `/json/list`: + Returns a list of all debuggable targets with, for each, their dynamic websocket URL. + For now it only reports tabs, but will report workers and addons as soon as we + support them. The main browser target is the only one target not listed here. + * `/json/protocol`: + Returns a big dictionary describing the supported protocol. + This is currently hard coded and returns the full CDP protocol schema, including APIs we don’t support. + We have a future intention to fix this and report only what Firefox implements. + You can connect to these websocket URL in order to debug things. + + * `cdp/targets/TargetList`: + This component is responsible of maintaining the list of all debuggable targets. + For now it can be either: + * The main browser target + A special target which allows to inspect the browser, but not any particular tab. + This is implemented by `cdp/targets/MainProcessTarget` and is instantiated on startup. + * Tab targets + Each opened tab will have a related `cdp/targets/TabTarget` instantiated on their opening, + or on server startup for already opened ones. + Each target aims at focusing on one particular context. This context is typically running in one + particular environment. This can be a particular process or thread. + In the future, we will most likely support targets for workers and add-ons. + All targets inherit from `cdp/targets/Target`. + +## Connecting to Websocket endpoints + +Each target's websocket URL will be registered as a HTTP endpoint via `server/HTTPD:registerPathHandler` (This registration is done from `RemoteAgentParentProcess:#listen`). +Once a HTTP request happens, `server/HTTPD` will call the `handle` method on the object passed to `registerPathHandler`. +For static endpoints registered by `JSONHandler`, this will call `JSONHandler:handle` and return a JSON string as HTTP body. +For target's endpoint, it is slightly more complicated as it requires a special handshake to morph the HTTP connection into a WebSocket one. +The WebSocket is then going to be long lived and be used to inspect the target over time. +When a request is made to a target URL, `cdp/targets/Target:handle` is called and: + +* delegate the complex HTTP to WebSocket handshake operation to `server/WebSocketHandshake:upgrade` + In return we retrieve a WebSocket object. + +* hand over this WebSocket to `server/WebSocketTransport` and get a transport + object in return. The transport implements a basic JSON stream over WebSocket. + With that, you can send and receive JSON objects over a WebSocket connection. + +* hand over the transport to a freshly instantiated `Connection`. The Connection has two goals: + * Interpret incoming CDP packets by reading the JSON object attribute (`id`, `method`, `params` and `sessionId`). This is done in `Connection:onPacket`. + * Format outgoing CDP packets by writing the right JSON object for command response (`id`, `result` and `sessionId`) and events (`method`, `params` and `sessionId`). + * Redirect CDP packet from/to the right session. + A connection may have more than one session attached to it. + + * Instantiate the default session. + The session is specific to each target kind and all of them inherit from `cdp/session/Session`. + For example, tabs targets uses `cdp/session/TabSession` and the main browser target uses `cdp/session/MainProcessSession`. + Which session class is used is defined by the Target subclass’ constructor, which pass a session class reference to `cdp/targets/Target:constructor`. + A session is mostly responsible of accommodating the eventual cross process/cross thread aspects of the target. + The code we are currently describing (`cdp/targets/Target:handle`) is running in the parent process. + The session class receive CDP commands from the connection and first try to execute the Domain commands in the parent process. + Then, if the target actually runs in some other context, the session tries to forward this command to this other context, which can be a thread or a process. + Typically, the `cdp/sessions/TabSession` forward the CDP command to the content process where the tab is running. + It also redirects back the command response as well as Domain events from that process back to the parent process in order to + forward them to the connection. + Sessions will be using the `DomainCache` class as a helper to manage a list of Domain implementations in a given context. + +## Debugging additional Targets + +From a given connection you can know about the other potential targets. +You typically do that via `Target.setDiscoverTargets()`, which will emit `Target.targetCreated` events providing a target ID. +You may create a new session for the new target by handing the ID to `Target.attachToTarget()`, which will return a session ID. +"Target" here is a reference to the CDP Domain implemented in `cdp/domains/parent/Target.jsm`. That is different from `cdp/targets/Target` +class which is an implementation detail of the Remote Agent. + +Then, there is two ways to communicate with the other targets: + +* Use `Target.sendMessageToTarget()` and `Target.receivedMessageFromTarget` + You will manually send commands via the `Target.sendMessageToTarget()` command and receive command's response as well as events via `Target.receivedMessageFromTarget`. + In both cases, a session ID attribute is passed in the command or event arguments in order to select which additional target you are communicating with. + +* Use `Target.attachToTarget({ flatten: true })` and include `sessionId` in CDP packets + This requires a special client, which will use the `sessionId` returned by `Target.attachToTarget()` in order to spawn a distinct client instance. + This client will reuse the same WebSocket connection, but every single CDP packet will contain an additional `sessionId` attribute. + This helps distinguish packets which relate to the original target as well as the multiple additional targets you may attach to. + +In both cases, `Target.attachToTarget()` is special as it will spawn `cdp/session/TabSession` for the tab you are attaching to. +This is the code path creating non-default session. The default session is related to the target you originally connected to, +so that you don't need any ID for this one. When you want to debug more than one target over a single connection +you need additional sessions, which will have a unique ID. +`Target.attachToTarget` will compute this ID and instantiate a new session bound to the given target. +This additional session will be managed by the `Connection` class, which will then redirect CDP packets to the +right session when you are using flatten session. + +## Cross Process / Layers + +Because targets may runs in different contexts, the Remote Agent code runs in different processes. +The main and startup code of the Remote Agent code runs in the parent process. +The handling of the command line as well as all the HTTP and WebSocket work is all done in the parent process. +The browser target is also all implemented in the parent process. +But when it comes to a tab target, as the tab runs in the content process, we have to run code there as well. +Let's start from the `cdp/sessions/TabSession` class, which has already been described. +We receive here JSON packets from the WebSocket connection and we are in the parent process. +In this class, we route the messages to the parent process domains first. +If there is no implementation of the domain or the particular method, +we forward the command to a `cdp/session/ContentProcessSession` which runs in the tab's content process. +These two Session classes will interact with each other in order to forward back the returned value +of the method we just called, as well as piping back any event being sent by a Domain implemented in any +of the two processes. + +## Organizational chart of all the classes + +```text + ┌─────────────────────────────────────────────────┐ + │ │ + 1 ▼ │ + ┌───────────────┐ 1 ┌───────────────┐ 1..n┌───────────────┐ + │ RemoteAgent │──────▶│ HttpServer │◀───────▶│ JsonHandler │ + └───────────────┘ └───────────────┘ 1 └───────────────┘ + │ + │ + │ 1 ┌────────────────┐ 1 + └───────────────▶│ TargetList │◀─┐ + └────────────────┘ │ + │ │ + ▼ 1..n │ + ┌────────────┐ │ + ┌─────────────────│ Target [1]│ │ + │ └────────────┘ │ + │ ▲ 1 │ + ▼ 1..n │ │ + ┌────────────┐ 1..n┌────────────┐ │ + │ Connection │◀─────────▶│ Session [2]│──────┘ + └────────────┘ 1 └────────────┘ + │ 1 ▲ + │ │ + ▼ 1 ▼ 1 +┌────────────────────┐ ┌──────────────┐ 1..n┌────────────┐ +│ WebSocketTransport │ │ DomainCache | │──────────▶│ Domain [3]│ +└────────────────────┘ └──────────────┘ └────────────┘ +``` + +[1] Target is inherited by TabTarget and MainProcessTarget. +[2] Session is inherited by TabSession and MainProcessSession. +[3] Domain is inherited by Log, Page, Browser, Target.... i.e. all domain implementations. From both cdp/domains/parent and cdp/domains/content folders. diff --git a/remote/doc/cdp/RequiredPreferences.md b/remote/doc/cdp/RequiredPreferences.md new file mode 100644 index 0000000000..a6e2c9a119 --- /dev/null +++ b/remote/doc/cdp/RequiredPreferences.md @@ -0,0 +1,13 @@ +# Required Preferences for Fission + +Fission (site isolation for Firefox) introduced some architectural changes that are incompatible with our CDP implementation. To keep using CDP for Firefox, make sure the following preferences are set in the profile before starting Firefox with `--remote-debugging-port`: + +* `fission.bfcacheInParent` should be set to `false`. + +* `fission.webContentIsolationStrategy` should be set to `0`. + +Without those preferences, expect issues related to navigation in several domains (Page, Runtime, ...). + +Third party tools relying on CDP such as Puppeteer ensure that those preferences are correctly set before starting Firefox. + +The work to lift those restrictions is tracked in [Bug 1732263](https://bugzilla.mozilla.org/show_bug.cgi?id=1732263) and [Bug 1706353](https://bugzilla.mozilla.org/show_bug.cgi?id=1706353). diff --git a/remote/doc/cdp/Usage.md b/remote/doc/cdp/Usage.md new file mode 100644 index 0000000000..9eb62929fb --- /dev/null +++ b/remote/doc/cdp/Usage.md @@ -0,0 +1,65 @@ +# Usage + +When using the CDP-based Remote Agent in Firefox, there are +three different programs/components running simultaneously: + +* the __client__, being the out-of-process script or library + (such as Puppeteer) or web inspector frontend you use to control + and retrieve information out of Firefox; + +* the __agent__ that the client connects to which is an HTTPD living + inside Firefox, facilitating communication between clients + and targets; + +* and the __target__, which is the web document being debugging. + +Since Firefox 86 the Remote Agent ships in all Firefox releases by default. + +To check if your Firefox binary has the Remote Agent enabled, you +can look in its help message for this: + +```shell +% ./firefox -h +… + --remote-debugging-port [<port>] Start the Firefox Remote Agent, which is + a low-level debugging interface based on the CDP protocol. + Defaults to listen on localhost:9222. +… +``` + +When used, the Remote Agent will start an HTTP server and print a +message on stderr with the location of the main target’s WebSocket +listener: + +```shell +% firefox --remote-debugging-port +DevTools listening on ws://localhost:9222/devtools/browser/7b4e84a4-597f-4839-ac6d-c9e86d16fb83 +``` + +The argument takes an optional `port` as value: + +You can also instruct the Remote Agent to bind to a particular port on +your system. Therefore the argument accepts an optional value, which means +that `firefox --remote-debugging-port=9989` +will bind the HTTPD to port `9989`: + +```shell +% firefox --remote-debugging-port 9989 +DevTools listening on ws://localhost:9989/devtools/browser/b49481af-8ad3-9b4d-b1bf-bb0cdb9a0620 +``` + +If the value is missing the default port `9222` will be used. + +When you ask the Remote Agent to listen on port 0, +the system will atomically allocate an arbitrary free port: + +```shell +% firefox --remote-debugging-port 0 +DevTools listening on ws://localhost:59982/devtools/browser/a12b22a9-1b8b-954a-b81f-bd31552d3f1c +``` + +Allocating an atomic port can be useful if you want to avoid race +conditions. The atomically allocated port will be somewhere in the +ephemeral port range, which varies depending on your system and +system configuration, but is always guaranteed to be free thus +eliminating the risk of binding to a port that is already in use. diff --git a/remote/doc/cdp/index.rst b/remote/doc/cdp/index.rst new file mode 100644 index 0000000000..82b6fe1179 --- /dev/null +++ b/remote/doc/cdp/index.rst @@ -0,0 +1,26 @@ +===================== +Remote Protocol (CDP) +===================== + +The Firefox **remote protocol (CDP)** is a low-level debugging interface +you can use to inspect the state and control execution of documents +running in web content, instrument the browser in interesting ways, +simulate user interaction for automation purposes, and for subscribing +to updates in the browser such as network- or console logs. + +It complements the existing Firefox Developer Tools :ref:`Remote Debugging +Protocol <Remote Debugging Protocol>` (RDP) by implementing a subset of the +`Chrome DevTools Protocol`_ (CDP). + +To use Firefox remote protocol with Fission, CDP client authors should read the +`Required Preferences`_ page. + +.. _Chrome DevTools Protocol: https://chromedevtools.github.io/devtools-protocol/ +.. _Required Preferences: /remote/cdp/RequiredPreferences.html + +.. toctree:: + :maxdepth: 1 + + Usage.md + Architecture.md + RequiredPreferences.md diff --git a/remote/doc/index.rst b/remote/doc/index.rst new file mode 100644 index 0000000000..eed38d3e90 --- /dev/null +++ b/remote/doc/index.rst @@ -0,0 +1,92 @@ +================ +Remote Protocols +================ + +Firefox supports several remote protocols, which allow to inspect and control +the browser, usually for automation purposes: + +* :ref:`marionette-header` +* :ref:`remote-protocol-cdp-header` +* :ref:`webdriver-bidi-header` + +Common documentation +==================== + +The following documentation pages apply to all remote protocols + +.. toctree:: + :maxdepth: 1 + + Building.md + Debugging.md + Prefs.md + Testing.md + CodeStyle.md + Security.md + PuppeteerVendor.md + +Protocols +========= + +.. _marionette-header: + +Marionette +---------- + +Marionette is used both by internal tools and testing solutions, but also by +geckodriver to implement the `WebDriver (HTTP) specification`_. The documentation +for Marionette can be found under `testing/marionette`_. + +.. _WebDriver (HTTP) specification: https://w3c.github.io/webdriver/ +.. _testing/marionette: /testing/marionette + + +.. _remote-protocol-cdp-header: + +Remote Protocol (CDP) +--------------------- + +Firefox implements a subset of the `Chrome DevTools Protocol`_ (CDP) in order to +support third party automation tools such as `puppeteer`. The documentation for +the remote protocol (CDP) implement can be found at `remote/cdp`_. + +.. _Chrome DevTools Protocol: https://chromedevtools.github.io/devtools-protocol/ +.. _remote/cdp: cdp/ + + +.. _webdriver-bidi-header: + +WebDriver BiDi +-------------- + +`The WebDriver BiDi specification <https://w3c.github.io/webdriver-bidi>`_ +extends WebDriver HTTP to add bidirectional communication. Dedicated +documentation will be added as the Firefox implementation makes progress. + +Architecture +============ + +Message Handler +--------------- + +The documentation for the framework used to build WebDriver BiDi modules can be +found at `remote/messagehandler`_. + +.. _remote/messagehandler: messagehandler/ + + +Bugs +==== + +Bugs are tracked under the `Remote Protocol product`_. + +.. _Remote Protocol product: https://bugzilla.mozilla.org/describecomponents.cgi?product=Remote%20Protocol + + +Communication +============= + +See `Communication`_ on `our project wiki`_. + +.. _Communication: https://wiki.mozilla.org/Remote#Communication +.. _our project wiki: https://wiki.mozilla.org/Remote diff --git a/remote/doc/marionette/Building.md b/remote/doc/marionette/Building.md new file mode 100644 index 0000000000..3d9f44516c --- /dev/null +++ b/remote/doc/marionette/Building.md @@ -0,0 +1,68 @@ +# Building + +Marionette is built into Firefox by default and ships in the official +Firefox binary. As Marionette is written in [XPCOM] flavoured +JavaScript, you may choose to rely on so called [artifact builds], +which will download pre-compiled Firefox blobs to your computer. +This means you don’t have to compile Firefox locally, but does +come at the cost of having a good internet connection. To enable +[artifact builds] you may choose ‘Firefox for Desktop Artifact +Mode’ when bootstrapping. + +Once you have a clone of [mozilla-unified], you can set up your +development environment by running this command and following the +on-screen instructions: + +```shell +./mach bootstrap +``` + +When you're getting asked to choose the version of Firefox you want to build, +you may want to consider choosing "Firefox for Desktop Artifact Mode". This +significantly reduces the time it takes to build Firefox on your machine +(from 30+ minutes to just 1-2 minutes) if you have a fast internet connection. + +To perform a regular build, simply do: + +```shell +./mach build +``` + +You can clean out the objdir using this command: + +```shell +./mach clobber +``` + +Occasionally a clean build will be required after you fetch the +latest changes from mozilla-central. You will find that the the +build will error when this is the case. To automatically do clean +builds when this happens you may optionally add this line to the +[mozconfig] file in your top source directory: + +``` +mk_add_options AUTOCLOBBER=1 +``` + +If you compile Firefox frequently you will also want to enable +[ccache] and [sccache] if you develop on a macOS or Linux system: + +``` +mk_add_options 'export RUSTC_WRAPPER=sccache' +mk_add_options 'export CCACHE_CPP2=yes' +ac_add_options --with-ccache +``` + +You may also opt out of building all the WebDriver specific components +(Marionette, and the [Remote Agent]) by setting the following flag: + +``` +ac_add_options --disable-webdriver +``` + +[mozilla-unified]: https://mozilla-version-control-tools.readthedocs.io/en/latest/hgmozilla/unifiedrepo.html +[artifact builds]: /contributing/build/artifact_builds.rst +[mozconfig]: /build/buildsystem/mozconfigs.rst +[ccache]: https://ccache.samba.org/ +[sccache]: https://github.com/mozilla/sccache +[Remote Agent]: /remote/index.rst diff --git a/remote/doc/marionette/CodeStyle.md b/remote/doc/marionette/CodeStyle.md new file mode 100644 index 0000000000..0658018a46 --- /dev/null +++ b/remote/doc/marionette/CodeStyle.md @@ -0,0 +1,253 @@ +# Style guide + +Like other projects, we also have some guidelines to keep to the code. +For the overall Marionette project, a few rough rules are: + +* Make your code readable and sensible, and don’t try to be + clever. Prefer simple and easy solutions over more convoluted + and foreign syntax. + +* Fixing style violations whilst working on a real change as a + preparatory clean-up step is good, but otherwise avoid useless + code churn for the sake of conforming to the style guide. + +* Code is mutable and not written in stone. Nothing that + is checked in is sacred and we encourage change to make + remote/marionette a pleasant ecosystem to work in. + +## JavaScript + +Marionette is written in JavaScript and ships +as part of Firefox. We have access to all the latest ECMAScript +features currently in development, usually before it ships in the +wild and we try to make use of new features when appropriate, +especially when they move us off legacy internal replacements +(such as Promise.jsm and Task.jsm). + +One of the peculiarities of working on JavaScript code that ships as +part of a runtime platform is, that unlike in a regular web document, +we share a single global state with the rest of Firefox. This means +we have to be responsible and not leak resources unnecessarily. + +JS code in Gecko is organised into _modules_ carrying _.js_ or _.jsm_ +file extensions. Depending on the area of Gecko you’re working on, +you may find they have different techniques for exporting symbols, +varying indentation and code style, as well as varying linting +requirements. + +To export symbols to other Marionette modules, remember to assign +your exported symbols to the shared global `this`: + +```javascript +const EXPORTED_SYMBOLS = ["PollPromise", "TimedPromise"]; +``` + +When importing symbols in Marionette code, try to be specific about +what you need: + +```javascript +const { TimedPromise } = ChromeUtils.import( + "chrome://remote/content/marionette/sync.js" +); +``` + +We prefer object assignment shorthands when redefining names, +for example when you use functionality from the `Components` global: + +```javascript +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; +``` + +When using symbols by their own name, the assignment name can be +omitted: + +```javascript +const {TYPE_ONE_SHOT, TYPE_REPEATING_SLACK} = Ci.nsITimer; +``` + +In addition to the default [Mozilla eslint rules], we have [our +own specialisations] that are stricter and enforce more security. +A few notable examples are that we disallow fallthrough `case` +statements unless they are explicitly grouped together: + +```javascript +switch (x) { + case "foo": + doSomething(); + + case "bar": // <-- disallowed! + doSomethingElse(); + break; + + case "baz": + case "bah": // <-- allowed (-: + doCrazyThings(); +} +``` + +We disallow the use of `var`, for which we always prefer `let` and +`const` as replacements. Do be aware that `const` does not mean +that the variable is immutable: just that it cannot be reassigned. +We require all lines to end with semicolons, disallow construction +of plain `new Object()`, require variable names to be camel-cased, +and complain about unused variables. + +For purely aesthetic reasons we indent our code with two spaces, +which includes switch-statement `case`s, and limit the maximum +line length to 78 columns. When you need to wrap a statement to +the next line, the second line is indented with four spaces, like this: + +```javascript +throw new TypeError(pprint`Expected an element or WindowProxy, got: ${el}`); +``` + +This is not normally something you have to think to deeply about as +it is enforced by the [linter]. The linter also has an automatic +mode that fixes and formats certain classes of style violations. + +If you find yourself struggling to fit a long statement on one line, +this is usually an indication that it is too long and should be +split into multiple lines. This is also a helpful tip to make the +code easier to read. Assigning transitive values to descriptive +variable names can serve as self-documentation: + +```javascript +let location = event.target.documentURI || event.target.location.href; +log.debug(`Received DOM event ${event.type} for ${location}`); +``` + +On the topic of variable naming the opinions are as many as programmers +writing code, but it is often helpful to keep the input and output +arguments to functions descriptive (longer), and let transitive +internal values to be described more succinctly: + +```javascript +/** Prettifies instance of Error and its stacktrace to a string. */ +function stringify(error) { + try { + let s = error.toString(); + if ("stack" in error) { + s += "\n" + error.stack; + } + return s; + } catch (e) { + return "<unprintable error>"; + } +} +``` + +When we can, we try to extract the relevant object properties in +the arguments to an event handler or a function: + +```javascript +const responseListener = ({name, target, json, data}) => { … }; +``` + +Instead of: + +```javascript +const responseListener = msg => { + let name = msg.name; + let target = msg.target; + let json = msg.json; + let data = msg.data; + … +}; +``` + +All source files should have `"use strict";` as the first directive +so that the file is parsed in [strict mode]. + +Every source code file that ships as part of the Firefox bundle +must also have a [copying header], such as this: + +```javascript + /* 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/. */ +``` + +New xpcshell test files _should not_ have a license header as all +new Mozilla tests should be in the [public domain] so that they can +easily be shared with other browser vendors. We want to re-license +existing tests covered by the [MPL] so that they can be shared. +We very much welcome your help in doing version control archeology +to make this happen! + +The practical details of working on the Marionette code is outlined +in [Contributing.md], but generally you do not have to re-build +Firefox when changing code. Any change to remote/marionette/*.js +will be picked up on restarting Firefox. The only notable exception +is remote/components/Marionette.jsm, which does require +a re-build. + +[strict mode]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Strict_mode +[Mozilla eslint rules]: https://searchfox.org/mozilla-central/source/.eslintrc.js +[our own specialisations]: https://searchfox.org/mozilla-central/source/remote/marionette/.eslintrc.js +[linter]: #linting +[copying header]: https://www.mozilla.org/en-US/MPL/headers/ +[public domain]: https://creativecommons.org/publicdomain/zero/1.0/ +[MPL]: https://www.mozilla.org/en-US/MPL/2.0/ +[Contributing.md]: ./Contributing.md + +## Python + +TODO + +## Documentation + +We keep our documentation in-tree under [remote/doc/marionette] +and [testing/geckodriver/doc]. Updates and minor changes to +documentation should ideally not be scrutinised to the same degree +as code changes to encourage frequent updates so that the documentation +does not go stale. To that end, documentation changes with `r=me` +from module peers are permitted. + +Use fmt(1) or an equivalent editor specific mechanism (such as Meta-Q +in Emacs) to format paragraphs at a maximum width of 75 columns +with a goal of roughly 65. This is equivalent to `fmt -w 75 -g 65`, +which happens to be the default on BSD and macOS. + +We endeavour to document all _public APIs_ of the Marionette component. +These include public functions—or command implementations—on +the `GeckoDriver` class, as well as all exported symbols from +other modules. Documentation for non-exported symbols is not required. + +[remote/doc/marionette]: https://searchfox.org/mozilla-central/source/remote/marionette/doc +[testing/geckodriver/doc]: https://searchfox.org/mozilla-central/source/testing/geckodriver/doc + +## Linting + +Marionette consists mostly of JavaScript (server) and Python (client, +harness, test runner) code. We lint our code with [mozlint], +which harmonises the output from [eslint] and [ruff]. + +To run the linter with a sensible output: + +```shell +% ./mach lint -funix remote/marionette +``` + +For certain classes of style violations the eslint linter has +an automatic mode for fixing and formatting your code. This is +particularly useful to keep to whitespace and indentation rules: + +```shell +% ./mach eslint --fix remote/marionette +``` + +The linter is also run as a try job (shorthand `ES`) which means +any style violations will automatically block a patch from landing +(if using Autoland) or cause your changeset to be backed out (if +landing directly on mozilla-inbound). + +If you use git(1) you can [enable automatic linting] before you push +to a remote through a pre-push (or pre-commit) hook. This will +run the linters on the changed files before a push and abort if +there are any problems. This is convenient for avoiding a try run +failing due to a stupid linting issue. + +[mozlint]: /code-quality/lint/mozlint.rst +[eslint]: /code-quality/lint/linters/eslint.rst +[ruff]: /code-quality/lint/linters/ruff.rst +[enable automatic linting]: /code-quality/lint/usage.rst#using-a-vcs-hook diff --git a/remote/doc/marionette/Contributing.md b/remote/doc/marionette/Contributing.md new file mode 100644 index 0000000000..b179d16613 --- /dev/null +++ b/remote/doc/marionette/Contributing.md @@ -0,0 +1,69 @@ +# Contributing + +If you are new to open source or to Mozilla, you might like this +[tutorial for new Marionette contributors](NewContributors.md). + +We are delighted that you want to help improve Marionette! +‘Marionette’ means different a few different things, depending +on who you talk to, but the overall scope of the project involves +these components: + +* [_Marionette_] is a Firefox remote protocol to communicate with, + instrument, and control Gecko-based applications such as Firefox + and Firefox for mobile. It is built in to the application and + written in JavaScript. + + It serves as the backend for the geckodriver WebDriver implementation, + and is used in the context of Firefox UI tests, reftesting, + Web Platform Tests, test harness bootstrapping, and in many + other far-reaching places where browser instrumentation is required. + +* [_geckodriver_] provides the HTTP API described by the [WebDriver + protocol] to communicate with Gecko-based applications such as + Firefox and Firefox for mobile. It is a standalone executable + written in Rust, and can be used with compatible W3C WebDriver clients. + +* [_webdriver_] is a Rust crate providing interfaces, traits + and types, errors, type- and bounds checks, and JSON marshaling + for correctly parsing and emitting the [WebDriver protocol]. + +By participating in this project, you agree to abide by the Mozilla +[Community Participation Guidelines]. Here are some guidelines +for contributing high-quality and actionable bugs and code. + +[_Marionette_]: ./index.rst +[_geckodriver_]: /testing/geckodriver/index.rst +[_webdriver_]: https://searchfox.org/mozilla-central/source/testing/webdriver/README.md +[WebDriver protocol]: https://w3c.github.io/webdriver/webdriver-spec.html#protocol +[Community Participation Guidelines]: https://www.mozilla.org/en-US/about/governance/policies/participation/ + +## Writing code + +Because there are many moving parts involved remote controlling +a web browser, it can be challenging to a new contributor to know +where to start. Please don’t hesitate to [ask questions]! + +The canonical source code repository is [mozilla-central]. Bugs are +filed in the [Testing :: Marionette] component on Bugzilla. We also +have a curated set of [good first bugs] you may consider attempting first. + +We have collected a lot of good advice for working on Marionette +code in our [code style document], which we highly recommend you read. + +[ask questions]: index.rst#Communication +[mozilla-central]: https://searchfox.org/mozilla-central/source/remote/marionette/ +[Testing :: Marionette]: https://bugzilla.mozilla.org/buglist.cgi?resolution=---&component=Marionette +[good first bugs]: https://codetribute.mozilla.org/projects/automation?project%3DMarionette +[code style document]: CodeStyle.md + +## Next steps + +* [Building](Building.md) +* [Debugging](Debugging.md) +* [Testing](Testing.md) +* [Patching](Patches.md) + +## Other resources + +* [Code style](CodeStyle.md) +* [New Contributor Tutorial](NewContributors.md) diff --git a/remote/doc/marionette/Debugging.md b/remote/doc/marionette/Debugging.md new file mode 100644 index 0000000000..040db24c6e --- /dev/null +++ b/remote/doc/marionette/Debugging.md @@ -0,0 +1,96 @@ +# Debugging + +## Redirecting the Gecko output + +The most common way to debug Marionette, as well as chrome code in +general, is to use `dump()` to print a string to stdout. In Firefox, +this log output normally ends up in the gecko.log file in your current +working directory. With Fennec it can be inspected using `adb logcat`. + +`mach marionette-test` takes a `--gecko-log` option which lets +you redirect this output stream. This is convenient if you want to +“merge” the test harness output with the stdout from the browser. +Per Unix conventions you can use `-` (dash) to have Firefox write +its log to stdout instead of file: + +```shell +% ./mach marionette-test --gecko-log - +``` + +It is common to use this in conjunction with an option to increase +the Marionette log level: + +```shell +% ./mach test --gecko-log - -vv TEST +``` + +A single `-v` enables debug logging, and a double `-vv` enables +trace logging. + +This debugging technique can be particularly effective when combined +with using [pdb] in the Python client or the JS remote debugger +that is described below. + +[pdb]: https://docs.python.org/3/library/pdb.html + +## JavaScript debugger + +You can attach the [Browser Toolbox] JavaScript debugger to the +Marionette server using the `--jsdebugger` flag. This enables you +to introspect and set breakpoints in Gecko chrome code, which is a +more powerful debugging technique than using `dump()` or `console.log()`. + +To automatically open the JS debugger for `Mn` tests: + +```shell +% ./mach marionette-test --jsdebugger +``` + +It will prompt you when to start to allow you time to set your +breakpoints. It will also prompt you between each test. + +You can also use the `debugger;` statement anywhere in chrome code +to add a breakpoint. In this example, a breakpoint will be added +whenever the `WebDriver:GetPageSource` command is called: + +```javascript + GeckoDriver.prototype.getPageSource = async function() { + debugger; + … + } +``` + +To be prompted at the start of the test run or between tests, +you can set the `marionette.debugging.clicktostart` preference to +`true` this way: + +```shell +% ./mach marionette-test --setpref='marionette.debugging.clicktostart=true' --jsdebugger +``` + +For reference, below is the list of preferences that enables the +chrome debugger for Marionette. These are all set implicitly when +`--jsdebugger` is passed to mach. In non-official builds, which +are the default when built using `./mach build`, you will find that +the chrome debugger won’t prompt for connection and will allow +remote connections. + +* `devtools.browsertoolbox.panel` -> `jsdebugger` + + Selects the Debugger panel by default. + +* `devtools.chrome.enabled` → true + + Enables debugging of chrome code. + +* `devtools.debugger.prompt-connection` → false + + Controls the remote connection prompt. Note that this will + automatically expose your Firefox instance to localhost. + +* `devtools.debugger.remote-enabled` → true + + Allows a remote debugger to connect, which is necessary for + debugging chrome code. + +[Browser Toolbox]: /devtools-user/browser_toolbox/index.rst diff --git a/remote/doc/marionette/Intro.md b/remote/doc/marionette/Intro.md new file mode 100644 index 0000000000..8c34eef700 --- /dev/null +++ b/remote/doc/marionette/Intro.md @@ -0,0 +1,70 @@ +# Introduction to Marionette + +Marionette is an automation driver for Mozilla's Gecko engine. +It can remotely control either the UI or the internal JavaScript of +a Gecko platform, such as Firefox. It can control both the chrome +(i.e. menus and functions) or the content (the webpage loaded inside +the browsing context), giving a high level of control and ability +to replicate user actions. In addition to performing actions on the +browser, Marionette can also read the properties and attributes of +the DOM. + +If this sounds similar to [Selenium/WebDriver] then you're +correct! Marionette shares much of the same ethos and API as +Selenium/WebDriver, with additional commands to interact with +Gecko's chrome interface. Its goal is to replicate what Selenium +does for web content: to enable the tester to have the ability to +send commands to remotely control a user agent. + +[Selenium/WebDriver]: https://dvcs.w3.org/hg/webdriver/raw-file/tip/webdriver-spec.html + +## How does it work? + +Marionette consists of two parts: a server which takes requests and +executes them in Gecko, and a client. The client sends commands to +the server and the server executes the command inside the browser. + +## When would I use it? + +If you want to perform UI tests with browser chrome or content, +Marionette is the tool you're looking for! You can use it to +control either web content, or Firefox itself. + +A test engineer would typically import the Marionette client package +into their test framework, import the classes and use the class +functions and methods to control the browser. After controlling +the browser, Marionette can be used to return information about +the state of the browser which can then be used to validate that +the action was performed correctly. + +## Using Marionette + +Marionette combines a gecko component (the Marionette server) with an +outside component (the Marionette client), which drives the tests. +The Marionette server ships with Firefox, and to use it you will +need to download a Marionette client or use the in-tree client. + +* [Download and setup the Python client for Marionette][1] +* [Run Tests with Python][2] – How to run tests using the + Python client +* You might want to experiment with [using Marionette interactively + at a Python command prompt][2] +* Start [writing and running][3] tests +* Tips on [debugging][4] Marionette code +* [Download and setup the Marionette JS client][5] +* [Protocol definition][6] + +[1]: /python/marionette_driver.rst +[2]: /python/marionette_driver.rst +[3]: PythonTests.md +[4]: Debugging.md +[5]: https://github.com/mozilla-b2g/marionette_js_client +[6]: Protocol.md + +## Bugs + +Please file any bugs you may find in the `Testing :: Marionette` +component in Bugzilla. You can view a [list of current bugs] +to see if your problem is already being addressed. + +[list of current bugs]: https://bugzilla.mozilla.org/buglist.cgi?product=Testing&component=Marionette diff --git a/remote/doc/marionette/NewContributors.md b/remote/doc/marionette/NewContributors.md new file mode 100644 index 0000000000..017a5e909b --- /dev/null +++ b/remote/doc/marionette/NewContributors.md @@ -0,0 +1,84 @@ +# New contributors + +This page is aimed at people who are new to Mozilla and want to contribute +to Mozilla source code related to Marionette Python tests, WebDriver +spec tests and related test harnesses and tools. Mozilla has both +git and Mercurial repositories, but this guide only describes Mercurial. + +If you run into issues or have doubts, check out the [Resources](#resources) +section below and **don't hesitate to ask questions**. :) The goal of these +steps is to make sure you have the basics of your development environment +working. Once you do, we can get you started with working on an +actual bug, yay! + +## Accounts, communication + + 1. Set up a [Bugzilla] account (and, if you like, a [Mozillians] profile). + Please include your Element nickname in both of these accounts so we can work + with you more easily. For example, Eve Smith would set the Bugzilla name + to "Eve Smith (:esmith)", where "esmith" is the Element nick. + + 2. For a direct communication with us it will be beneficial to setup [Element]. + Make sure to also register your nickname as described in the linked document. + + 3. Join our [#webdriver:mozilla.org] channel, and introduce yourself to the + team. :whimboo, :jdescottes, and :jgraham are all familiar with Marionette. + We're nice, I promise, but we might not answer right away due to different + time zones, time off, etc. So please be patient. + + 4. When you want to ask a question on Element, just go ahead an ask it even if + no one appears to be around/responding. + Provide lots of detail so that we have a better chance of helping you. + If you don't get an answer right away, check again in a few hours -- + someone may have answered you in the mean time. + + 5. If you're having trouble reaching us over Element, you are welcome to send an + email to our [mailing list](index.rst#Communication) instead. It's a good + idea to include your Element nick in your email message. + +[Element]: https://chat.mozilla.org +[#webdriver:mozilla.org]: https://chat.mozilla.org/#/room/#webdriver:mozilla.org +[Bugzilla]: https://bugzilla.mozilla.org/ +[Mozillians]: https://mozillians.org/ + +## Getting the code, running tests + +Follow the documentation on [Contributing](Contributing.md) to get a sense of +our projects, and which is of most interest for you. You will also learn how to +get the Firefox source code, build your custom Firefox build, and how to run the +tests. + +## Work on bugs and get code review + +Once you are familiar with the code of the test harnesses, and the tests you might +want to start with your first contribution. The necessary steps to submit and verify +your patches are laid out in [Patches](Patches.md). + +## Resources + +* Search Mozilla's code repository with searchfox to find the [code for + Marionette] and the [Marionette client/harness]. + +* Another [guide for new contributors]. It has not been updated in a long + time but it's a good general resource if you ever get stuck on something. + The most relevant sections to you are about Bugzilla, Mercurial, Python and the + Development Process. + +* [Mercurial for Mozillians] + +* More general resources are available in this little [guide] :maja_zf wrote + in 2015 to help a student get started with open source contributions. + +* Textbook about general open source practices: [Practical Open Source Software Exploration] + +* If you'd rather use git instead of hg, see [git workflow for + Gecko development] and/or [this blog post by :ato]. + +[code for Marionette]: https://searchfox.org/mozilla-central/source/remote/marionette/ +[Marionette client/harness]: https://searchfox.org/mozilla-central/source/testing/marionette/ +[guide for new contributors]: https://ateam-bootcamp.readthedocs.org/en/latest/guide/index.html#new-contributor-guide +[Mercurial for Mozillians]: https://mozilla-version-control-tools.readthedocs.org/en/latest/hgmozilla/index.html +[guide]: https://gist.github.com/mjzffr/d2adef328a416081f543 +[Practical Open Source Software Exploration]: https://quaid.fedorapeople.org/TOS/Practical_Open_Source_Software_Exploration/html/index.html +[git workflow for Gecko development]: https://github.com/glandium/git-cinnabar/wiki/Mozilla:-A-git-workflow-for-Gecko-development +[this blog post by :ato]: https://sny.no/2016/03/geckogit diff --git a/remote/doc/marionette/Patches.md b/remote/doc/marionette/Patches.md new file mode 100644 index 0000000000..72b28e0edd --- /dev/null +++ b/remote/doc/marionette/Patches.md @@ -0,0 +1,38 @@ +# Submitting patches + +You can submit patches by using [Phabricator]. Walk through its documentation +in how to set it up, and uploading patches for review. Don't worry about which +person to select for reviewing your code. It will be done automatically. + +Please also make sure to follow the [commit creation guidelines]. + +Once you have contributed a couple of patches, we are happy to +sponsor you in [becoming a Mozilla committer]. When you have been +granted commit access level 1 you will have permission to use the +[Firefox CI] to trigger your own “try runs” to test your changes. + +You can use the `remote-protocol` [try preset]: + +```shell +% ./mach try --preset remote-protocol +``` + +This preset will schedule tests related to the Remote Protocol component on +various platforms. You can reduce the number of tasks by filtering on platforms +(e.g. linux) or build type (e.g. opt): + +```shell +% ./mach try --preset remote-protocol -xq "'linux 'opt" +``` + +But you can also schedule tests by selecting relevant jobs yourself: + +```shell +% ./mach try fuzzy +``` + +[Phabricator]: https://moz-conduit.readthedocs.io/en/latest/phabricator-user.html +[commit creation guidelines]: https://mozilla-version-control-tools.readthedocs.io/en/latest/devguide/contributing.html#submitting-patches-for-review +[becoming a Mozilla committer]: https://www.mozilla.org/en-US/about/governance/policies/commit/ +[Firefox CI]: https://treeherder.mozilla.org/ +[try preset]: /tools/try/presets diff --git a/remote/doc/marionette/Prefs.md b/remote/doc/marionette/Prefs.md new file mode 100644 index 0000000000..2f464054c3 --- /dev/null +++ b/remote/doc/marionette/Prefs.md @@ -0,0 +1,24 @@ +# Preferences + +There are a couple of [Remote Agent preferences] associated with the Gecko remote +protocol. Those listed below are additional ones uniquely used for Marionette. + +[Remote Agent preferences]: /remote/Prefs.md + +## `marionette.debugging.clicktostart` + +Delay server startup until a modal dialogue has been clicked to +allow time for user to set breakpoints in the [Browser Toolbox]. + +[Browser Toolbox]: /devtools-user/browser_toolbox/index.rst + +## `marionette.port` + +Defines the port on which the Marionette server will listen. Defaults +to port 2828. + +This can be set to 0 to have the system atomically allocate a free +port, which can be useful when running multiple Marionette servers +on the same system. The effective port is written to the user +preference file when the server has started and is also logged to +stdout. diff --git a/remote/doc/marionette/Protocol.md b/remote/doc/marionette/Protocol.md new file mode 100644 index 0000000000..b20a3dc6ec --- /dev/null +++ b/remote/doc/marionette/Protocol.md @@ -0,0 +1,122 @@ +# Protocol + +Marionette provides an asynchronous, parallel pipelining user-facing +interface. Message sequencing limits chances of payload race +conditions and provides a uniform way in which payloads are serialised. + +Clients that deliver a blocking WebDriver interface are still +expected to not send further command requests before the response +from the last command has come back, but if they still happen to do +so because of programming error, no harm will be done. This guards +against [mixing up responses]. + +Schematic flow of messages: + +```text + client server + | | +msgid=1 |----------->| + | command | + | | +msgid=2 |<-----------| + | command | + | | +msgid=2 |----------->| + | response | + | | +msgid=1 |<-----------| + | response | + | | +``` + +The protocol consists of a `command` message and the corresponding +`response` message. A `response` message must always be sent in +reply to a `command` message. + +This means that the server implementation does not need to send +the reply precisely in the order of the received commands: if it +receives multiple messages, the server may even reply in random order. +It is therefore strongly advised that clients take this into account +when imlpementing the client end of this wire protocol. + +This is required for pipelining messages. On the server side, +some functions are fast, and some less so. If the server must +reply in order, the slow functions delay the other replies even if +its execution is already completed. + +[mixing up responses]: https://bugzil.la/1207125 + +## Command + +The request, or `command` message, is a four element JSON Array as shown +below, that may originate from either the client- or server remote ends: + +```python +[type, message ID, command, parameters] +``` + +* _type_ must be 0 (integer). This indicates that the message + is a `command`. + +* _message ID_ is a 32-bit unsigned integer. This number is + used as a sequencing number that uniquely identifies a pair of + `command` and `response` messages. The other remote part will + reply with a corresponding `response` with the same message ID. + +* _command_ is a string identifying the RPC method or command + to execute. + +* _parameters_ is an arbitrary JSON serialisable object. + +## Response + +The response message is also a four element array as shown below, +and must always be sent after receiving a `command`: + +```python +[type, message ID, error, result] +``` + +* _type_ must be 1 (integer). This indicates that the message is a + `response`. + +* _message ID_ is a 32-bit unsigned integer. This corresponds + to the `command`’s message ID. + +* _error_ is null if the command executed correctly. If the + error occurred on the server-side, then this is an [error] object. + +* _result_ is the result object from executing the `command`, if + it executed correctly. If an error occurred on the server-side, + this field is null. + +The structure of the result field can vary, but is documented +individually for each command. + +## Error object + +An error object is a serialisation of JavaScript error types, +and it is structured like this: + +```javascript +{ + "error": "invalid session id", + "message": "No active session with ID 1234", + "stacktrace": "" +} +``` + +All the fields of the error object are required, so the stacktrace and +message fields may be empty strings. The error field is guaranteed +to be one of the JSON error codes as laid out by the [WebDriver standard]. + +## Clients + +Clients may be implemented in any language that is capable of writing +and receiving data over TCP socket. A [reference client] is provided. +Clients may be implemented both synchronously and asynchronously, +although the latter is impossible in protocol levels 2 and earlier +due to the lack of message sequencing. + +[WebDriver standard]: https://w3c.github.io/webdriver/#dfn-error-code +[reference client]: https://searchfox.org/mozilla-central/source/testing/marionette/client/ diff --git a/remote/doc/marionette/PythonTests.md b/remote/doc/marionette/PythonTests.md new file mode 100644 index 0000000000..c3497d6272 --- /dev/null +++ b/remote/doc/marionette/PythonTests.md @@ -0,0 +1,69 @@ +# Mn Python tests + +_Marionette_ is the codename of a [remote protocol] built in to +Firefox as well as the name of a functional test framework for +automating user interface tests. + +The in-tree test framework supports tests written in Python, using +Python’s [unittest] library. Test cases are written as a subclass +of `MarionetteTestCase`, with child tests belonging to instance +methods that have a name starting with `test_`. + +You can additionally define [`setUp`] and [`tearDown`] instance +methods to execute code before and after child tests, and +[`setUpClass`]/[`tearDownClass`] for the parent test. When you use +these, it is important to remember calling the `MarionetteTestCase` +superclass’ own [`setUp`]/[`tearDown`] methods since they handle +setup/cleanup of the session. + +The test structure is illustrated here: + +```python +from marionette_harness import MarionetteTestCase + +class TestSomething(MarionetteTestCase): + def setUp(self): + # code to execute before any tests are run + MarionetteTestCase.setUp(self) + + def test_foo(self): + # run test for 'foo' + + def test_bar(self): + # run test for 'bar' + + def tearDown(self): + # code to execute after all tests are run + MarionetteTestCase.tearDown(self) +``` + +[remote protocol]: Protocol.md +[unittest]: https://docs.python.org/3/library/unittest.html +[`setUp`]: https://docs.python.org/3/library/unittest.html#unittest.TestCase.setUp +[`setUpClass`]: https://docs.python.org/3/library/unittest.html#unittest.TestCase.setUpClass +[`tearDown`]: https://docs.python.org/3/library/unittest.html#unittest.TestCase.tearDown +[`tearDownClass`]: https://docs.python.org/3/library/unittest.html#unittest.TestCase.tearDownClass + +## Test assertions + +Assertions are provided courtesy of [unittest]. For example: + +```python +from marionette_harness import MarionetteTestCase + +class TestSomething(MarionetteTestCase): + def test_foo(self): + self.assertEqual(9, 3 * 3, '3 x 3 should be 9') + self.assertTrue(type(2) == int, '2 should be an integer') +``` + +## The API + +The full API documentation is found [here], but the key objects are: + +* `MarionetteTestCase`: a subclass for `unittest.TestCase` + used as a base class for all tests to run. + +* {class}`Marionette <marionette_driver.marionette.Marionette>`: client that speaks to Firefox + +[here]: /python/marionette_driver.rst diff --git a/remote/doc/marionette/SeleniumAtoms.md b/remote/doc/marionette/SeleniumAtoms.md new file mode 100644 index 0000000000..9f25af46cc --- /dev/null +++ b/remote/doc/marionette/SeleniumAtoms.md @@ -0,0 +1,92 @@ +# Selenium atoms + +Marionette uses a small list of [Selenium atoms] to interact with +web elements. Initially those have been added to ensure a better +reliability due to a wider usage inside the Selenium project. But +by adding full support for the [WebDriver specification] they will +be removed step by step. + +Currently the following atoms are in use: + +- `getElementText` +- `isElementDisplayed` +- `isElementEnabled` + +To use one of those atoms Javascript modules will have to import +[atom.sys.mjs]. + +[Selenium atoms]: https://github.com/SeleniumHQ/selenium/tree/master/javascript/webdriver/atoms +[WebDriver specification]: https://w3c.github.io/webdriver/webdriver-spec.html +[atom.sys.mjs]: https://searchfox.org/mozilla-central/source/remote/marionette/atom.sys.mjs + +## Update required Selenium atoms + +In regular intervals the atoms, which are still in use, have to +be updated. Therefore they have to be exported from the Selenium +repository first, and then updated in [atom.sys.mjs]. + +### Export Selenium Atoms + +The canonical GitHub repository for Selenium is + + <https://github.com/SeleniumHQ/selenium.git> + +so make sure to have an up-to-date local copy of it. If you have to clone +it first, it is recommended to specify the `--depth=1` argument, so only the +last changeset is getting downloaded (which itself might already be +more than 100 MB). + +```bash +git clone --depth=1 https://github.com/SeleniumHQ/selenium.git +``` + +To export the correct version of the atoms identify the changeset id (SHA1) of +the Selenium repository in the [index section] of the WebDriver specification. + +Fetch that changeset and check it out: + +```bash +git fetch --depth=1 origin SHA1 +git checkout SHA1 +``` + +Now you can export all the required atoms by running the following +commands. Make sure to [install bazelisk] first. + +```bash +bazel build //javascript/atoms/fragments:get-text +bazel build //javascript/atoms/fragments:is-displayed +bazel build //javascript/atoms/fragments:is-enabled +``` + +For each of the exported atoms a file can now be found in the folder +`bazel-bin/javascript/atoms/fragments/`. They contain all the +code including dependencies for the atom wrapped into a single function. + +[index section]: <https://w3c.github.io/webdriver/#index> +[install bazelisk]: <https://github.com/bazelbuild/bazelisk#installation> + +### Update atom.sys.mjs + +To update the atoms for Marionette the `atoms.js` file has to be edited. For +each atom to be updated the steps as laid out below have to be performed: + +1. Open the Javascript file of the exported atom. See above for + its location. + +2. Add the related function name and `element` as parameters to the wrapper + function, which can be found at the very beginning of the file so that it + is equal to the parameters in `atom.sys.mjs`. + +3. Copy and paste the whole contents of the file into the left textarea on + <https://jsonformatter.org/json-stringify-online> to get a stringified + version of all the required functions. + +4. Copy and paste the whole contents of the right textarea, and replace the + existing code for the atom in `atom.sys.mjs`. + +### Test the changes + +To ensure that the update of the atoms doesn't cause a regression +a try build should be run including Marionette unit tests, Firefox +ui tests, and all the web-platform-tests. diff --git a/remote/doc/marionette/Taskcluster.md b/remote/doc/marionette/Taskcluster.md new file mode 100644 index 0000000000..11381745aa --- /dev/null +++ b/remote/doc/marionette/Taskcluster.md @@ -0,0 +1,98 @@ +# Testing with one-click loaners + +[Taskcluster] is the task execution framework that supports Mozilla's +continuous integration and release processes. + +Build and test jobs (like Marionette) are executed across all supported +platforms, and job results are pushed to [Treeherder] for observation. + +The best way to debug issues for intermittent test failures of +Marionette tests for Firefox and Fennec (Android) is to use a +one-click loaner as provided by Taskcluster. Such a loaner creates +an interactive task you can interact with via a shell and VNC. + +To create an interactive task for a Marionette job which is shown +as failed on Treeherder, select the job, click the ellipse in the lower +left pane, and choose `Create Interactive Task`. + +Please note that you need special permissions to actually request +such a loaner. + +When the task has been created you will receive an email with the connection +details. Open the referenced shell and you will be connected via a WebSocket. +Once that has been done a wizard will automatically launch and +provide some options. Best here is to choose the second option, +which will run all the setup steps, installs the Firefox or Fennec +binary, and then exits. + +[Taskcluster]: https://docs.taskcluster.net/ +[Treeherder]: https://treeherder.mozilla.org + +## Setting up the Marionette environment + +Best here is to use a virtual environment, which has all the +necessary packages installed. If no modifications to any Python +package will be done, the already created environment by the +wizard can be used: + +```shell +% cd /builds/worker/workspace/build +% source venv/bin/activate +``` + +Otherwise a new virtual environment needs to be created and +populated with the mozbase and marionette packages installed: + +```shell +% cd /builds/worker/workspace/build && rm -r venv +% virtualenv venv && source venv/bin/activate +% cd tests/mozbase && ./setup_development.py +% cd ../marionette/client && python setup.py develop +% cd ../harness && python setup.py develop +% cd ../../../ +``` + +## Running Marionette tests + +### Firefox + +To run the Marionette tests execute the `runtests.py` script. For all +the required options as best search in the log file of the failing job +the interactive task has been created from. Then copy the complete +command and run it inside the already sourced virtual environment: + +```shell +% /builds/worker/workspace/build/venv/bin/python -u /builds/worker/workspace/build/tests/marionette/harness/marionette_harness/runtests.py --gecko-log=- -vv --binary=/builds/worker/workspace/build/application/firefox/firefox --address=127.0.0.1:2828 --symbols-path=https://queue.taskcluster.net/v1/task/GSuwee61Qyibujtxq4UV3A/artifacts/public/build/target.crashreporter-symbols.zip /builds/worker/workspace/build/tests/marionette/tests/testing/marionette/harness/marionette_harness/tests/unit-tests.ini +``` + +### Fennec + +The Marionette tests for Fennec are executed by using an Android +emulator which runs on the host platform. As such some extra setup +steps compared to Firefox on desktop are required. + +The following lines set necessary environment variables before +starting the emulator in the background, and to let Marionette +know of various Android SDK tools. + +```shell +% export ADB_PATH=/builds/worker/workspace/build/android-sdk-linux/platform-tools/adb +% export ANDROID_AVD_HOME=/builds/worker/workspace/build/.android/avd/ +% /builds/worker/workspace/build/android-sdk-linux/tools/emulator -avd test-1 -show-kernel -debug init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket & +``` + +The actual call to `runtests.py` is different per test job because +those are using chunks on Android. As best search for the command +and its options in the log file of the failing job the interactive +task has been created from. Then copy the complete command and run +it inside the already sourced virtual environment. + +Here an example for chunk 1 which runs all the tests in the current +chunk with some options for logs removed: + +```shell +% /builds/worker/workspace/build/venv/bin/python -u /builds/worker/workspace/build/tests/marionette/harness/marionette_harness/runtests.py --emulator --app=fennec --package=org.mozilla.fennec_aurora --address=127.0.0.1:2828 /builds/worker/workspace/build/tests/marionette/tests/testing/marionette/harness/marionette_harness/tests/unit-tests.ini --gecko-log=- --symbols-path=/builds/worker/workspace/build/symbols --startup-timeout=300 --this-chunk 1 --total-chunks 10 +``` + +To execute a specific test only simply replace `unit-tests.ini` +with its name. diff --git a/remote/doc/marionette/Testing.md b/remote/doc/marionette/Testing.md new file mode 100644 index 0000000000..b48f0c7a30 --- /dev/null +++ b/remote/doc/marionette/Testing.md @@ -0,0 +1,229 @@ +# Testing + +We verify and test Marionette in a couple of different ways, using +a combination of unit tests and functional tests. There are three +distinct components that we test: + +* the Marionette **server**, using a combination of xpcshell +unit tests and functional tests written in Python spread across +Marionette- and WPT tests; + +* the Python **client** is tested with the same body of functional +Marionette tests; + +* and the **harness** that backs the Marionette, or `Mn` job on +try, tests is verified using separate mock-styled unit tests. + +All these tests can be run by using [mach]. + +[mach]: https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/mach + +## xpcshell unit tests + +Marionette has a set of [xpcshell] unit tests located in +_remote/marionette/test/xpcshell. These can be run this way: + +```shell +% ./mach test remote/marionette/test/unit +``` + +Because tests are run in parallel and xpcshell itself is quite +chatty, it can sometimes be useful to run the tests sequentially: + +```shell +% ./mach test --sequential remote/marionette/test/xpcshell/test_error.js +``` + +These unit tests run as part of the `X` jobs on Treeherder. + +[xpcshell]: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Writing_xpcshell-based_unit_tests + +## Marionette functional tests + +We also have a set of [functional tests] that make use of the Marionette +Python client. These start a Firefox process and tests the Marionette +protocol input and output, and will appear as `Mn` on Treeherder. +The following command will run all tests locally: + +```shell +% ./mach marionette-test +``` + +But you can also run individual tests: + +```shell +% ./mach marionette-test testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py +``` + +In case you want to run the tests with another binary like [Firefox Nightly]: + +```shell +% ./mach marionette-test --binary /path/to/nightly/firefox TEST +``` + +When working on Marionette it is often useful to surface the stdout +from Gecko, which can be achieved using the `--gecko-log` option. +See [Debugging](Debugging.md) for usage instructions, but the gist is that +you can redirect all Gecko output to stdout: + +```shell +% ./mach marionette-test --gecko-log - TEST +``` + +Our functional integration tests pop up Firefox windows sporadically, +and a helpful tip is to suppress the window can be to use Firefox’ +headless mode: + +```shell +% ./mach marionette-test -z TEST +``` + +`-z` is an alias for the `--headless` flag and equivalent to setting +the `MOZ_HEADLESS` output variable. In addition to `MOZ_HEADLESS` +there is also `MOZ_HEADLESS_WIDTH` and `MOZ_HEADLESS_HEIGHT` for +controlling the dimensions of the no-op virtual display. This is +similar to using Xvfb(1) which you may know from the X windowing system, +but has the additional benefit of also working on macOS and Windows. + +[functional tests]: PythonTests.md +[Firefox Nightly]: https://nightly.mozilla.org/ + +### Android + +Prerequisites: + +* You have [built Fennec](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Simple_Firefox_for_Android_build). + +* You can run an Android [emulator](https://wiki.mozilla.org/Mobile/Fennec/Android/Testing#Running_tests_on_the_Android_emulator), + which means you have the AVD you need. + +When running tests on Fennec, you can have Marionette runner take care of +starting Fennec and an emulator, as shown below. + +```shell +% ./mach marionette-test --emulator --app fennec + --avd-home /path/to/.mozbuild/android-device/avd + --emulator-binary /path/to/.mozbuild/android-sdk/emulator/emulator + --avd=mozemulator-x86 +``` + +For Fennec tests, if the appropriate `emulator` command is in your `PATH`, you may omit the `--emulator-binary` argument. See `./mach marionette-test -h` +for additional options. + +Alternately, you can start an emulator yourself and have the Marionette runner +start Fennec for you: + +```shell +% ./mach marionette-test --emulator --app='fennec' --address=127.0.0.1:2828 +``` + +To connect to an already-running Fennec in an emulator or on a device, +you will need to have it started with the `-marionette` command line argument, +or by setting the environment variable `MOZ_MARIONETTE=1` for the process. + +Make sure port 2828 is forwarded: + +```shell +% adb forward tcp:2828 tcp:2828 +``` + +If Fennec is already started: + +```shell +% ./mach marionette-test --app='fennec' --address=127.0.0.1:2828 +``` + +If Fennec is not already started on the emulator/device, add the `--emulator` +option. Marionette Test Runner will take care of forwarding the port and +starting Fennec with the correct prefs. (You may need to run +`adb forward --remove-all` to allow the runner to start.) + +```shell +% ./mach marionette-test --emulator --app='fennec' --address=127.0.0.1:2828 --startup-timeout=300 +``` + +If you need to troubleshoot the Marionette connection, the most basic check is +to start Fennec with `-marionette` or the environment variable `MOZ_MARIONETTE=1`, +make sure that the port 2828 is forwarded, and then see if you get any response from +Marionette when you connect manually: + +```shell +% telnet 127.0.0.1:2828 +``` + +You should see output like `{"applicationType":"gecko","marionetteProtocol":3}` + +[geckodriver]: /testing/geckodriver/index.rst + +## WPT functional tests + +Marionette is also indirectly tested through [geckodriver] with WPT +(`Wd` on Treeherder). To run them: + +```shell +% ./mach wpt testing/web-platform/tests/webdriver +``` + +WPT tests conformance to the [WebDriver] standard and uses +[geckodriver]. Together with the Marionette remote protocol in +Gecko, they make up Mozilla’s WebDriver implementation. + +This command supports a `--webdriver-arg='-vv'` argument that +enables more detailed logging, as well as `--jsdebugger` for opening +the Browser Toolbox. + +A particularly useful trick is to combine this with the headless +mode for Firefox: + +```shell +% ./mach wpt --webdriver-arg='-vv' --headless testing/web-platform/tests/webdriver +``` + +[WebDriver]: https://w3c.github.io/webdriver/ + +## Harness tests + +The Marionette harness Python package has a set of mock-styled unit +tests that uses the [pytest] framework. The following command will +run all tests: + +```shell +% ./mach python-test testing/marionette +``` + +To run a specific test specify the full path to the module: + +```shell +% ./mach python-test testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py +``` + +[pytest]: https://docs.pytest.org/en/latest/ + +## One-click loaners + +Additionally, for debugging hard-to-reproduce test failures in CI, +one-click loaners from [Taskcluster](Taskcluster.md) can be particularly useful. + +## Out-of-tree testing + +All the above examples show tests running _in-tree_, with a local +checkout of _central_ and a local build of Firefox. It is also +possibly to run the Marionette tests _without_ a local build and +with a downloaded test archive from [Taskcluster](Taskcluster.md) + +If you want to run tests from a downloaded test archive, you will +need to download the `target.common.tests.tar.gz` artifact attached to +Treeherder [build jobs] `B` for your system. Extract the archive +and set up the Python Marionette client and harness by executing +the following command in a virtual environment: + +```shell +% pip install -r config/marionette_requirements.txt +``` + +The tests can then be found under +_marionette/tests/testing/marionette/harness/marionette_harness/tests_ +and can be executed with the command `marionette`. It supports +the same options as described above for `mach`. + +[build jobs]: https://treeherder.mozilla.org/#/jobs?repo=mozilla-central&filter-searchStr=build diff --git a/remote/doc/marionette/index.rst b/remote/doc/marionette/index.rst new file mode 100644 index 0000000000..7272aff162 --- /dev/null +++ b/remote/doc/marionette/index.rst @@ -0,0 +1,64 @@ +========== +Marionette +========== + +Marionette is a remote `protocol`_ that lets out-of-process programs +communicate with, instrument, and control Gecko-based browsers. + +It provides interfaces for interacting with both the internal JavaScript +runtime and UI elements of Gecko-based browsers, such as Firefox +and Fennec. It can control both the chrome- and content documents, +giving a high level of control and ability to emulate user interaction. + +Within the central tree, Marionette is used in most TaskCluster +test jobs to instrument Gecko. It can additionally be used to +write different kinds of functional tests: + + * The `Marionette Python client`_ is used in the `Mn` job, which + is generally what you want to use for interacting with web documents + +Outside the tree, Marionette is used by `geckodriver`_ to implement +`WebDriver`_. + +Marionette supports to various degrees all the Gecko based applications, +including Firefox, Thunderbird, Fennec, and Fenix. + +.. _protocol: Protocol.html +.. _Marionette Python client: /python/marionette_driver.html +.. _geckodriver: /testing/geckodriver/ +.. _WebDriver: https://w3c.github.io/webdriver/ + +Some further documentation can be found here: + +.. toctree:: + :maxdepth: 1 + + Intro.md + Building.md + PythonTests.md + Protocol.md + Contributing.md + NewContributors.md + Patches.md + Debugging.md + Testing.md + Taskcluster.md + CodeStyle.md + SeleniumAtoms.md + Prefs.md + + +Bugs +==== + +Bugs are tracked in the `Testing :: Marionette` component. + + +Communication +============= + +The mailing list for Marionette discussion is +https://groups.google.com/a/mozilla.org/g/dev-webdriver. + +If you prefer real-time chat, ask your questions +on `#webdriver:mozilla.org <https://chat.mozilla.org/#/room/#webdriver:mozilla.org>`__. diff --git a/remote/doc/messagehandler/Intro.md b/remote/doc/messagehandler/Intro.md new file mode 100644 index 0000000000..e1db1a5945 --- /dev/null +++ b/remote/doc/messagehandler/Intro.md @@ -0,0 +1,83 @@ +# Introduction + +## Overview + +When developing browser tools in Firefox, you need to reach objects or APIs only available in certain layers (eg. processes or threads). There are powerful APIs available to communicate across layers (JSWindowActors, JSProcessActors) but they don't usually match all the needs from browser tool developers. For instance support for sessions, for events, ... + +### Modules + +The MessageHandler framework proposes to organize your code in modules, with the restriction that a given module can only run in a specific layer. Thanks to this, the framework will instantiate the modules where needed, and will provide easy ways to communicate between modules across layers. The goal is to take away all the complexity of routing information so that developers can simply focus on implementing the logic for their modules. + +### Commands and Events + +The framework is also designed around commands and events. Each module developed for the MessageHandler framework should expose commands and/or events. Commands follow a request/response model, and are conceptually similar to function calls where the caller could live in a different process than the callee. Events are emitted at the initiative of the module, and can reach listeners located in other layers. The role of modules is to implement the logic to handle commands (eg "click on an element") or generate events. The role of the framework is to send commands to modules, or to bubble events from modules. Commands and events are both used to communicate internally between modules, as well as externally with the consumer of your tooling modules. + +The "MessageHandler" name comes from this role of "handling" commands and events, aka "messages". + +### Summary + +As a summary, the MessageHandler framework proposes to write tooling code as modules, which will run in various processes or threads, and communicate across layers using commands and events. + +## Basic Architecture + +### MessageHandler Network + +Modules created for the MessageHandler framework need to run in several processes, threads, ... + +To support this the framework will dynamically create a network of [MessageHandler](https://searchfox.org/mozilla-central/source/remote/shared/messagehandler/MessageHandler.sys.mjs) instances in the various layers that need to be accessed by your modules. The MessageHandler class is obviously named after the framework, but the name is appropriate because its role is mainly to route commands and events. + +On top of routing duties, the MessageHandler class is also responsible for instantiating and managing modules. Typically, processing a command has two possible outcomes. Either it's not intended for this particular layer, in which case the MessageHandler will analyze the command and send it towards the appropriate recipient. But if it is intended for this layer, then the MessageHandler will try to delegate the command to the appropriate module. This means instantiating the module if it wasn't done before. So each node of a MessageHandler network also contains module instances. + +The root of this network is the [RootMessageHandler](https://searchfox.org/mozilla-central/source/remote/shared/messagehandler/RootMessageHandler.sys.mjs) and lives in the parent process. For consumers, this is also the single entry point exposing the commands and events of your modules. It can also own module instances, if you have modules which are supposed to live in the parent process (aka root layer). + +At the moment we only support another type of MessageHandler, the [WindowGlobalMessageHandler](https://searchfox.org/mozilla-central/source/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs) which will be used for the windowglobal layer and lives in the content process. + +### Simplified architecture example + +Let's imagine a very simple example, with a couple of modules: + +* a root module called "version" with a command returning the current version of the browser + +* a windowglobal module called "location" with a command returning the location of the windowglobal + +Suppose the browser has 2 tabs, running in different processes. If the consumer used the "version" module, and the "location" module but only for one of the two tabs, the network will look like: + +```text + parent process content process 1 +┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + ╔═══════════════════════╗ ┌─────────────┐ │ +│ ╔═══════════════════════╗ │ │ ║ WindowGlobal ╠──────┤ location │ + ║ RootMessageHandler ║◀ ─ ─ ─ ─▶║ MessageHandler ║ │ module │ │ +│ ╚══════════╦════════════╝ │ │ ╚═══════════════════════╝ └─────────────┘ + │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ +│ │ │ + ┌──────┴──────┐ content process 2 +│ │ version │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + │ module │ │ +│ └─────────────┘ │ │ + │ +│ │ │ + ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ +``` + +But if the consumer sends another command, to retrieve the location of the other tab, the network will then evolve to: + +```text + parent process content process 1 +┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + ╔═══════════════════════╗ ┌─────────────┐ │ +│ ╔═══════════════════════╗ │ │ ║ WindowGlobal ╠──────┤ location │ + ║ RootMessageHandler ║◀ ─ ┬ ─ ─▶║ MessageHandler ║ │ module │ │ +│ ╚══════════╦════════════╝ │ │ ╚═══════════════════════╝ └─────────────┘ + │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ +│ │ │ + ┌──────┴──────┐ │ content process 2 +│ │ version │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + │ module │ │ ╔═══════════════════════╗ ┌─────────────┐ │ +│ └─────────────┘ │ │ ║ WindowGlobal ╠──────┤ location │ + └ ─ ▶ ║ MessageHandler ║ │ module │ │ +│ │ │ ╚═══════════════════════╝ └─────────────┘ + ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ +``` + +We can already see here that while RootMessageHandler is connected to both WindowGlobalMessageHandler(s), they are not connected with each other. There are restriction on the way messages can travel on the network both for commands and events, which will be the topic for other documentation pages. diff --git a/remote/doc/messagehandler/SimpleExample.md b/remote/doc/messagehandler/SimpleExample.md new file mode 100644 index 0000000000..8adb9451c3 --- /dev/null +++ b/remote/doc/messagehandler/SimpleExample.md @@ -0,0 +1,147 @@ +# Simple Example + +As a tutorial, let's create a very simple example, with a couple of modules: + +* a root (parent process) module to retrieve the current version of the browser + +* a windowglobal (content process) module to retrieve the location of a given tab + +Some concepts used here will not be explained in details. More documentation should follow to clarify those. + +We will not use events in this example, only commands. + +## Create a root `version` module + +First let's create the root module. + +```javascript +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class VersionModule extends Module { + destroy() {} + + getVersion() { + return Services.appinfo.platformVersion; + } +} + +export const version = VersionModule; +``` + +All modules should extend Module.sys.mjs and must define a destroy method. +Each public method of a Module class will be exposed as a command for this module. +The name used to export the module class will be the public name of the module, used to call commands on it. + +## Create a windowglobal `location` module + +Let's create the second module. + +```javascript +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class LocationModule extends Module { + #window; + + constructor(messageHandler) { + super(messageHandler); + + // LocationModule will be a windowglobal module, so `messageHandler` will + // be a WindowGlobalMessageHandler which comes with a few helpful getters + // such as a `window` getter. + this.#window = messageHandler.window; + } + + destroy() { + this.#window = null; + } + + getLocation() { + return this.#window.location.href; + } +} + +export const location = LocationModule; +``` + +We could simplify the module and simply write `getLocation` to return `this.messageHandler.window.location.href`, but this gives us the occasion to get a glimpse at the module constructor. + +## Register the modules as Firefox modules + +Before we register those modules for the MessageHandler framework, we need to register them as Firefox modules first. For the sake of simplicity, we can assume they are added under a new folder `remote/example`: + +* `remote/example/modules/root/version.sys.mjs` + +* `remote/example/modules/windowglobal/location.sys.mjs` + +Register them in the jar.mn so that they can be loaded as any other Firefox module. + +The paths contain the corresponding layer (root, windowglobal) only for clarity. We don't rely on this as a naming convention to actually load the modules so you could decide to organize your folders differently. However the name used to export the module's class (eg `location`) will be the official name of the module, used in commands and events, so pay attention and use the correct export name. + +## Define a ModuleRegistry + +We do need to instruct the framework where each module should be loaded however. + +This is done via a ModuleRegistry. Without getting into too much details, each "set of modules" intended to work with the MessageHandler framework needs to provide a ModuleRegistry module which exports a single `getModuleClass` helper. This method will be called by the framework to know which modules are available. For now let's just define the simplest registry possible for us under `remote/example/modules/root/ModuleRegistry.sys.mjs` + +```javascript +export const getModuleClass = function(moduleName, moduleFolder) { + if (moduleName === "version" && moduleFolder === "root") { + return ChromeUtils.importESModule( + "chrome://remote/content/example/modules/root/version.sys.mjs" + ).version; + } + if (moduleName === "location" && moduleFolder === "windowglobal") { + return ChromeUtils.importESModule( + "chrome://remote/content/example/modules/windowglobal/location.sys.mjs" + ).location; + } + return null; +}; +``` + +Note that this can (and should) be improved by defining some naming conventions or patterns, but for now each set of modules is really free to implement this logic as needed. + +Add this module to jar.mn as well so that it becomes a valid Firefox module. + +### Temporary workaround to use the custom ModuleRegistry + +With this we have a set of modules which is almost ready to use. Except that for now MessageHandler is hardcoded to use WebDriver BiDi modules only. Once [Bug 1722464](https://bugzilla.mozilla.org/show_bug.cgi?id=1722464) is fixed we will be able to specify other protocols, but at the moment, the only way to instruct the MessageHandler framework to use non-bidi modules is to update the [following line](https://searchfox.org/mozilla-central/rev/08f7e9ef03dd2a83118fba6768d1143d809f5ebe/remote/shared/messagehandler/ModuleCache.sys.mjs#25) to point to `remote/example/modules/ModuleRegistry.sys.mjs`. + +Now with this, you should be able to create a MessageHandler network and use your modules. + +## Try it out + +For instance, you can open the Browser Console and run the following snippet: + +```javascript +(async function() { + const { RootMessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs" + ); + const messageHandler = RootMessageHandlerRegistry.getOrCreateMessageHandler("test-session"); + const version = await messageHandler.handleCommand({ + moduleName: "version", + commandName: "getVersion", + params: {}, + destination: { + type: "ROOT", + }, + }); + console.log({ version }); + + const location = await messageHandler.handleCommand({ + moduleName: "location", + commandName: "getLocation", + params: {}, + destination: { + type: "WINDOW_GLOBAL", + id: gBrowser.selectedBrowser.browsingContext.id, + }, + }); + console.log({ location }); +})(); +``` + +This should print a version number `{ version: "109.0a1" }` and a location `{ location: "https://www.mozilla.org/en-US/" }` (actual values should of course be different for you). + +We are voluntarily skipping detailed explanations about the various parameters passed to `handleCommand`, as well as about the `RootMessageHandlerRegistry`, but this should give you some idea already of how you can start creating modules and using them. diff --git a/remote/doc/messagehandler/index.rst b/remote/doc/messagehandler/index.rst new file mode 100644 index 0000000000..68cf4aef24 --- /dev/null +++ b/remote/doc/messagehandler/index.rst @@ -0,0 +1,11 @@ +============== +MessageHandler +============== + +MessageHandler is the framework used to implement WebDriver BiDi modules in Firefox. + +.. toctree:: + :maxdepth: 1 + + Intro.md + SimpleExample.md diff --git a/remote/jar.mn b/remote/jar.mn new file mode 100644 index 0000000000..4c0ae791b0 --- /dev/null +++ b/remote/jar.mn @@ -0,0 +1,84 @@ +# 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/. + +remote.jar: +% content remote %content/ + content/components/Marionette.sys.mjs (components/Marionette.sys.mjs) + content/components/RemoteAgent.sys.mjs (components/RemoteAgent.sys.mjs) + + # transport layer (http / websocket) + content/server/httpd.sys.mjs (../netwerk/test/httpserver/httpd.sys.mjs) + content/server/WebSocketHandshake.sys.mjs (server/WebSocketHandshake.sys.mjs) + content/server/WebSocketTransport.sys.mjs (server/WebSocketTransport.sys.mjs) + + # shared modules (all protocols) + content/shared/AppInfo.sys.mjs (shared/AppInfo.sys.mjs) + content/shared/Browser.sys.mjs (shared/Browser.sys.mjs) + content/shared/Capture.sys.mjs (shared/Capture.sys.mjs) + content/shared/ChallengeHeaderParser.sys.mjs (shared/ChallengeHeaderParser.sys.mjs) + content/shared/DOM.sys.mjs (shared/DOM.sys.mjs) + content/shared/Format.sys.mjs (shared/Format.sys.mjs) + content/shared/Log.sys.mjs (shared/Log.sys.mjs) + content/shared/MobileTabBrowser.sys.mjs (shared/MobileTabBrowser.sys.mjs) + content/shared/Navigate.sys.mjs (shared/Navigate.sys.mjs) + content/shared/NavigationManager.sys.mjs (shared/NavigationManager.sys.mjs) + content/shared/PDF.sys.mjs (shared/PDF.sys.mjs) + content/shared/Prompt.sys.mjs (shared/Prompt.sys.mjs) + content/shared/Realm.sys.mjs (shared/Realm.sys.mjs) + content/shared/RecommendedPreferences.sys.mjs (shared/RecommendedPreferences.sys.mjs) + content/shared/RemoteError.sys.mjs (shared/RemoteError.sys.mjs) + content/shared/Stack.sys.mjs (shared/Stack.sys.mjs) + content/shared/Sync.sys.mjs (shared/Sync.sys.mjs) + content/shared/TabManager.sys.mjs (shared/TabManager.sys.mjs) + content/shared/UserContextManager.sys.mjs (shared/UserContextManager.sys.mjs) + content/shared/UUID.sys.mjs (shared/UUID.sys.mjs) + content/shared/WebSocketConnection.sys.mjs (shared/WebSocketConnection.sys.mjs) + content/shared/WindowManager.sys.mjs (shared/WindowManager.sys.mjs) + content/shared/listeners/BrowsingContextListener.sys.mjs (shared/listeners/BrowsingContextListener.sys.mjs) + content/shared/listeners/ConsoleAPIListener.sys.mjs (shared/listeners/ConsoleAPIListener.sys.mjs) + content/shared/listeners/ConsoleListener.sys.mjs (shared/listeners/ConsoleListener.sys.mjs) + content/shared/listeners/ContextualIdentityListener.sys.mjs (shared/listeners/ContextualIdentityListener.sys.mjs) + content/shared/listeners/LoadListener.sys.mjs (shared/listeners/LoadListener.sys.mjs) + content/shared/listeners/NavigationListener.sys.mjs (shared/listeners/NavigationListener.sys.mjs) + content/shared/listeners/NetworkEventRecord.sys.mjs (shared/listeners/NetworkEventRecord.sys.mjs) + content/shared/listeners/NetworkListener.sys.mjs (shared/listeners/NetworkListener.sys.mjs) + content/shared/listeners/PromptListener.sys.mjs (shared/listeners/PromptListener.sys.mjs) + + # JSWindowActors + content/shared/js-window-actors/NavigationListenerActor.sys.mjs (shared/js-window-actors/NavigationListenerActor.sys.mjs) + content/shared/js-window-actors/NavigationListenerChild.sys.mjs (shared/js-window-actors/NavigationListenerChild.sys.mjs) + content/shared/js-window-actors/NavigationListenerParent.sys.mjs (shared/js-window-actors/NavigationListenerParent.sys.mjs) + + # shared modules (messagehandler architecture) + content/shared/messagehandler/Errors.sys.mjs (shared/messagehandler/Errors.sys.mjs) + content/shared/messagehandler/EventsDispatcher.sys.mjs (shared/messagehandler/EventsDispatcher.sys.mjs) + content/shared/messagehandler/MessageHandler.sys.mjs (shared/messagehandler/MessageHandler.sys.mjs) + content/shared/messagehandler/MessageHandlerRegistry.sys.mjs (shared/messagehandler/MessageHandlerRegistry.sys.mjs) + content/shared/messagehandler/Module.sys.mjs (shared/messagehandler/Module.sys.mjs) + content/shared/messagehandler/ModuleCache.sys.mjs (shared/messagehandler/ModuleCache.sys.mjs) + content/shared/messagehandler/RootMessageHandler.sys.mjs (shared/messagehandler/RootMessageHandler.sys.mjs) + content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs (shared/messagehandler/RootMessageHandlerRegistry.sys.mjs) + content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs (shared/messagehandler/WindowGlobalMessageHandler.sys.mjs) + content/shared/messagehandler/sessiondata/SessionData.sys.mjs (shared/messagehandler/sessiondata/SessionData.sys.mjs) + content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs (shared/messagehandler/sessiondata/SessionDataReader.sys.mjs) + content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs (shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs) + content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs (shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs) + content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs (shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs) + content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs (shared/messagehandler/transports/BrowsingContextUtils.sys.mjs) + content/shared/messagehandler/transports/RootTransport.sys.mjs (shared/messagehandler/transports/RootTransport.sys.mjs) + + # shared modules (WebDriver HTTP / BiDi only) + content/shared/webdriver/Actions.sys.mjs (shared/webdriver/Actions.sys.mjs) + content/shared/webdriver/Assert.sys.mjs (shared/webdriver/Assert.sys.mjs) + content/shared/webdriver/Capabilities.sys.mjs (shared/webdriver/Capabilities.sys.mjs) + content/shared/webdriver/Errors.sys.mjs (shared/webdriver/Errors.sys.mjs) + content/shared/webdriver/KeyData.sys.mjs (shared/webdriver/KeyData.sys.mjs) + content/shared/webdriver/NodeCache.sys.mjs (shared/webdriver/NodeCache.sys.mjs) + content/shared/webdriver/Session.sys.mjs (shared/webdriver/Session.sys.mjs) + content/shared/webdriver/URLPattern.sys.mjs (shared/webdriver/URLPattern.sys.mjs) + content/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs (shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs) + content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs (shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs) + + # imports from external folders + content/external/EventUtils.js (../testing/mochitest/tests/SimpleTest/EventUtils.js) diff --git a/remote/mach_commands.py b/remote/mach_commands.py new file mode 100644 index 0000000000..abf5615ce0 --- /dev/null +++ b/remote/mach_commands.py @@ -0,0 +1,764 @@ +# 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/. + +import argparse +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import tempfile +from collections import OrderedDict + +import mozlog +import mozprofile +from mach.decorators import Command, CommandArgument, SubCommand +from mozbuild import nodeutil +from mozbuild.base import BinaryNotFoundException, MozbuildObject + +EX_CONFIG = 78 +EX_SOFTWARE = 70 +EX_USAGE = 64 + + +def setup(): + # add node and npm from mozbuild to front of system path + npm, _ = nodeutil.find_npm_executable() + if not npm: + exit(EX_CONFIG, "could not find npm executable") + path = os.path.abspath(os.path.join(npm, os.pardir)) + os.environ["PATH"] = "{}{}{}".format(path, os.pathsep, os.environ["PATH"]) + + +def remotedir(command_context): + return os.path.join(command_context.topsrcdir, "remote") + + +@Command("remote", category="misc", description="Remote protocol related operations.") +def remote(command_context): + """The remote subcommands all relate to the remote protocol.""" + command_context._sub_mach(["help", "remote"]) + return 1 + + +@SubCommand( + "remote", "vendor-puppeteer", "Pull in latest changes of the Puppeteer client." +) +@CommandArgument( + "--repository", + metavar="REPO", + default="https://github.com/puppeteer/puppeteer.git", + help="The (possibly local) repository to clone from.", +) +@CommandArgument( + "--commitish", + metavar="COMMITISH", + required=True, + help="The commit or tag object name to check out.", +) +@CommandArgument( + "--no-install", + dest="install", + action="store_false", + default=True, + help="Do not install the just-pulled Puppeteer package,", +) +def vendor_puppeteer(command_context, repository, commitish, install): + puppeteer_dir = os.path.join(remotedir(command_context), "test", "puppeteer") + + # Preserve our custom mocha reporter + shutil.move( + os.path.join(puppeteer_dir, "json-mocha-reporter.js"), + os.path.join(remotedir(command_context), "json-mocha-reporter.js"), + ) + shutil.rmtree(puppeteer_dir, ignore_errors=True) + os.makedirs(puppeteer_dir) + with TemporaryDirectory() as tmpdir: + git("clone", "-q", repository, tmpdir) + git("checkout", commitish, worktree=tmpdir) + git( + "checkout-index", + "-a", + "-f", + "--prefix", + "{}/".format(puppeteer_dir), + worktree=tmpdir, + ) + + # remove files which may interfere with git checkout of central + try: + os.remove(os.path.join(puppeteer_dir, ".gitattributes")) + os.remove(os.path.join(puppeteer_dir, ".gitignore")) + except OSError: + pass + + unwanted_dirs = ["experimental", "docs"] + + for dir in unwanted_dirs: + dir_path = os.path.join(puppeteer_dir, dir) + if os.path.isdir(dir_path): + shutil.rmtree(dir_path) + + shutil.move( + os.path.join(remotedir(command_context), "json-mocha-reporter.js"), + puppeteer_dir, + ) + + import yaml + + annotation = { + "schema": 1, + "bugzilla": { + "product": "Remote Protocol", + "component": "Agent", + }, + "origin": { + "name": "puppeteer", + "description": "Headless Chrome Node API", + "url": repository, + "license": "Apache-2.0", + "release": commitish, + }, + } + with open(os.path.join(puppeteer_dir, "moz.yaml"), "w") as fh: + yaml.safe_dump( + annotation, + fh, + default_flow_style=False, + encoding="utf-8", + allow_unicode=True, + ) + + if install: + env = { + "CI": "1", # Force the quiet logger of wireit + "HUSKY": "0", # Disable any hook checks + "PUPPETEER_SKIP_DOWNLOAD": "1", # Don't download any build + } + + run_npm( + "install", + cwd=os.path.join(command_context.topsrcdir, puppeteer_dir), + env=env, + ) + + +def git(*args, **kwargs): + cmd = ("git",) + if kwargs.get("worktree"): + cmd += ("-C", kwargs["worktree"]) + cmd += args + + pipe = kwargs.get("pipe") + git_p = subprocess.Popen( + cmd, + env={"GIT_CONFIG_NOSYSTEM": "1"}, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + pipe_p = None + if pipe: + pipe_p = subprocess.Popen(pipe, stdin=git_p.stdout, stderr=subprocess.PIPE) + + if pipe: + _, pipe_err = pipe_p.communicate() + out, git_err = git_p.communicate() + + # use error from first program that failed + if git_p.returncode > 0: + exit(EX_SOFTWARE, git_err) + if pipe and pipe_p.returncode > 0: + exit(EX_SOFTWARE, pipe_err) + + return out + + +def run_npm(*args, **kwargs): + from mozprocess import run_and_wait + + def output_timeout_handler(proc): + # In some cases, we wait longer for a mocha timeout + print( + "Timed out after {} seconds of no output".format(kwargs["output_timeout"]) + ) + + env = os.environ.copy() + npm, _ = nodeutil.find_npm_executable() + if kwargs.get("env"): + env.update(kwargs["env"]) + + proc_kwargs = {"output_timeout_handler": output_timeout_handler} + for kw in ["output_line_handler", "output_timeout"]: + if kw in kwargs: + proc_kwargs[kw] = kwargs[kw] + + cmd = [npm] + cmd.extend(list(args)) + + p = run_and_wait( + args=cmd, + cwd=kwargs.get("cwd"), + env=env, + text=True, + **proc_kwargs, + ) + post_wait_proc(p, cmd=npm, exit_on_fail=kwargs.get("exit_on_fail", True)) + + return p.returncode + + +def post_wait_proc(p, cmd=None, exit_on_fail=True): + if p.poll() is None: + p.kill() + if exit_on_fail and p.returncode > 0: + msg = ( + "%s: exit code %s" % (cmd, p.returncode) + if cmd + else "exit code %s" % p.returncode + ) + exit(p.returncode, msg) + + +class MochaOutputHandler(object): + def __init__(self, logger, expected): + self.hook_re = re.compile('"before\b?.*" hook|"after\b?.*" hook') + + self.logger = logger + self.proc = None + self.test_results = OrderedDict() + self.expected = expected + self.unexpected_skips = set() + + self.has_unexpected = False + self.logger.suite_start([], name="puppeteer-tests") + self.status_map = { + "CRASHED": "CRASH", + "OK": "PASS", + "TERMINATED": "CRASH", + "pass": "PASS", + "fail": "FAIL", + "pending": "SKIP", + } + + @property + def pid(self): + return self.proc and self.proc.pid + + def __call__(self, proc, line): + self.proc = proc + line = line.rstrip("\r\n") + event = None + try: + if line.startswith("[") and line.endswith("]"): + event = json.loads(line) + self.process_event(event) + except ValueError: + pass + finally: + self.logger.process_output(self.pid, line, command="npm") + + def testExpectation(self, testIdPattern, expected_name): + if testIdPattern.find("*") == -1: + return expected_name == testIdPattern + else: + return re.compile(re.escape(testIdPattern).replace(r"\*", ".*")).search( + expected_name + ) + + def process_event(self, event): + if isinstance(event, list) and len(event) > 1: + status = self.status_map.get(event[0]) + test_start = event[0] == "test-start" + if not status and not test_start: + return + test_info = event[1] + test_full_title = test_info.get("fullTitle", "") + test_name = test_full_title + test_path = test_info.get("file", "") + test_file_name = os.path.basename(test_path).replace(".js", "") + test_err = test_info.get("err") + if status == "FAIL" and test_err: + if "timeout" in test_err.lower(): + status = "TIMEOUT" + if test_name and test_path: + test_name = "{} ({})".format(test_name, os.path.basename(test_path)) + # mocha hook failures are not tracked in metadata + if status != "PASS" and self.hook_re.search(test_name): + self.logger.error("TEST-UNEXPECTED-ERROR %s" % (test_name,)) + return + if test_start: + self.logger.test_start(test_name) + return + expected_name = "[{}] {}".format(test_file_name, test_full_title) + expected_item = next( + ( + expectation + for expectation in reversed(list(self.expected)) + if self.testExpectation(expectation["testIdPattern"], expected_name) + ), + None, + ) + if expected_item is None: + expected = ["PASS"] + else: + expected = expected_item["expectations"] + # mozlog doesn't really allow unexpected skip, + # so if a test is disabled just expect that and note the unexpected skip + # Also, mocha doesn't log test-start for skipped tests + if status == "SKIP": + self.logger.test_start(test_name) + if self.expected and status not in expected: + self.unexpected_skips.add(test_name) + expected = ["SKIP"] + known_intermittent = expected[1:] + expected_status = expected[0] + + # check if we've seen a result for this test before this log line + result_recorded = self.test_results.get(test_name) + if result_recorded: + self.logger.warning( + "Received a second status for {}: " + "first {}, now {}".format(test_name, result_recorded, status) + ) + # mocha intermittently logs an additional test result after the + # test has already timed out. Avoid recording this second status. + if result_recorded != "TIMEOUT": + self.test_results[test_name] = status + if status not in expected: + self.has_unexpected = True + self.logger.test_end( + test_name, + status=status, + expected=expected_status, + known_intermittent=known_intermittent, + ) + + def after_end(self): + if self.unexpected_skips: + self.has_unexpected = True + for test_name in self.unexpected_skips: + self.logger.error( + "TEST-UNEXPECTED-MISSING Unexpected skipped %s" % (test_name,) + ) + self.logger.suite_end() + + +# tempfile.TemporaryDirectory missing from Python 2.7 +class TemporaryDirectory(object): + def __init__(self): + self.path = tempfile.mkdtemp() + self._closed = False + + def __repr__(self): + return "<{} {!r}>".format(self.__class__.__name__, self.path) + + def __enter__(self): + return self.path + + def __exit__(self, exc, value, tb): + self.clean() + + def __del__(self): + self.clean() + + def clean(self): + if self.path and not self._closed: + shutil.rmtree(self.path) + self._closed = True + + +class PuppeteerRunner(MozbuildObject): + def __init__(self, *args, **kwargs): + super(PuppeteerRunner, self).__init__(*args, **kwargs) + + self.remotedir = os.path.join(self.topsrcdir, "remote") + self.puppeteer_dir = os.path.join(self.remotedir, "test", "puppeteer") + + def run_test(self, logger, *tests, **params): + """ + Runs Puppeteer unit tests with npm. + + Possible optional test parameters: + + `binary`: + Path for the browser binary to use. Defaults to the local + build. + `cdp`: + Boolean to indicate whether to test Firefox with CDP protocol. + `headless`: + Boolean to indicate whether to activate Firefox' headless mode. + `extra_prefs`: + Dictionary of extra preferences to write to the profile, + before invoking npm. Overrides default preferences. + `enable_webrender`: + Boolean to indicate whether to enable WebRender compositor in Gecko. + """ + setup() + + binary = params.get("binary") or self.get_binary_path() + headless = params.get("headless", False) + product = params.get("product", "firefox") + with_cdp = params.get("cdp", False) + + extra_options = {} + for k, v in params.get("extra_launcher_options", {}).items(): + extra_options[k] = json.loads(v) + + # Override upstream defaults: no retries, shorter timeout + mocha_options = [ + "--reporter", + "./json-mocha-reporter.js", + "--retries", + "0", + "--fullTrace", + "--timeout", + "20000", + "--no-parallel", + "--no-coverage", + ] + + env = { + # Checked by Puppeteer's custom mocha config + "CI": "1", + # Print browser process ouptut + "DUMPIO": "1", + # Run in headless mode if trueish, otherwise use headful + "HEADLESS": str(headless), + # Causes some tests to be skipped due to assumptions about install + "PUPPETEER_ALT_INSTALL": "1", + } + + if product == "firefox": + env["BINARY"] = binary + env["PUPPETEER_PRODUCT"] = "firefox" + env["MOZ_WEBRENDER"] = "%d" % params.get("enable_webrender", False) + else: + env["PUPPETEER_CACHE_DIR"] = os.path.join( + self.topobjdir, + "_tests", + "remote", + "test", + "puppeteer", + ".cache", + ) + + test_command = "test:" + product + + if with_cdp: + if headless: + test_command = test_command + ":headless" + else: + test_command = test_command + ":headful" + else: + if headless: + test_command = test_command + ":bidi" + else: + if product == "chrome": + raise Exception( + "Chrome doesn't support headful mode with the WebDriver BiDi protocol" + ) + + test_command = test_command + ":bidi:headful" + + command = ["run", test_command, "--"] + mocha_options + + prefs = {} + for k, v in params.get("extra_prefs", {}).items(): + print("Using extra preference: {}={}".format(k, v)) + prefs[k] = mozprofile.Preferences.cast(v) + + if prefs: + extra_options["extraPrefsFirefox"] = prefs + + if extra_options: + env["EXTRA_LAUNCH_OPTIONS"] = json.dumps(extra_options) + + expected_path = os.path.join( + os.path.dirname(__file__), + "test", + "puppeteer", + "test", + "TestExpectations.json", + ) + if os.path.exists(expected_path): + with open(expected_path) as f: + expected_data = json.load(f) + else: + expected_data = [] + + expected_platform = platform.uname().system.lower() + if expected_platform == "windows": + expected_platform = "win32" + + # Filter expectation data for the selected browser, + # headless or headful mode, the operating system, + # run in BiDi mode or not. + expectations = [ + expectation + for expectation in expected_data + if is_relevant_expectation( + expectation, product, with_cdp, env["HEADLESS"], expected_platform + ) + ] + + output_handler = MochaOutputHandler(logger, expectations) + run_npm( + *command, + cwd=self.puppeteer_dir, + env=env, + output_line_handler=output_handler, + # Puppeteer unit tests don't always clean-up child processes in case of + # failure, so use an output_timeout as a fallback + output_timeout=60, + exit_on_fail=True, + ) + + output_handler.after_end() + + if output_handler.has_unexpected: + logger.error("Got unexpected results") + exit(1) + + +def create_parser_puppeteer(): + p = argparse.ArgumentParser() + p.add_argument( + "--product", type=str, default="firefox", choices=["chrome", "firefox"] + ) + p.add_argument( + "--binary", + type=str, + help="Path to browser binary. Defaults to local Firefox build.", + ) + p.add_argument( + "--cdp", + action="store_true", + help="Flag that indicates whether to test Firefox with the CDP protocol.", + ) + p.add_argument( + "--ci", + action="store_true", + help="Flag that indicates that tests run in a CI environment.", + ) + p.add_argument( + "--disable-fission", + action="store_true", + default=False, + dest="disable_fission", + help="Disable Fission (site isolation) in Gecko.", + ) + p.add_argument( + "--enable-webrender", + action="store_true", + help="Enable the WebRender compositor in Gecko.", + ) + p.add_argument( + "-z", "--headless", action="store_true", help="Run browser in headless mode." + ) + p.add_argument( + "--setpref", + action="append", + dest="extra_prefs", + metavar="<pref>=<value>", + help="Defines additional user preferences.", + ) + p.add_argument( + "--setopt", + action="append", + dest="extra_options", + metavar="<option>=<value>", + help="Defines additional options for `puppeteer.launch`.", + ) + p.add_argument( + "-v", + dest="verbosity", + action="count", + default=0, + help="Increase remote agent logging verbosity to include " + "debug level messages with -v, trace messages with -vv," + "and to not truncate long trace messages with -vvv", + ) + p.add_argument("tests", nargs="*") + mozlog.commandline.add_logging_group(p) + return p + + +def is_relevant_expectation( + expectation, expected_product, with_cdp, is_headless, expected_platform +): + parameters = expectation["parameters"] + + if expected_product == "firefox": + is_expected_product = "chrome" not in parameters + else: + is_expected_product = "firefox" not in parameters + + if with_cdp: + is_expected_protocol = "webDriverBiDi" not in parameters + else: + is_expected_protocol = "cdp" not in parameters + is_headless = "True" + + if is_headless == "True": + is_expected_mode = "headful" not in parameters + else: + is_expected_mode = "headless" not in parameters + + is_expected_platform = expected_platform in expectation["platforms"] + + return ( + is_expected_product + and is_expected_protocol + and is_expected_mode + and is_expected_platform + ) + + +@Command( + "puppeteer-test", + category="testing", + description="Run Puppeteer unit tests.", + parser=create_parser_puppeteer, +) +@CommandArgument( + "--no-install", + dest="install", + action="store_false", + default=True, + help="Do not install the Puppeteer package", +) +def puppeteer_test( + command_context, + binary=None, + cdp=False, + ci=False, + disable_fission=False, + enable_webrender=False, + headless=False, + extra_prefs=None, + extra_options=None, + install=False, + verbosity=0, + tests=None, + product="firefox", + **kwargs, +): + logger = mozlog.commandline.setup_logging( + "puppeteer-test", kwargs, {"mach": sys.stdout} + ) + + # moztest calls this programmatically with test objects or manifests + if "test_objects" in kwargs and tests is not None: + logger.error("Expected either 'test_objects' or 'tests'") + exit(1) + + if product != "firefox" and extra_prefs is not None: + logger.error("User preferences are not recognized by %s" % product) + exit(1) + + if "test_objects" in kwargs: + tests = [] + for test in kwargs["test_objects"]: + tests.append(test["path"]) + + prefs = {} + for s in extra_prefs or []: + kv = s.split("=") + if len(kv) != 2: + logger.error("syntax error in --setpref={}".format(s)) + exit(EX_USAGE) + prefs[kv[0]] = kv[1].strip() + + options = {} + for s in extra_options or []: + kv = s.split("=") + if len(kv) != 2: + logger.error("syntax error in --setopt={}".format(s)) + exit(EX_USAGE) + options[kv[0]] = kv[1].strip() + + prefs.update({"fission.autostart": True}) + if disable_fission: + prefs.update({"fission.autostart": False}) + + if verbosity == 1: + prefs["remote.log.level"] = "Debug" + elif verbosity > 1: + prefs["remote.log.level"] = "Trace" + if verbosity > 2: + prefs["remote.log.truncate"] = False + + if install: + install_puppeteer(command_context, product, ci) + + params = { + "binary": binary, + "cdp": cdp, + "headless": headless, + "enable_webrender": enable_webrender, + "extra_prefs": prefs, + "product": product, + "extra_launcher_options": options, + } + puppeteer = command_context._spawn(PuppeteerRunner) + try: + return puppeteer.run_test(logger, *tests, **params) + except BinaryNotFoundException as e: + logger.error(e) + logger.info(e.help()) + exit(1) + except Exception as e: + exit(EX_SOFTWARE, e) + + +def install_puppeteer(command_context, product, ci): + setup() + + env = { + "CI": "1", # Force the quiet logger of wireit + "HUSKY": "0", # Disable any hook checks + } + + puppeteer_dir = os.path.join("remote", "test", "puppeteer") + puppeteer_dir_full_path = os.path.join(command_context.topsrcdir, puppeteer_dir) + puppeteer_test_dir = os.path.join(puppeteer_dir, "test") + + if product == "chrome": + env["PUPPETEER_CACHE_DIR"] = os.path.join( + command_context.topobjdir, "_tests", puppeteer_dir, ".cache" + ) + else: + env["PUPPETEER_SKIP_DOWNLOAD"] = "1" + + if not ci: + run_npm( + "run", + "clean", + cwd=puppeteer_dir_full_path, + env=env, + exit_on_fail=False, + ) + + # Always use the `ci` command to not get updated sub-dependencies installed. + run_npm("ci", cwd=puppeteer_dir_full_path, env=env) + run_npm( + "run", + "build", + cwd=os.path.join(command_context.topsrcdir, puppeteer_test_dir), + env=env, + ) + + +def exit(code, error=None): + if error is not None: + if isinstance(error, Exception): + import traceback + + traceback.print_exc() + else: + message = str(error).split("\n")[0].strip() + print("{}: {}".format(sys.argv[0], message), file=sys.stderr) + sys.exit(code) diff --git a/remote/marionette/.eslintrc.js b/remote/marionette/.eslintrc.js new file mode 100644 index 0000000000..64a8883c43 --- /dev/null +++ b/remote/marionette/.eslintrc.js @@ -0,0 +1,14 @@ +/* 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"; + +// inherits from ../../tools/lint/eslint/eslint-plugin-mozilla/lib/configs/recommended.js + +module.exports = { + rules: { + camelcase: ["error", { properties: "never" }], + "no-var": "error", + }, +}; diff --git a/remote/marionette/README b/remote/marionette/README new file mode 100644 index 0000000000..d077a5136c --- /dev/null +++ b/remote/marionette/README @@ -0,0 +1,20 @@ +Marionette [ ˌmarɪəˈnɛt] is + + * a puppet worked by strings: the bird bobs up and down like + a marionette; + + * a person who is easily manipulated or controlled: many officers + dismissed him as the mayor’s marionette; + + * the remote protocol that lets out-of-process programs communicate + with, instrument, and control Gecko-based browsers. + +Marionette provides interfaces for interacting with both the internal +JavaScript runtime and UI elements of Gecko-based browsers, such +as Firefox on desktop and mobile. It can control both the chrome- and content +documents, giving a high level of control and ability to replicate, +or emulate, user interaction. + +Head on to the Marionette documentation to find out more: + + https://firefox-source-docs.mozilla.org/testing/marionette/ diff --git a/remote/marionette/accessibility.sys.mjs b/remote/marionette/accessibility.sys.mjs new file mode 100644 index 0000000000..c500f2121e --- /dev/null +++ b/remote/marionette/accessibility.sys.mjs @@ -0,0 +1,479 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +ChromeUtils.defineLazyGetter(lazy, "service", () => { + try { + return Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + } catch (e) { + lazy.logger.warn("Accessibility module is not present"); + return undefined; + } +}); + +/** @namespace */ +export const accessibility = { + get service() { + return lazy.service; + }, +}; + +/** + * Accessible states used to check element"s state from the accessiblity API + * perspective. + * + * Note: if gecko is built with --disable-accessibility, the interfaces + * are not defined. This is why we use getters instead to be able to use + * these statically. + */ +accessibility.State = { + get Unavailable() { + return Ci.nsIAccessibleStates.STATE_UNAVAILABLE; + }, + get Focusable() { + return Ci.nsIAccessibleStates.STATE_FOCUSABLE; + }, + get Selectable() { + return Ci.nsIAccessibleStates.STATE_SELECTABLE; + }, + get Selected() { + return Ci.nsIAccessibleStates.STATE_SELECTED; + }, +}; + +/** + * Accessible object roles that support some action. + */ +accessibility.ActionableRoles = new Set([ + "checkbutton", + "check menu item", + "check rich option", + "combobox", + "combobox option", + "entry", + "key", + "link", + "listbox option", + "listbox rich option", + "menuitem", + "option", + "outlineitem", + "pagetab", + "pushbutton", + "radiobutton", + "radio menu item", + "rowheader", + "slider", + "spinbutton", + "switch", +]); + +/** + * Factory function that constructs a new {@code accessibility.Checks} + * object with enforced strictness or not. + */ +accessibility.get = function (strict = false) { + return new accessibility.Checks(!!strict); +}; + +/** + * Wait for the document accessibility state to be different from STATE_BUSY. + * + * @param {Document} doc + * The document to wait for. + * @returns {Promise} + * A promise which resolves when the document's accessibility state is no + * longer busy. + */ +function waitForDocumentAccessibility(doc) { + const documentAccessible = accessibility.service.getAccessibleFor(doc); + const state = {}; + documentAccessible.getState(state, {}); + if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) { + return Promise.resolve(); + } + + // Accessibility for the doc is busy, so wait for the state to change. + return lazy.waitForObserverTopic("accessible-event", { + checkFn: subject => { + // If event type does not match expected type, skip the event. + // If event's accessible does not match expected accessible, + // skip the event. + const event = subject.QueryInterface(Ci.nsIAccessibleEvent); + return ( + event.eventType === Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE && + event.accessible === documentAccessible + ); + }, + }); +} + +/** + * Retrieve the Accessible for the provided element. + * + * @param {Element} element + * The element for which we need to retrieve the accessible. + * + * @returns {nsIAccessible|null} + * The Accessible object corresponding to the provided element or null if + * the accessibility service is not available. + */ +accessibility.getAccessible = async function (element) { + if (!accessibility.service) { + return null; + } + + // First, wait for accessibility to be ready for the element's document. + await waitForDocumentAccessibility(element.ownerDocument); + + const acc = accessibility.service.getAccessibleFor(element); + if (acc) { + return acc; + } + + // The Accessible doesn't exist yet. This can happen because a11y tree + // mutations happen during refresh driver ticks. Stop the refresh driver from + // doing its regular ticks and force two refresh driver ticks: the first to + // let layout update and notify a11y, and the second to let a11y process + // updates. + const windowUtils = element.ownerGlobal.windowUtils; + windowUtils.advanceTimeAndRefresh(0); + windowUtils.advanceTimeAndRefresh(0); + // Go back to normal refresh driver ticks. + windowUtils.restoreNormalRefresh(); + return accessibility.service.getAccessibleFor(element); +}; + +/** + * Component responsible for interacting with platform accessibility + * API. + * + * Its methods serve as wrappers for testing content and chrome + * accessibility as well as accessibility of user interactions. + */ +accessibility.Checks = class { + /** + * @param {boolean} strict + * Flag indicating whether the accessibility issue should be logged + * or cause an error to be thrown. Default is to log to stdout. + */ + constructor(strict) { + this.strict = strict; + } + + /** + * Assert that the element has a corresponding accessible object, and retrieve + * this accessible. Note that if the accessibility.Checks component was + * created in non-strict mode, this helper will not attempt to resolve the + * accessible at all and will simply return null. + * + * @param {DOMElement|XULElement} element + * Element to get the accessible object for. + * @param {boolean=} mustHaveAccessible + * Flag indicating that the element must have an accessible object. + * Defaults to not require this. + * + * @returns {Promise.<nsIAccessible>} + * Promise with an accessibility object for the given element. + */ + async assertAccessible(element, mustHaveAccessible = false) { + if (!this.strict) { + return null; + } + + const accessible = await accessibility.getAccessible(element); + if (!accessible && mustHaveAccessible) { + this.error("Element does not have an accessible object", element); + } + + return accessible; + } + + /** + * Test if the accessible has a role that supports some arbitrary + * action. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @returns {boolean} + * True if an actionable role is found on the accessible, false + * otherwise. + */ + isActionableRole(accessible) { + return accessibility.ActionableRoles.has( + accessibility.service.getStringRole(accessible.role) + ); + } + + /** + * Test if an accessible has at least one action that it supports. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @returns {boolean} + * True if the accessible has at least one supported action, + * false otherwise. + */ + hasActionCount(accessible) { + return accessible.actionCount > 0; + } + + /** + * Test if an accessible has a valid name. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @returns {boolean} + * True if the accessible has a non-empty valid name, or false if + * this is not the case. + */ + hasValidName(accessible) { + return accessible.name && accessible.name.trim(); + } + + /** + * Test if an accessible has a {@code hidden} attribute. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @returns {boolean} + * True if the accessible object has a {@code hidden} attribute, + * false otherwise. + */ + hasHiddenAttribute(accessible) { + let hidden = false; + try { + hidden = accessible.attributes.getStringProperty("hidden"); + } catch (e) {} + // if the property is missing, error will be thrown + return hidden && hidden === "true"; + } + + /** + * Verify if an accessible has a given state. + * Test if an accessible has a given state. + * + * @param {nsIAccessible} accessible + * Accessible object to test. + * @param {number} stateToMatch + * State to match. + * + * @returns {boolean} + * True if |accessible| has |stateToMatch|, false otherwise. + */ + matchState(accessible, stateToMatch) { + let state = {}; + accessible.getState(state, {}); + return !!(state.value & stateToMatch); + } + + /** + * Test if an accessible is hidden from the user. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @returns {boolean} + * True if element is hidden from user, false otherwise. + */ + isHidden(accessible) { + if (!accessible) { + return true; + } + + while (accessible) { + if (this.hasHiddenAttribute(accessible)) { + return true; + } + accessible = accessible.parent; + } + return false; + } + + /** + * Test if the element's visible state corresponds to its accessibility + * API visibility. + * + * @param {nsIAccessible} accessible + * Accessible object. + * @param {DOMElement|XULElement} element + * Element associated with |accessible|. + * @param {boolean} visible + * Visibility state of |element|. + * + * @throws ElementNotAccessibleError + * If |element|'s visibility state does not correspond to + * |accessible|'s. + */ + assertVisible(accessible, element, visible) { + let hiddenAccessibility = this.isHidden(accessible); + + let message; + if (visible && hiddenAccessibility) { + message = + "Element is not currently visible via the accessibility API " + + "and may not be manipulated by it"; + } else if (!visible && !hiddenAccessibility) { + message = + "Element is currently only visible via the accessibility API " + + "and can be manipulated by it"; + } + this.error(message, element); + } + + /** + * Test if the element's unavailable accessibility state matches the + * enabled state. + * + * @param {nsIAccessible} accessible + * Accessible object. + * @param {DOMElement|XULElement} element + * Element associated with |accessible|. + * @param {boolean} enabled + * Enabled state of |element|. + * + * @throws ElementNotAccessibleError + * If |element|'s enabled state does not match |accessible|'s. + */ + assertEnabled(accessible, element, enabled) { + if (!accessible) { + return; + } + + let win = element.ownerGlobal; + let disabledAccessibility = this.matchState( + accessible, + accessibility.State.Unavailable + ); + let explorable = + win.getComputedStyle(element).getPropertyValue("pointer-events") !== + "none"; + + let message; + if (!explorable && !disabledAccessibility) { + message = + "Element is enabled but is not explorable via the " + + "accessibility API"; + } else if (enabled && disabledAccessibility) { + message = "Element is enabled but disabled via the accessibility API"; + } else if (!enabled && !disabledAccessibility) { + message = "Element is disabled but enabled via the accessibility API"; + } + this.error(message, element); + } + + /** + * Test if it is possible to activate an element with the accessibility + * API. + * + * @param {nsIAccessible} accessible + * Accessible object. + * @param {DOMElement|XULElement} element + * Element associated with |accessible|. + * + * @throws ElementNotAccessibleError + * If it is impossible to activate |element| with |accessible|. + */ + assertActionable(accessible, element) { + if (!accessible) { + return; + } + + let message; + if (!this.hasActionCount(accessible)) { + message = "Element does not support any accessible actions"; + } else if (!this.isActionableRole(accessible)) { + message = + "Element does not have a correct accessibility role " + + "and may not be manipulated via the accessibility API"; + } else if (!this.hasValidName(accessible)) { + message = "Element is missing an accessible name"; + } else if (!this.matchState(accessible, accessibility.State.Focusable)) { + message = "Element is not focusable via the accessibility API"; + } + + this.error(message, element); + } + + /** + * Test that an element's selected state corresponds to its + * accessibility API selected state. + * + * @param {nsIAccessible} accessible + * Accessible object. + * @param {DOMElement|XULElement} element + * Element associated with |accessible|. + * @param {boolean} selected + * The |element|s selected state. + * + * @throws ElementNotAccessibleError + * If |element|'s selected state does not correspond to + * |accessible|'s. + */ + assertSelected(accessible, element, selected) { + if (!accessible) { + return; + } + + // element is not selectable via the accessibility API + if (!this.matchState(accessible, accessibility.State.Selectable)) { + return; + } + + let selectedAccessibility = this.matchState( + accessible, + accessibility.State.Selected + ); + + let message; + if (selected && !selectedAccessibility) { + message = + "Element is selected but not selected via the accessibility API"; + } else if (!selected && selectedAccessibility) { + message = + "Element is not selected but selected via the accessibility API"; + } + this.error(message, element); + } + + /** + * Throw an error if strict accessibility checks are enforced and log + * the error to the log. + * + * @param {string} message + * @param {DOMElement|XULElement} element + * Element that caused an error. + * + * @throws ElementNotAccessibleError + * If |strict| is true. + */ + error(message, element) { + if (!message || !this.strict) { + return; + } + if (element) { + let { id, tagName, className } = element; + message += `: id: ${id}, tagName: ${tagName}, className: ${className}`; + } + + throw new lazy.error.ElementNotAccessibleError(message); + } +}; diff --git a/remote/marionette/actors/MarionetteCommandsChild.sys.mjs b/remote/marionette/actors/MarionetteCommandsChild.sys.mjs new file mode 100644 index 0000000000..078612da56 --- /dev/null +++ b/remote/marionette/actors/MarionetteCommandsChild.sys.mjs @@ -0,0 +1,595 @@ +/* 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/. */ + +/* eslint-disable no-restricted-globals */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs", + action: "chrome://remote/content/shared/webdriver/Actions.sys.mjs", + atom: "chrome://remote/content/marionette/atom.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + evaluate: "chrome://remote/content/marionette/evaluate.sys.mjs", + interaction: "chrome://remote/content/marionette/interaction.sys.mjs", + json: "chrome://remote/content/marionette/json.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + sandbox: "chrome://remote/content/marionette/evaluate.sys.mjs", + Sandboxes: "chrome://remote/content/marionette/evaluate.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +export class MarionetteCommandsChild extends JSWindowActorChild { + #processActor; + + constructor() { + super(); + + this.#processActor = ChromeUtils.domProcessChild.getActor( + "WebDriverProcessData" + ); + + // sandbox storage and name of the current sandbox + this.sandboxes = new lazy.Sandboxes(() => this.document.defaultView); + // State of the input actions. This is specific to contexts and sessions + this.actionState = null; + } + + get innerWindowId() { + return this.manager.innerWindowId; + } + + actorCreated() { + lazy.logger.trace( + `[${this.browsingContext.id}] MarionetteCommands actor created ` + + `for window id ${this.innerWindowId}` + ); + } + + didDestroy() { + lazy.logger.trace( + `[${this.browsingContext.id}] MarionetteCommands actor destroyed ` + + `for window id ${this.innerWindowId}` + ); + } + + async receiveMessage(msg) { + if (!this.contentWindow) { + throw new DOMException("Actor is no longer active", "InactiveActor"); + } + + try { + let result; + let waitForNextTick = false; + + const { name, data: serializedData } = msg; + + const data = lazy.json.deserialize( + serializedData, + this.#processActor.getNodeCache(), + this.contentWindow.browsingContext + ); + + switch (name) { + case "MarionetteCommandsParent:clearElement": + this.clearElement(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:clickElement": + result = await this.clickElement(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:executeScript": + result = await this.executeScript(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:findElement": + result = await this.findElement(data); + break; + case "MarionetteCommandsParent:findElements": + result = await this.findElements(data); + break; + case "MarionetteCommandsParent:getActiveElement": + result = await this.getActiveElement(); + break; + case "MarionetteCommandsParent:getComputedLabel": + result = await this.getComputedLabel(data); + break; + case "MarionetteCommandsParent:getComputedRole": + result = await this.getComputedRole(data); + break; + case "MarionetteCommandsParent:getElementAttribute": + result = await this.getElementAttribute(data); + break; + case "MarionetteCommandsParent:getElementProperty": + result = await this.getElementProperty(data); + break; + case "MarionetteCommandsParent:getElementRect": + result = await this.getElementRect(data); + break; + case "MarionetteCommandsParent:getElementTagName": + result = await this.getElementTagName(data); + break; + case "MarionetteCommandsParent:getElementText": + result = await this.getElementText(data); + break; + case "MarionetteCommandsParent:getElementValueOfCssProperty": + result = await this.getElementValueOfCssProperty(data); + break; + case "MarionetteCommandsParent:getPageSource": + result = await this.getPageSource(); + break; + case "MarionetteCommandsParent:getScreenshotRect": + result = await this.getScreenshotRect(data); + break; + case "MarionetteCommandsParent:getShadowRoot": + result = await this.getShadowRoot(data); + break; + case "MarionetteCommandsParent:isElementDisplayed": + result = await this.isElementDisplayed(data); + break; + case "MarionetteCommandsParent:isElementEnabled": + result = await this.isElementEnabled(data); + break; + case "MarionetteCommandsParent:isElementSelected": + result = await this.isElementSelected(data); + break; + case "MarionetteCommandsParent:performActions": + result = await this.performActions(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:releaseActions": + result = await this.releaseActions(); + break; + case "MarionetteCommandsParent:sendKeysToElement": + result = await this.sendKeysToElement(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:switchToFrame": + result = await this.switchToFrame(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:switchToParentFrame": + result = await this.switchToParentFrame(); + waitForNextTick = true; + break; + } + + // Inform the content process that the command has completed. It allows + // it to process async follow-up tasks before the reply is sent. + if (waitForNextTick) { + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + } + + const { seenNodeIds, serializedValue, hasSerializedWindows } = + lazy.json.clone(result, this.#processActor.getNodeCache()); + + // Because in WebDriver classic nodes can only be returned from the same + // browsing context, we only need the seen unique ids as flat array. + return { + seenNodeIds: [...seenNodeIds.values()].flat(), + serializedValue, + hasSerializedWindows, + }; + } catch (e) { + // Always wrap errors as WebDriverError + return { error: lazy.error.wrap(e).toJSON() }; + } + } + + // Implementation of WebDriver commands + + /** Clear the text of an element. + * + * @param {object} options + * @param {Element} options.elem + */ + clearElement(options = {}) { + const { elem } = options; + + lazy.interaction.clearElement(elem); + } + + /** + * Click an element. + */ + async clickElement(options = {}) { + const { capabilities, elem } = options; + + return lazy.interaction.clickElement( + elem, + capabilities["moz:accessibilityChecks"], + capabilities["moz:webdriverClick"] + ); + } + + /** + * Executes a JavaScript function. + */ + async executeScript(options = {}) { + const { args, opts = {}, script } = options; + + let sb; + if (opts.sandboxName) { + sb = this.sandboxes.get(opts.sandboxName, opts.newSandbox); + } else { + sb = lazy.sandbox.createMutable(this.document.defaultView); + } + + return lazy.evaluate.sandbox(sb, script, args, opts); + } + + /** + * Find an element in the current browsing context's document using the + * given search strategy. + * + * @param {object=} options + * @param {string} options.strategy + * @param {string} options.selector + * @param {object} options.opts + * @param {Element} options.opts.startNode + * + */ + async findElement(options = {}) { + const { strategy, selector, opts } = options; + + opts.all = false; + + const container = { frame: this.document.defaultView }; + return lazy.dom.find(container, strategy, selector, opts); + } + + /** + * Find elements in the current browsing context's document using the + * given search strategy. + * + * @param {object=} options + * @param {string} options.strategy + * @param {string} options.selector + * @param {object} options.opts + * @param {Element} options.opts.startNode + * + */ + async findElements(options = {}) { + const { strategy, selector, opts } = options; + + opts.all = true; + + const container = { frame: this.document.defaultView }; + return lazy.dom.find(container, strategy, selector, opts); + } + + /** + * Return the active element in the document. + */ + async getActiveElement() { + let elem = this.document.activeElement; + if (!elem) { + throw new lazy.error.NoSuchElementError(); + } + + return elem; + } + + /** + * Return the accessible label for a given element. + */ + async getComputedLabel(options = {}) { + const { elem } = options; + + const accessible = await lazy.accessibility.getAccessible(elem); + if (!accessible) { + return null; + } + + // If name is null (absent), expose the empty string. + if (accessible.name === null) { + return ""; + } + + return accessible.name; + } + + /** + * Return the accessible role for a given element. + */ + async getComputedRole(options = {}) { + const { elem } = options; + + const accessible = await lazy.accessibility.getAccessible(elem); + if (!accessible) { + // If it's not in the a11y tree, it's probably presentational. + return "none"; + } + + return accessible.computedARIARole; + } + + /** + * Get the value of an attribute for the given element. + */ + async getElementAttribute(options = {}) { + const { name, elem } = options; + + if (lazy.dom.isBooleanAttribute(elem, name)) { + if (elem.hasAttribute(name)) { + return "true"; + } + return null; + } + return elem.getAttribute(name); + } + + /** + * Get the value of a property for the given element. + */ + async getElementProperty(options = {}) { + const { name, elem } = options; + + // Waive Xrays to get unfiltered access to the untrusted element. + const el = Cu.waiveXrays(elem); + return typeof el[name] != "undefined" ? el[name] : null; + } + + /** + * Get the position and dimensions of the element. + */ + async getElementRect(options = {}) { + const { elem } = options; + + const rect = elem.getBoundingClientRect(); + return { + x: rect.x + this.document.defaultView.pageXOffset, + y: rect.y + this.document.defaultView.pageYOffset, + width: rect.width, + height: rect.height, + }; + } + + /** + * Get the tagName for the given element. + */ + async getElementTagName(options = {}) { + const { elem } = options; + + return elem.tagName.toLowerCase(); + } + + /** + * Get the text content for the given element. + */ + async getElementText(options = {}) { + const { elem } = options; + + try { + return await lazy.atom.getVisibleText(elem, this.document.defaultView); + } catch (e) { + lazy.logger.warn(`Atom getVisibleText failed: "${e.message}"`); + + // Fallback in case the atom implementation is broken. + // As known so far this only happens for XML documents (bug 1794099). + return elem.textContent; + } + } + + /** + * Get the value of a css property for the given element. + */ + async getElementValueOfCssProperty(options = {}) { + const { name, elem } = options; + + const style = this.document.defaultView.getComputedStyle(elem); + return style.getPropertyValue(name); + } + + /** + * Get the source of the current browsing context's document. + */ + async getPageSource() { + return this.document.documentElement.outerHTML; + } + + /** + * Returns the rect of the element to screenshot. + * + * Because the screen capture takes place in the parent process the dimensions + * for the screenshot have to be determined in the appropriate child process. + * + * Also it takes care of scrolling an element into view if requested. + * + * @param {object} options + * @param {Element} options.elem + * Optional element to take a screenshot of. + * @param {boolean=} options.full + * True to take a screenshot of the entire document element. + * Defaults to true. + * @param {boolean=} options.scroll + * When <var>elem</var> is given, scroll it into view. + * Defaults to true. + * + * @returns {DOMRect} + * The area to take a snapshot from. + */ + async getScreenshotRect(options = {}) { + const { elem, full = true, scroll = true } = options; + const win = elem + ? this.document.defaultView + : this.browsingContext.top.window; + + let rect; + + if (elem) { + if (scroll) { + lazy.dom.scrollIntoView(elem); + } + rect = this.getElementRect({ elem }); + } else if (full) { + const docEl = win.document.documentElement; + rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight); + } else { + // viewport + rect = new DOMRect( + win.pageXOffset, + win.pageYOffset, + win.innerWidth, + win.innerHeight + ); + } + + return rect; + } + + /** + * Return the shadowRoot attached to an element + */ + async getShadowRoot(options = {}) { + const { elem } = options; + + return lazy.dom.getShadowRoot(elem); + } + + /** + * Determine the element displayedness of the given web element. + */ + async isElementDisplayed(options = {}) { + const { capabilities, elem } = options; + + return lazy.interaction.isElementDisplayed( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Check if element is enabled. + */ + async isElementEnabled(options = {}) { + const { capabilities, elem } = options; + + return lazy.interaction.isElementEnabled( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Determine whether the referenced element is selected or not. + */ + async isElementSelected(options = {}) { + const { capabilities, elem } = options; + + return lazy.interaction.isElementSelected( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Perform a series of grouped actions at the specified points in time. + * + * @param {object} options + * @param {object} options.actions + * Array of objects with each representing an action sequence. + * @param {object} options.capabilities + * Object with a list of WebDriver session capabilities. + */ + async performActions(options = {}) { + const { actions } = options; + if (this.actionState === null) { + this.actionState = new lazy.action.State(); + } + let actionChain = lazy.action.Chain.fromJSON(this.actionState, actions); + + await actionChain.dispatch(this.actionState, this.document.defaultView); + // Terminate the current wheel transaction if there is one. Wheel + // transactions should not live longer than a single action chain. + ChromeUtils.endWheelTransaction(); + } + + /** + * The release actions command is used to release all the keys and pointer + * buttons that are currently depressed. This causes events to be fired + * as if the state was released by an explicit series of actions. It also + * clears all the internal state of the virtual devices. + */ + async releaseActions() { + if (this.actionState === null) { + return; + } + await this.actionState.release(this.document.defaultView); + this.actionState = null; + } + + /* + * Send key presses to element after focusing on it. + */ + async sendKeysToElement(options = {}) { + const { capabilities, elem, text } = options; + + const opts = { + strictFileInteractability: capabilities.strictFileInteractability, + accessibilityChecks: capabilities["moz:accessibilityChecks"], + webdriverClick: capabilities["moz:webdriverClick"], + }; + + return lazy.interaction.sendKeysToElement(elem, text, opts); + } + + /** + * Switch to the specified frame. + * + * @param {object=} options + * @param {(number|Element)=} options.id + * If it's a number treat it as the index for all the existing frames. + * If it's an Element switch to this specific frame. + * If not specified or `null` switch to the top-level browsing context. + */ + async switchToFrame(options = {}) { + const { id } = options; + + const childContexts = this.browsingContext.children; + let browsingContext; + + if (id == null) { + browsingContext = this.browsingContext.top; + } else if (typeof id == "number") { + if (id < 0 || id >= childContexts.length) { + throw new lazy.error.NoSuchFrameError( + `Unable to locate frame with index: ${id}` + ); + } + browsingContext = childContexts[id]; + } else { + const context = childContexts.find(context => { + return context.embedderElement === id; + }); + if (!context) { + throw new lazy.error.NoSuchFrameError( + `Unable to locate frame for element: ${id}` + ); + } + browsingContext = context; + } + + // For in-process iframes the window global is lazy-loaded for optimization + // reasons. As such force the currentWindowGlobal to be created so we always + // have a window (bug 1691348). + browsingContext.window; + + return { browsingContextId: browsingContext.id }; + } + + /** + * Switch to the parent frame. + */ + async switchToParentFrame() { + const browsingContext = this.browsingContext.parent || this.browsingContext; + + return { browsingContextId: browsingContext.id }; + } +} diff --git a/remote/marionette/actors/MarionetteCommandsParent.sys.mjs b/remote/marionette/actors/MarionetteCommandsParent.sys.mjs new file mode 100644 index 0000000000..970c927eab --- /dev/null +++ b/remote/marionette/actors/MarionetteCommandsParent.sys.mjs @@ -0,0 +1,436 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + capture: "chrome://remote/content/shared/Capture.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + getSeenNodesForBrowsingContext: + "chrome://remote/content/shared/webdriver/Session.sys.mjs", + json: "chrome://remote/content/marionette/json.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// Because Marionette supports a single session only we store its id +// globally so that the parent actor can access it. +let webDriverSessionId = null; + +export class MarionetteCommandsParent extends JSWindowActorParent { + #deferredDialogOpened; + + actorCreated() { + this.#deferredDialogOpened = null; + } + + async sendQuery(name, serializedValue) { + const seenNodes = lazy.getSeenNodesForBrowsingContext( + webDriverSessionId, + this.manager.browsingContext + ); + + // return early if a dialog is opened + this.#deferredDialogOpened = Promise.withResolvers(); + let { + error, + seenNodeIds, + serializedValue: serializedResult, + hasSerializedWindows, + } = await Promise.race([ + super.sendQuery(name, serializedValue), + this.#deferredDialogOpened.promise, + ]).finally(() => { + this.#deferredDialogOpened = null; + }); + + if (error) { + const err = lazy.error.WebDriverError.fromJSON(error); + this.#handleError(err, seenNodes); + } + + // Update seen nodes for serialized element and shadow root nodes. + seenNodeIds?.forEach(nodeId => seenNodes.add(nodeId)); + + if (hasSerializedWindows) { + // The serialized data contains WebWindow references that need to be + // converted to unique identifiers. + serializedResult = lazy.json.mapToNavigableIds(serializedResult); + } + + return serializedResult; + } + + /** + * Handle WebDriver error and replace error type if necessary. + * + * @param {WebDriverError} error + * The WebDriver error to handle. + * @param {Set<string>} seenNodes + * List of node ids already seen in this navigable. + * + * @throws {WebDriverError} + * The original or replaced WebDriver error. + */ + #handleError(error, seenNodes) { + // If an element hasn't been found during deserialization check if it + // may be a stale reference. + if ( + error instanceof lazy.error.NoSuchElementError && + error.data.elementId !== undefined && + seenNodes.has(error.data.elementId) + ) { + throw new lazy.error.StaleElementReferenceError(error); + } + + // If a shadow root hasn't been found during deserialization check if it + // may be a detached reference. + if ( + error instanceof lazy.error.NoSuchShadowRootError && + error.data.shadowId !== undefined && + seenNodes.has(error.data.shadowId) + ) { + throw new lazy.error.DetachedShadowRootError(error); + } + + throw error; + } + + notifyDialogOpened() { + if (this.#deferredDialogOpened) { + this.#deferredDialogOpened.resolve({ data: null }); + } + } + + // Proxying methods for WebDriver commands + + clearElement(webEl) { + return this.sendQuery("MarionetteCommandsParent:clearElement", { + elem: webEl, + }); + } + + clickElement(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:clickElement", { + elem: webEl, + capabilities: capabilities.toJSON(), + }); + } + + async executeScript(script, args, opts) { + return this.sendQuery("MarionetteCommandsParent:executeScript", { + script, + args: lazy.json.mapFromNavigableIds(args), + opts, + }); + } + + findElement(strategy, selector, opts) { + return this.sendQuery("MarionetteCommandsParent:findElement", { + strategy, + selector, + opts, + }); + } + + findElements(strategy, selector, opts) { + return this.sendQuery("MarionetteCommandsParent:findElements", { + strategy, + selector, + opts, + }); + } + + async getShadowRoot(webEl) { + return this.sendQuery("MarionetteCommandsParent:getShadowRoot", { + elem: webEl, + }); + } + + async getActiveElement() { + return this.sendQuery("MarionetteCommandsParent:getActiveElement"); + } + + async getComputedLabel(webEl) { + return this.sendQuery("MarionetteCommandsParent:getComputedLabel", { + elem: webEl, + }); + } + + async getComputedRole(webEl) { + return this.sendQuery("MarionetteCommandsParent:getComputedRole", { + elem: webEl, + }); + } + + async getElementAttribute(webEl, name) { + return this.sendQuery("MarionetteCommandsParent:getElementAttribute", { + elem: webEl, + name, + }); + } + + async getElementProperty(webEl, name) { + return this.sendQuery("MarionetteCommandsParent:getElementProperty", { + elem: webEl, + name, + }); + } + + async getElementRect(webEl) { + return this.sendQuery("MarionetteCommandsParent:getElementRect", { + elem: webEl, + }); + } + + async getElementTagName(webEl) { + return this.sendQuery("MarionetteCommandsParent:getElementTagName", { + elem: webEl, + }); + } + + async getElementText(webEl) { + return this.sendQuery("MarionetteCommandsParent:getElementText", { + elem: webEl, + }); + } + + async getElementValueOfCssProperty(webEl, name) { + return this.sendQuery( + "MarionetteCommandsParent:getElementValueOfCssProperty", + { + elem: webEl, + name, + } + ); + } + + async getPageSource() { + return this.sendQuery("MarionetteCommandsParent:getPageSource"); + } + + async isElementDisplayed(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:isElementDisplayed", { + capabilities: capabilities.toJSON(), + elem: webEl, + }); + } + + async isElementEnabled(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:isElementEnabled", { + capabilities: capabilities.toJSON(), + elem: webEl, + }); + } + + async isElementSelected(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:isElementSelected", { + capabilities: capabilities.toJSON(), + elem: webEl, + }); + } + + async sendKeysToElement(webEl, text, capabilities) { + return this.sendQuery("MarionetteCommandsParent:sendKeysToElement", { + capabilities: capabilities.toJSON(), + elem: webEl, + text, + }); + } + + async performActions(actions) { + return this.sendQuery("MarionetteCommandsParent:performActions", { + actions, + }); + } + + async releaseActions() { + return this.sendQuery("MarionetteCommandsParent:releaseActions"); + } + + async switchToFrame(id) { + const { browsingContextId } = await this.sendQuery( + "MarionetteCommandsParent:switchToFrame", + { id } + ); + + return { + browsingContext: BrowsingContext.get(browsingContextId), + }; + } + + async switchToParentFrame() { + const { browsingContextId } = await this.sendQuery( + "MarionetteCommandsParent:switchToParentFrame" + ); + + return { + browsingContext: BrowsingContext.get(browsingContextId), + }; + } + + async takeScreenshot(webEl, format, full, scroll) { + const rect = await this.sendQuery( + "MarionetteCommandsParent:getScreenshotRect", + { + elem: webEl, + full, + scroll, + } + ); + + // If no element has been specified use the top-level browsing context. + // Otherwise use the browsing context from the currently selected frame. + const browsingContext = webEl + ? this.browsingContext + : this.browsingContext.top; + + let canvas = await lazy.capture.canvas( + browsingContext.topChromeWindow, + browsingContext, + rect.x, + rect.y, + rect.width, + rect.height + ); + + switch (format) { + case lazy.capture.Format.Hash: + return lazy.capture.toHash(canvas); + + case lazy.capture.Format.Base64: + return lazy.capture.toBase64(canvas); + + default: + throw new TypeError(`Invalid capture format: ${format}`); + } + } +} + +/** + * Proxy that will dynamically create MarionetteCommands actors for a dynamically + * provided browsing context until the method can be fully executed by the + * JSWindowActor pair. + * + * @param {function(): BrowsingContext} browsingContextFn + * A function that returns the reference to the browsing context for which + * the query should run. + */ +export function getMarionetteCommandsActorProxy(browsingContextFn) { + const MAX_ATTEMPTS = 10; + + /** + * Methods which modify the content page cannot be retried safely. + * See Bug 1673345. + */ + const NO_RETRY_METHODS = [ + "clickElement", + "executeScript", + "performActions", + "releaseActions", + "sendKeysToElement", + ]; + + return new Proxy( + {}, + { + get(target, methodName) { + return async (...args) => { + let attempts = 0; + while (true) { + try { + const browsingContext = browsingContextFn(); + if (!browsingContext) { + throw new DOMException( + "No BrowsingContext found", + "NoBrowsingContext" + ); + } + + // TODO: Scenarios where the window/tab got closed and + // currentWindowGlobal is null will be handled in Bug 1662808. + const actor = + browsingContext.currentWindowGlobal.getActor( + "MarionetteCommands" + ); + + const result = await actor[methodName](...args); + return result; + } catch (e) { + if (!["AbortError", "InactiveActor"].includes(e.name)) { + // Only retry when the JSWindowActor pair gets destroyed, or + // gets inactive eg. when the page is moved into bfcache. + throw e; + } + + if (NO_RETRY_METHODS.includes(methodName)) { + const browsingContextId = browsingContextFn()?.id; + lazy.logger.trace( + `[${browsingContextId}] Querying "${methodName}" failed with` + + ` ${e.name}, returning "null" as fallback` + ); + return null; + } + + if (++attempts > MAX_ATTEMPTS) { + const browsingContextId = browsingContextFn()?.id; + lazy.logger.trace( + `[${browsingContextId}] Querying "${methodName} "` + + `reached the limit of retry attempts (${MAX_ATTEMPTS})` + ); + throw e; + } + + lazy.logger.trace( + `Retrying "${methodName}", attempt: ${attempts}` + ); + } + } + }; + }, + } + ); +} + +/** + * Register the MarionetteCommands actor that holds all the commands. + * + * @param {string} sessionId + * The id of the current WebDriver session. + */ +export function registerCommandsActor(sessionId) { + try { + ChromeUtils.registerWindowActor("MarionetteCommands", { + kind: "JSWindowActor", + parent: { + esModuleURI: + "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs", + }, + child: { + esModuleURI: + "chrome://remote/content/marionette/actors/MarionetteCommandsChild.sys.mjs", + }, + + allFrames: true, + includeChrome: true, + }); + } catch (e) { + if (e.name === "NotSupportedError") { + lazy.logger.warn(`MarionetteCommands actor is already registered!`); + } else { + throw e; + } + } + + webDriverSessionId = sessionId; +} + +export function unregisterCommandsActor() { + webDriverSessionId = null; + + ChromeUtils.unregisterWindowActor("MarionetteCommands"); +} diff --git a/remote/marionette/actors/MarionetteEventsChild.sys.mjs b/remote/marionette/actors/MarionetteEventsChild.sys.mjs new file mode 100644 index 0000000000..b59b4d00e7 --- /dev/null +++ b/remote/marionette/actors/MarionetteEventsChild.sys.mjs @@ -0,0 +1,71 @@ +/* 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/. */ + +/* eslint-disable no-restricted-globals */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +export class MarionetteEventsChild extends JSWindowActorChild { + get innerWindowId() { + return this.manager.innerWindowId; + } + + actorCreated() { + // Prevent the logger from being created if the current log level + // isn't set to 'trace'. This is important for a faster content process + // creation when Marionette is running. + if (lazy.Log.isTraceLevelOrOrMore) { + lazy.logger.trace( + `[${this.browsingContext.id}] MarionetteEvents actor created ` + + `for window id ${this.innerWindowId}` + ); + } + } + + handleEvent({ target, type }) { + if (!Services.cpmm.sharedData.get("MARIONETTE_EVENTS_ENABLED")) { + // The parent process will set MARIONETTE_EVENTS_ENABLED to false when + // the Marionette session ends to avoid unnecessary inter process + // communications + return; + } + + // Ignore invalid combinations of load events and document's readyState. + if ( + (type === "DOMContentLoaded" && target.readyState != "interactive") || + (type === "pageshow" && target.readyState != "complete") + ) { + lazy.logger.warn( + `Ignoring event '${type}' because document has an invalid ` + + `readyState of '${target.readyState}'.` + ); + return; + } + + switch (type) { + case "beforeunload": + case "DOMContentLoaded": + case "hashchange": + case "pagehide": + case "pageshow": + case "popstate": + this.sendAsyncMessage("MarionetteEventsChild:PageLoadEvent", { + browsingContext: this.browsingContext, + documentURI: target.documentURI, + readyState: target.readyState, + type, + windowId: this.innerWindowId, + }); + break; + } + } +} diff --git a/remote/marionette/actors/MarionetteEventsParent.sys.mjs b/remote/marionette/actors/MarionetteEventsParent.sys.mjs new file mode 100644 index 0000000000..c051fb2b1f --- /dev/null +++ b/remote/marionette/actors/MarionetteEventsParent.sys.mjs @@ -0,0 +1,113 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// Singleton to allow forwarding events to registered listeners. +export const EventDispatcher = { + init() { + lazy.EventEmitter.decorate(this); + }, +}; + +EventDispatcher.init(); + +export class MarionetteEventsParent extends JSWindowActorParent { + async receiveMessage(msg) { + const { name, data } = msg; + + let rv; + switch (name) { + case "MarionetteEventsChild:PageLoadEvent": + EventDispatcher.emit("page-load", data); + break; + } + + return rv; + } +} + +// Flag to check if the MarionetteEvents actors have already been registed. +let eventsActorRegistered = false; + +/** + * Register Events actors to listen for page load events via EventDispatcher. + */ +function registerEventsActor() { + if (eventsActorRegistered) { + return; + } + + try { + // Register the JSWindowActor pair for events as used by Marionette + ChromeUtils.registerWindowActor("MarionetteEvents", { + kind: "JSWindowActor", + parent: { + esModuleURI: + "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs", + }, + child: { + esModuleURI: + "chrome://remote/content/marionette/actors/MarionetteEventsChild.sys.mjs", + events: { + beforeunload: { capture: true }, + DOMContentLoaded: { mozSystemGroup: true }, + hashchange: { mozSystemGroup: true }, + pagehide: { mozSystemGroup: true }, + pageshow: { mozSystemGroup: true }, + // popstate doesn't bubble, as such use capturing phase + popstate: { capture: true, mozSystemGroup: true }, + + click: {}, + dblclick: {}, + unload: { capture: true, createActor: false }, + }, + }, + + allFrames: true, + includeChrome: true, + }); + + eventsActorRegistered = true; + } catch (e) { + if (e.name === "NotSupportedError") { + lazy.logger.warn(`MarionetteEvents actor is already registered!`); + } else { + throw e; + } + } +} + +/** + * Enable MarionetteEvents actors to start forwarding page load events from the + * child actor to the parent actor. Register the MarionetteEvents actor if necessary. + */ +export function enableEventsActor() { + // sharedData is replicated across processes and will be checked by + // MarionetteEventsChild before forward events to the parent actor. + Services.ppmm.sharedData.set("MARIONETTE_EVENTS_ENABLED", true); + // Request to immediately flush the data to the content processes to avoid races. + Services.ppmm.sharedData.flush(); + + registerEventsActor(); +} + +/** + * Disable MarionetteEvents actors to stop forwarding page load events from the + * child actor to the parent actor. + */ +export function disableEventsActor() { + Services.ppmm.sharedData.set("MARIONETTE_EVENTS_ENABLED", false); + Services.ppmm.sharedData.flush(); +} diff --git a/remote/marionette/actors/MarionetteReftestChild.sys.mjs b/remote/marionette/actors/MarionetteReftestChild.sys.mjs new file mode 100644 index 0000000000..fd73f88a88 --- /dev/null +++ b/remote/marionette/actors/MarionetteReftestChild.sys.mjs @@ -0,0 +1,239 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", + + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +/** + * Child JSWindowActor to handle navigation for reftests relying on marionette. + */ +export class MarionetteReftestChild extends JSWindowActorChild { + constructor() { + super(); + + // This promise will resolve with the URL recorded in the "load" event + // handler. This URL will not be impacted by any hash modification that + // might be performed by the test script. + // The harness should be loaded before loading any test page, so the actors + // should be registered before the "load" event is received for a test page. + this._loadedURLPromise = new Promise( + r => (this._resolveLoadedURLPromise = r) + ); + } + + handleEvent(event) { + if (event.type == "load") { + const url = event.target.location.href; + lazy.logger.debug(`Handle load event with URL ${url}`); + this._resolveLoadedURLPromise(url); + } + } + + actorCreated() { + lazy.logger.trace( + `[${this.browsingContext.id}] Reftest actor created ` + + `for window id ${this.manager.innerWindowId}` + ); + } + + async receiveMessage(msg) { + const { name, data } = msg; + + let result; + switch (name) { + case "MarionetteReftestParent:flushRendering": + result = await this.flushRendering(data); + break; + case "MarionetteReftestParent:reftestWait": + result = await this.reftestWait(data); + break; + } + return result; + } + + /** + * Wait for a reftest page to be ready for screenshots: + * - wait for the loadedURL to be available (see handleEvent) + * - check if the URL matches the expected URL + * - if present, wait for the "reftest-wait" classname to be removed from the + * document element + * + * @param {object} options + * @param {string} options.url + * The expected test page URL + * @param {boolean} options.useRemote + * True when using e10s + * @param {boolean} options.warnOnOverflow + * True if we should check the content fits in the viewport. + * This isn't necessary for print reftests where we will render the full + * size of the paginated content. + * @returns {boolean} + * Returns true when the correct page is loaded and ready for + * screenshots. Returns false if the page loaded bug does not have the + * expected URL. + */ + async reftestWait(options = {}) { + const { url, useRemote } = options; + const loadedURL = await this._loadedURLPromise; + if (loadedURL !== url) { + lazy.logger.debug( + `Window URL does not match the expected URL "${loadedURL}" !== "${url}"` + ); + return false; + } + + const documentElement = this.document.documentElement; + const hasReftestWait = documentElement.classList.contains("reftest-wait"); + + lazy.logger.debug("Waiting for event loop to spin"); + await new Promise(resolve => lazy.setTimeout(resolve, 0)); + + await this.paintComplete({ useRemote, ignoreThrottledAnimations: true }); + + if (hasReftestWait) { + const event = new this.document.defaultView.Event("TestRendered", { + bubbles: true, + }); + documentElement.dispatchEvent(event); + lazy.logger.info("Emitted TestRendered event"); + await this.reftestWaitRemoved(); + await this.paintComplete({ useRemote, ignoreThrottledAnimations: false }); + } + if ( + options.warnOnOverflow && + (this.document.defaultView.innerWidth < documentElement.scrollWidth || + this.document.defaultView.innerHeight < documentElement.scrollHeight) + ) { + lazy.logger.warn( + `${url} overflows viewport (width: ${documentElement.scrollWidth}, height: ${documentElement.scrollHeight})` + ); + } + return true; + } + + paintComplete({ useRemote, ignoreThrottledAnimations }) { + lazy.logger.debug("Waiting for rendering"); + let windowUtils = this.document.defaultView.windowUtils; + return new Promise(resolve => { + let maybeResolve = () => { + this.flushRendering({ ignoreThrottledAnimations }); + if (useRemote) { + // Flush display (paint) + lazy.logger.debug("Force update of layer tree"); + windowUtils.updateLayerTree(); + } + + if (windowUtils.isMozAfterPaintPending) { + lazy.logger.debug("isMozAfterPaintPending: true"); + this.document.defaultView.addEventListener( + "MozAfterPaint", + maybeResolve, + { + once: true, + } + ); + } else { + // resolve at the start of the next frame in case of leftover paints + lazy.logger.debug("isMozAfterPaintPending: false"); + this.document.defaultView.requestAnimationFrame(() => { + this.document.defaultView.requestAnimationFrame(resolve); + }); + } + }; + maybeResolve(); + }); + } + + reftestWaitRemoved() { + lazy.logger.debug("Waiting for reftest-wait removal"); + return new Promise(resolve => { + const documentElement = this.document.documentElement; + let observer = new this.document.defaultView.MutationObserver(() => { + if (!documentElement.classList.contains("reftest-wait")) { + observer.disconnect(); + lazy.logger.debug("reftest-wait removed"); + lazy.setTimeout(resolve, 0); + } + }); + if (documentElement.classList.contains("reftest-wait")) { + observer.observe(documentElement, { attributes: true }); + } else { + lazy.setTimeout(resolve, 0); + } + }); + } + + /** + * Ensure layout is flushed in each frame + * + * @param {object} options + * @param {boolean} options.ignoreThrottledAnimations Don't flush + * the layout of throttled animations. We can end up in a + * situation where flushing a throttled animation causes + * mozAfterPaint events even when all rendering we care about + * should have ceased. See + * https://searchfox.org/mozilla-central/rev/d58860eb739af613774c942c3bb61754123e449b/layout/tools/reftest/reftest-content.js#723-729 + * for more detail. + */ + flushRendering(options = {}) { + let { ignoreThrottledAnimations } = options; + lazy.logger.debug( + `flushRendering ignoreThrottledAnimations:${ignoreThrottledAnimations}` + ); + let anyPendingPaintsGeneratedInDescendants = false; + + let windowUtils = this.document.defaultView.windowUtils; + + function flushWindow(win) { + let utils = win.windowUtils; + let afterPaintWasPending = utils.isMozAfterPaintPending; + + let root = win.document.documentElement; + if (root) { + try { + if (ignoreThrottledAnimations) { + utils.flushLayoutWithoutThrottledAnimations(); + } else { + root.getBoundingClientRect(); + } + } catch (e) { + lazy.logger.error("flushWindow failed", e); + } + } + + if (!afterPaintWasPending && utils.isMozAfterPaintPending) { + anyPendingPaintsGeneratedInDescendants = true; + } + + for (let i = 0; i < win.frames.length; ++i) { + // Skip remote frames, flushRendering will be called on their individual + // MarionetteReftest actor via _recursiveFlushRendering performed from + // the topmost MarionetteReftest actor. + if (!Cu.isRemoteProxy(win.frames[i])) { + flushWindow(win.frames[i]); + } + } + } + flushWindow(this.document.defaultView); + + if ( + anyPendingPaintsGeneratedInDescendants && + !windowUtils.isMozAfterPaintPending + ) { + lazy.logger.error( + "Descendant frame generated a MozAfterPaint event, " + + "but the root document doesn't have one!" + ); + } + } +} diff --git a/remote/marionette/actors/MarionetteReftestParent.sys.mjs b/remote/marionette/actors/MarionetteReftestParent.sys.mjs new file mode 100644 index 0000000000..327806ebbf --- /dev/null +++ b/remote/marionette/actors/MarionetteReftestParent.sys.mjs @@ -0,0 +1,90 @@ +/* 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/. */ + +/** + * Parent JSWindowActor to handle navigation for reftests relying on marionette. + */ +export class MarionetteReftestParent extends JSWindowActorParent { + /** + * Wait for the expected URL to be loaded. + * + * @param {string} url + * The expected url. + * @param {boolean} useRemote + * True if tests are running with e10s. + * @param {boolean} warnOnOverflow + * True if we should check the content fits in the viewport. + * This isn't necessary for print reftests where we will render the full + * size of the paginated content. + * @returns {boolean} true if the page is fully loaded with the expected url, + * false otherwise. + */ + async reftestWait(url, useRemote, warnOnOverflow) { + try { + const isCorrectUrl = await this.sendQuery( + "MarionetteReftestParent:reftestWait", + { + url, + useRemote, + warnOnOverflow, + } + ); + + if (isCorrectUrl) { + // Trigger flush rendering for all remote frames. + await this._flushRenderingInSubtree({ + ignoreThrottledAnimations: false, + }); + } + + return isCorrectUrl; + } catch (e) { + if (e.name === "AbortError") { + // If the query is aborted, the window global is being destroyed, most + // likely because a navigation happened. + return false; + } + + // Other errors should not be swallowed. + throw e; + } + } + + /** + * Call flushRendering on all browsing contexts in the subtree. + * Each actor will flush rendering in all the same process frames. + */ + async _flushRenderingInSubtree({ ignoreThrottledAnimations }) { + const browsingContext = this.manager.browsingContext; + const contexts = browsingContext.getAllBrowsingContextsInSubtree(); + + await Promise.all( + contexts.map(async context => { + if (context === browsingContext) { + // Skip the top browsing context, for which flushRendering is + // already performed via the initial reftestWait call. + return; + } + + const windowGlobal = context.currentWindowGlobal; + if (!windowGlobal) { + // Bail out if there is no window attached to the current context. + return; + } + + if (!windowGlobal.isProcessRoot) { + // Bail out if this window global is not a process root. + // MarionetteReftestChild::flushRendering will flush all same process + // frames, so we only need to call flushRendering on process roots. + return; + } + + const reftestActor = windowGlobal.getActor("MarionetteReftest"); + await reftestActor.sendQuery("MarionetteReftestParent:flushRendering", { + ignoreThrottledAnimations, + }); + }) + ); + } +} diff --git a/remote/marionette/addon.sys.mjs b/remote/marionette/addon.sys.mjs new file mode 100644 index 0000000000..f83671694b --- /dev/null +++ b/remote/marionette/addon.sys.mjs @@ -0,0 +1,142 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +// from https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager/AddonManager#AddonInstall_errors +const ERRORS = { + [-1]: "ERROR_NETWORK_FAILURE: A network error occured.", + [-2]: "ERROR_INCORRECT_HASH: The downloaded file did not match the expected hash.", + [-3]: "ERROR_CORRUPT_FILE: The file appears to be corrupt.", + [-4]: "ERROR_FILE_ACCESS: There was an error accessing the filesystem.", + [-5]: "ERROR_SIGNEDSTATE_REQUIRED: The addon must be signed and isn't.", + [-6]: "ERROR_UNEXPECTED_ADDON_TYPE: The downloaded add-on had a different type than expected (during an update).", + [-7]: "ERROR_INCORRECT_ID: The addon did not have the expected ID (during an update).", + [-8]: "ERROR_INVALID_DOMAIN: The addon install_origins does not list the 3rd party domain.", + [-9]: "ERROR_UNEXPECTED_ADDON_VERSION: The downloaded add-on had a different version than expected (during an update).", + [-10]: "ERROR_BLOCKLISTED: The add-on is blocklisted.", + [-11]: + "ERROR_INCOMPATIBLE: The add-on is incompatible (w.r.t. the compatibility range).", + [-12]: + "ERROR_UNSUPPORTED_ADDON_TYPE: The add-on type is not supported by the platform.", +}; + +async function installAddon(file) { + let install = await lazy.AddonManager.getInstallForFile(file, null, { + source: "internal", + }); + + if (install.error) { + throw new lazy.error.UnknownError(ERRORS[install.error]); + } + + return install.install().catch(err => { + throw new lazy.error.UnknownError(ERRORS[install.error]); + }); +} + +/** Installs addons by path and uninstalls by ID. */ +export class Addon { + /** + * Install a Firefox addon. + * + * If the addon is restartless, it can be used right away. Otherwise a + * restart is required. + * + * Temporary addons will automatically be uninstalled on shutdown and + * do not need to be signed, though they must be restartless. + * + * @param {string} path + * Full path to the extension package archive. + * @param {boolean=} temporary + * True to install the addon temporarily, false (default) otherwise. + * + * @returns {Promise.<string>} + * Addon ID. + * + * @throws {UnknownError} + * If there is a problem installing the addon. + */ + static async install(path, temporary = false) { + let addon; + let file; + + try { + file = new lazy.FileUtils.File(path); + } catch (e) { + throw new lazy.error.UnknownError(`Expected absolute path: ${e}`, e); + } + + if (!file.exists()) { + throw new lazy.error.UnknownError(`No such file or directory: ${path}`); + } + + try { + if (temporary) { + addon = await lazy.AddonManager.installTemporaryAddon(file); + } else { + addon = await installAddon(file); + } + } catch (e) { + throw new lazy.error.UnknownError( + `Could not install add-on: ${path}: ${e.message}`, + e + ); + } + + return addon.id; + } + + /** + * Uninstall a Firefox addon. + * + * If the addon is restartless it will be uninstalled right away. + * Otherwise, Firefox must be restarted for the change to take effect. + * + * @param {string} id + * ID of the addon to uninstall. + * + * @returns {Promise} + * + * @throws {UnknownError} + * If there is a problem uninstalling the addon. + */ + static async uninstall(id) { + let candidate = await lazy.AddonManager.getAddonByID(id); + if (candidate === null) { + // `AddonManager.getAddonByID` never rejects but instead + // returns `null` if the requested addon cannot be found. + throw new lazy.error.UnknownError(`Addon ${id} is not installed`); + } + + return new Promise(resolve => { + let listener = { + onOperationCancelled: addon => { + if (addon.id === candidate.id) { + lazy.AddonManager.removeAddonListener(listener); + throw new lazy.error.UnknownError( + `Uninstall of ${candidate.id} has been canceled` + ); + } + }, + + onUninstalled: addon => { + if (addon.id === candidate.id) { + lazy.AddonManager.removeAddonListener(listener); + resolve(); + } + }, + }; + + lazy.AddonManager.addAddonListener(listener); + candidate.uninstall(); + }); + } +} diff --git a/remote/marionette/atom.sys.mjs b/remote/marionette/atom.sys.mjs new file mode 100644 index 0000000000..d065849d43 --- /dev/null +++ b/remote/marionette/atom.sys.mjs @@ -0,0 +1,55 @@ +// Copyright 2011-2017 Software Freedom Conservancy +// +// Licensed 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + evaluate: "chrome://remote/content/marionette/evaluate.sys.mjs", + sandbox: "chrome://remote/content/marionette/evaluate.sys.mjs", +}); + +/** @namespace */ +export const atom = {}; + +// Follow the instructions to export all the atoms: +// https://firefox-source-docs.mozilla.org/testing/marionette/SeleniumAtoms.html +// +// Built from SHA1: bd5cbe5b3a3e60b5970d8168474dd69a996c392c +const ATOMS = { + getVisibleText: "function(){return (function(){var k=this||self;function aa(a){return\"string\"==typeof a}function ba(a,b){a=a.split(\".\");var c=k;a[0]in c||\"undefined\"==typeof c.execScript||c.execScript(\"var \"+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b}\nfunction ca(a){var b=typeof a;if(\"object\"==b)if(a){if(a instanceof Array)return\"array\";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if(\"[object Window]\"==c)return\"object\";if(\"[object Array]\"==c||\"number\"==typeof a.length&&\"undefined\"!=typeof a.splice&&\"undefined\"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable(\"splice\"))return\"array\";if(\"[object Function]\"==c||\"undefined\"!=typeof a.call&&\"undefined\"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable(\"call\"))return\"function\"}else return\"null\";\nelse if(\"function\"==b&&\"undefined\"==typeof a.call)return\"object\";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}\nfunction fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf(\"native code\")?fa=da:fa=ea;return fa.apply(null,arguments)}function ha(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var d=c.slice();d.push.apply(d,arguments);return a.apply(this,d)}}function m(a,b){function c(){}c.prototype=b.prototype;a.prototype=new c;a.prototype.constructor=a};/*\n\n The MIT License\n\n Copyright (c) 2007 Cybozu Labs, Inc.\n Copyright (c) 2012 Google Inc.\n\n Permission is hereby granted, free of charge, to any person obtaining a copy\n of this software and associated documentation files (the \"Software\"), to\n deal in the Software without restriction, including without limitation the\n rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the Software is\n furnished to do so, subject to the following conditions:\n\n The above copyright notice and this permission notice shall be included in\n all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n IN THE SOFTWARE.\n*/\nfunction ia(a,b,c){this.a=a;this.b=b||1;this.f=c||1};var ja=Array.prototype.indexOf?function(a,b){return Array.prototype.indexOf.call(a,b,void 0)}:function(a,b){if(\"string\"===typeof a)return\"string\"!==typeof b||1!=b.length?-1:a.indexOf(b,0);for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1},p=Array.prototype.forEach?function(a,b){Array.prototype.forEach.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)},ka=Array.prototype.filter?function(a,b){return Array.prototype.filter.call(a,\nb,void 0)}:function(a,b){for(var c=a.length,d=[],e=0,f=\"string\"===typeof a?a.split(\"\"):a,g=0;g<c;g++)if(g in f){var h=f[g];b.call(void 0,h,g,a)&&(d[e++]=h)}return d},la=Array.prototype.map?function(a,b){return Array.prototype.map.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=Array(c),e=\"string\"===typeof a?a.split(\"\"):a,f=0;f<c;f++)f in e&&(d[f]=b.call(void 0,e[f],f,a));return d},ma=Array.prototype.reduce?function(a,b,c){return Array.prototype.reduce.call(a,b,c)}:function(a,b,c){var d=c;p(a,\nfunction(e,f){d=b.call(void 0,d,e,f,a)});return d},na=Array.prototype.some?function(a,b){return Array.prototype.some.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a))return!0;return!1},oa=Array.prototype.every?function(a,b){return Array.prototype.every.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)if(e in d&&!b.call(void 0,d[e],e,a))return!1;return!0};\nfunction pa(a,b){a:{for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a)){b=e;break a}b=-1}return 0>b?null:\"string\"===typeof a?a.charAt(b):a[b]}function qa(a){return Array.prototype.concat.apply([],arguments)}function ra(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};function sa(a){var b=a.length-1;return 0<=b&&a.indexOf(\" \",b)==b}var ta=String.prototype.trim?function(a){return a.trim()}:function(a){return/^[\\s\\xa0]*([\\s\\S]*?)[\\s\\xa0]*$/.exec(a)[1]};function ua(a,b){return a<b?-1:a>b?1:0};var r;a:{var va=k.navigator;if(va){var wa=va.userAgent;if(wa){r=wa;break a}}r=\"\"}function u(a){return-1!=r.indexOf(a)};function xa(){return u(\"Firefox\")||u(\"FxiOS\")}function ya(){return(u(\"Chrome\")||u(\"CriOS\"))&&!u(\"Edge\")};function za(a){return String(a).replace(/\\-([a-z])/g,function(b,c){return c.toUpperCase()})};function Aa(){return u(\"iPhone\")&&!u(\"iPod\")&&!u(\"iPad\")};function Ba(a,b){var c=Ca;return Object.prototype.hasOwnProperty.call(c,a)?c[a]:c[a]=b(a)};var Da=u(\"Opera\"),w=u(\"Trident\")||u(\"MSIE\"),Ea=u(\"Edge\"),Fa=u(\"Gecko\")&&!(-1!=r.toLowerCase().indexOf(\"webkit\")&&!u(\"Edge\"))&&!(u(\"Trident\")||u(\"MSIE\"))&&!u(\"Edge\"),Ga=-1!=r.toLowerCase().indexOf(\"webkit\")&&!u(\"Edge\");function Ha(){var a=k.document;return a?a.documentMode:void 0}var Ia;\na:{var Ja=\"\",Ka=function(){var a=r;if(Fa)return/rv:([^\\);]+)(\\)|;)/.exec(a);if(Ea)return/Edge\\/([\\d\\.]+)/.exec(a);if(w)return/\\b(?:MSIE|rv)[: ]([^\\);]+)(\\)|;)/.exec(a);if(Ga)return/WebKit\\/(\\S+)/.exec(a);if(Da)return/(?:Version)[ \\/]?(\\S+)/.exec(a)}();Ka&&(Ja=Ka?Ka[1]:\"\");if(w){var La=Ha();if(null!=La&&La>parseFloat(Ja)){Ia=String(La);break a}}Ia=Ja}var Ca={};\nfunction Ma(a){return Ba(a,function(){for(var b=0,c=ta(String(Ia)).split(\".\"),d=ta(String(a)).split(\".\"),e=Math.max(c.length,d.length),f=0;0==b&&f<e;f++){var g=c[f]||\"\",h=d[f]||\"\";do{g=/(\\d*)(\\D*)(.*)/.exec(g)||[\"\",\"\",\"\",\"\"];h=/(\\d*)(\\D*)(.*)/.exec(h)||[\"\",\"\",\"\",\"\"];if(0==g[0].length&&0==h[0].length)break;b=ua(0==g[1].length?0:parseInt(g[1],10),0==h[1].length?0:parseInt(h[1],10))||ua(0==g[2].length,0==h[2].length)||ua(g[2],h[2]);g=g[3];h=h[3]}while(0==b)}return 0<=b})}var Na;\nNa=k.document&&w?Ha():void 0;var x=w&&!(9<=Number(Na)),Oa=w&&!(8<=Number(Na));function Pa(a,b,c,d){this.a=a;this.nodeName=c;this.nodeValue=d;this.nodeType=2;this.parentNode=this.ownerElement=b}function Qa(a,b){var c=Oa&&\"href\"==b.nodeName?a.getAttribute(b.nodeName,2):b.nodeValue;return new Pa(b,a,b.nodeName,c)};function Ra(a){this.b=a;this.a=0}function Sa(a){a=a.match(Ta);for(var b=0;b<a.length;b++)Ua.test(a[b])&&a.splice(b,1);return new Ra(a)}var Ta=/\\$?(?:(?![0-9-\\.])(?:\\*|[\\w-\\.]+):)?(?![0-9-\\.])(?:\\*|[\\w-\\.]+)|\\/\\/|\\.\\.|::|\\d+(?:\\.\\d*)?|\\.\\d+|\"[^\"]*\"|'[^']*'|[!<>]=|\\s+|./g,Ua=/^\\s/;function y(a,b){return a.b[a.a+(b||0)]}function z(a){return a.b[a.a++]}function Va(a){return a.b.length<=a.a};function Wa(a,b){this.x=void 0!==a?a:0;this.y=void 0!==b?b:0}Wa.prototype.ceil=function(){this.x=Math.ceil(this.x);this.y=Math.ceil(this.y);return this};Wa.prototype.floor=function(){this.x=Math.floor(this.x);this.y=Math.floor(this.y);return this};Wa.prototype.round=function(){this.x=Math.round(this.x);this.y=Math.round(this.y);return this};function Xa(a,b){this.width=a;this.height=b}Xa.prototype.aspectRatio=function(){return this.width/this.height};Xa.prototype.ceil=function(){this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};Xa.prototype.floor=function(){this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};Xa.prototype.round=function(){this.width=Math.round(this.width);this.height=Math.round(this.height);return this};function Ya(a){for(;a&&1!=a.nodeType;)a=a.previousSibling;return a}function Za(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if(\"undefined\"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}\nfunction $a(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if(w&&!(9<=Number(Na))){if(9==a.nodeType)return-1;if(9==b.nodeType)return 1}if(\"sourceIndex\"in a||a.parentNode&&\"sourceIndex\"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?ab(a,b):!c&&Za(e,b)?-1*bb(a,b):!d&&Za(f,a)?bb(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=A(a);c=d.createRange();\nc.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b);a.collapse(!0);return c.compareBoundaryPoints(k.Range.START_TO_END,a)}function bb(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return ab(b,a)}function ab(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function A(a){return 9==a.nodeType?a:a.ownerDocument||a.document}function cb(a,b){a&&(a=a.parentNode);for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null}\nfunction db(a){this.a=a||k.document||document}db.prototype.getElementsByTagName=function(a,b){return(b||this.a).getElementsByTagName(String(a))};function B(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?\"\":b);if(\"string\"!=typeof b)if(x&&\"title\"==a.nodeName.toLowerCase()&&1==c)b=a.text;else if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;c=0;var d=[];for(b=\"\";a;){do 1!=a.nodeType&&(b+=a.nodeValue),x&&\"title\"==a.nodeName.toLowerCase()&&(b+=a.text),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return b}\nfunction C(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}Oa&&\"class\"==b&&(b=\"className\");return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function eb(a,b,c,d,e){return(x?fb:gb).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)}\nfunction fb(a,b,c,d,e){if(a instanceof F||8==a.b||c&&null===a.b){var f=b.all;if(!f)return e;a=ib(a);if(\"*\"!=a&&(f=b.getElementsByTagName(a),!f))return e;if(c){for(var g=[],h=0;b=f[h++];)C(b,c,d)&&g.push(b);f=g}for(h=0;b=f[h++];)\"*\"==a&&\"!\"==b.tagName||e.add(b);return e}jb(a,b,c,d,e);return e}\nfunction gb(a,b,c,d,e){b.getElementsByName&&d&&\"name\"==c&&!w?(b=b.getElementsByName(d),p(b,function(f){a.a(f)&&e.add(f)})):b.getElementsByClassName&&d&&\"class\"==c?(b=b.getElementsByClassName(d),p(b,function(f){f.className==d&&a.a(f)&&e.add(f)})):a instanceof G?jb(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),p(b,function(f){C(f,c,d)&&e.add(f)}));return e}\nfunction kb(a,b,c,d,e){var f;if((a instanceof F||8==a.b||c&&null===a.b)&&(f=b.childNodes)){var g=ib(a);if(\"*\"!=g&&(f=ka(f,function(h){return h.tagName&&h.tagName.toLowerCase()==g}),!f))return e;c&&(f=ka(f,function(h){return C(h,c,d)}));p(f,function(h){\"*\"==g&&(\"!\"==h.tagName||\"*\"==g&&1!=h.nodeType)||e.add(h)});return e}return lb(a,b,c,d,e)}function lb(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b);return e}\nfunction jb(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b),jb(a,b,c,d,e)}function ib(a){if(a instanceof G){if(8==a.b)return\"!\";if(null===a.b)return\"*\"}return a.f()};function E(){this.b=this.a=null;this.l=0}function mb(a){this.f=a;this.a=this.b=null}function nb(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;){e=c.f;var g=b.f;e==g||e instanceof Pa&&g instanceof Pa&&e.a==g.a?(e=c,c=c.a,b=b.a):0<$a(c.f,b.f)?(e=b,b=b.a):(e=c,c=c.a);(e.b=d)?d.a=e:a.a=e;d=e;f++}for(e=c||b;e;)e.b=d,d=d.a=e,f++,e=e.a;a.b=d;a.l=f;return a}function ob(a,b){b=new mb(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}\nE.prototype.add=function(a){a=new mb(a);a.b=this.b;this.a?this.b.a=a:this.a=this.b=a;this.b=a;this.l++};function pb(a){return(a=a.a)?a.f:null}function qb(a){return(a=pb(a))?B(a):\"\"}function H(a,b){return new rb(a,!!b)}function rb(a,b){this.f=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function I(a){var b=a.b;if(null==b)return null;var c=a.a=b;a.b=a.s?b.b:b.a;return c.f};function J(a){this.i=a;this.b=this.g=!1;this.f=null}function K(a){return\"\\n \"+a.toString().split(\"\\n\").join(\"\\n \")}function sb(a,b){a.g=b}function tb(a,b){a.b=b}function L(a,b){a=a.a(b);return a instanceof E?+qb(a):+a}function O(a,b){a=a.a(b);return a instanceof E?qb(a):\"\"+a}function ub(a,b){a=a.a(b);return a instanceof E?!!a.l:!!a};function vb(a,b,c){J.call(this,a.i);this.c=a;this.h=b;this.o=c;this.g=b.g||c.g;this.b=b.b||c.b;this.c==wb&&(c.b||c.g||4==c.i||0==c.i||!b.f?b.b||b.g||4==b.i||0==b.i||!c.f||(this.f={name:c.f.name,u:b}):this.f={name:b.f.name,u:c})}m(vb,J);\nfunction xb(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof E&&c instanceof E){b=H(b);for(d=I(b);d;d=I(b))for(e=H(c),f=I(e);f;f=I(e))if(a(B(d),B(f)))return!0;return!1}if(b instanceof E||c instanceof E){b instanceof E?(e=b,d=c):(e=c,d=b);f=H(e);for(var g=typeof d,h=I(f);h;h=I(f)){switch(g){case \"number\":h=+B(h);break;case \"boolean\":h=!!B(h);break;case \"string\":h=B(h);break;default:throw Error(\"Illegal primitive type for comparison.\");}if(e==b&&a(h,d)||e==c&&a(d,h))return!0}return!1}return e?\"boolean\"==\ntypeof b||\"boolean\"==typeof c?a(!!b,!!c):\"number\"==typeof b||\"number\"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}vb.prototype.a=function(a){return this.c.m(this.h,this.o,a)};vb.prototype.toString=function(){var a=\"Binary Expression: \"+this.c;a+=K(this.h);return a+=K(this.o)};function yb(a,b,c,d){this.I=a;this.D=b;this.i=c;this.m=d}yb.prototype.toString=function(){return this.I};var zb={};\nfunction P(a,b,c,d){if(zb.hasOwnProperty(a))throw Error(\"Binary operator already created: \"+a);a=new yb(a,b,c,d);return zb[a.toString()]=a}P(\"div\",6,1,function(a,b,c){return L(a,c)/L(b,c)});P(\"mod\",6,1,function(a,b,c){return L(a,c)%L(b,c)});P(\"*\",6,1,function(a,b,c){return L(a,c)*L(b,c)});P(\"+\",5,1,function(a,b,c){return L(a,c)+L(b,c)});P(\"-\",5,1,function(a,b,c){return L(a,c)-L(b,c)});P(\"<\",4,2,function(a,b,c){return xb(function(d,e){return d<e},a,b,c)});\nP(\">\",4,2,function(a,b,c){return xb(function(d,e){return d>e},a,b,c)});P(\"<=\",4,2,function(a,b,c){return xb(function(d,e){return d<=e},a,b,c)});P(\">=\",4,2,function(a,b,c){return xb(function(d,e){return d>=e},a,b,c)});var wb=P(\"=\",3,2,function(a,b,c){return xb(function(d,e){return d==e},a,b,c,!0)});P(\"!=\",3,2,function(a,b,c){return xb(function(d,e){return d!=e},a,b,c,!0)});P(\"and\",2,2,function(a,b,c){return ub(a,c)&&ub(b,c)});P(\"or\",1,2,function(a,b,c){return ub(a,c)||ub(b,c)});function Ab(a,b){if(b.a.length&&4!=a.i)throw Error(\"Primary expression must evaluate to nodeset if filter has predicate(s).\");J.call(this,a.i);this.c=a;this.h=b;this.g=a.g;this.b=a.b}m(Ab,J);Ab.prototype.a=function(a){a=this.c.a(a);return Bb(this.h,a)};Ab.prototype.toString=function(){var a=\"Filter:\"+K(this.c);return a+=K(this.h)};function Cb(a,b){if(b.length<a.C)throw Error(\"Function \"+a.j+\" expects at least\"+a.C+\" arguments, \"+b.length+\" given\");if(null!==a.B&&b.length>a.B)throw Error(\"Function \"+a.j+\" expects at most \"+a.B+\" arguments, \"+b.length+\" given\");a.H&&p(b,function(c,d){if(4!=c.i)throw Error(\"Argument \"+d+\" to function \"+a.j+\" is not of type Nodeset: \"+c);});J.call(this,a.i);this.v=a;this.c=b;sb(this,a.g||na(b,function(c){return c.g}));tb(this,a.G&&!b.length||a.F&&!!b.length||na(b,function(c){return c.b}))}\nm(Cb,J);Cb.prototype.a=function(a){return this.v.m.apply(null,qa(a,this.c))};Cb.prototype.toString=function(){var a=\"Function: \"+this.v;if(this.c.length){var b=ma(this.c,function(c,d){return c+K(d)},\"Arguments:\");a+=K(b)}return a};function Db(a,b,c,d,e,f,g,h){this.j=a;this.i=b;this.g=c;this.G=d;this.F=!1;this.m=e;this.C=f;this.B=void 0!==g?g:f;this.H=!!h}Db.prototype.toString=function(){return this.j};var Eb={};\nfunction Q(a,b,c,d,e,f,g,h){if(Eb.hasOwnProperty(a))throw Error(\"Function already created: \"+a+\".\");Eb[a]=new Db(a,b,c,d,e,f,g,h)}Q(\"boolean\",2,!1,!1,function(a,b){return ub(b,a)},1);Q(\"ceiling\",1,!1,!1,function(a,b){return Math.ceil(L(b,a))},1);Q(\"concat\",3,!1,!1,function(a,b){return ma(ra(arguments,1),function(c,d){return c+O(d,a)},\"\")},2,null);Q(\"contains\",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return-1!=b.indexOf(a)},2);Q(\"count\",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0);\nQ(\"false\",2,!1,!1,function(){return!1},0);Q(\"floor\",1,!1,!1,function(a,b){return Math.floor(L(b,a))},1);Q(\"id\",4,!1,!1,function(a,b){function c(h){if(x){var l=e.all[h];if(l){if(l.nodeType&&h==l.id)return l;if(l.length)return pa(l,function(v){return h==v.id})}return null}return e.getElementById(h)}var d=a.a,e=9==d.nodeType?d:d.ownerDocument;a=O(b,a).split(/\\s+/);var f=[];p(a,function(h){h=c(h);!h||0<=ja(f,h)||f.push(h)});f.sort($a);var g=new E;p(f,function(h){g.add(h)});return g},1);\nQ(\"lang\",2,!1,!1,function(){return!1},1);Q(\"last\",1,!0,!1,function(a){if(1!=arguments.length)throw Error(\"Function last expects ()\");return a.f},0);Q(\"local-name\",3,!1,!0,function(a,b){return(a=b?pb(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():\"\"},0,1,!0);Q(\"name\",3,!1,!0,function(a,b){return(a=b?pb(b.a(a)):a.a)?a.nodeName.toLowerCase():\"\"},0,1,!0);Q(\"namespace-uri\",3,!0,!1,function(){return\"\"},0,1,!0);\nQ(\"normalize-space\",3,!1,!0,function(a,b){return(b?O(b,a):B(a.a)).replace(/[\\s\\xa0]+/g,\" \").replace(/^\\s+|\\s+$/g,\"\")},0,1);Q(\"not\",2,!1,!1,function(a,b){return!ub(b,a)},1);Q(\"number\",1,!1,!0,function(a,b){return b?L(b,a):+B(a.a)},0,1);Q(\"position\",1,!0,!1,function(a){return a.b},0);Q(\"round\",1,!1,!1,function(a,b){return Math.round(L(b,a))},1);Q(\"starts-with\",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return 0==b.lastIndexOf(a,0)},2);Q(\"string\",3,!1,!0,function(a,b){return b?O(b,a):B(a.a)},0,1);\nQ(\"string-length\",1,!1,!0,function(a,b){return(b?O(b,a):B(a.a)).length},0,1);Q(\"substring\",3,!1,!1,function(a,b,c,d){c=L(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return\"\";d=d?L(d,a):Infinity;if(isNaN(d)||-Infinity===d)return\"\";c=Math.round(c)-1;var e=Math.max(c,0);a=O(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);Q(\"substring-after\",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);c=b.indexOf(a);return-1==c?\"\":b.substring(c+a.length)},2);\nQ(\"substring-before\",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);a=b.indexOf(a);return-1==a?\"\":b.substring(0,a)},2);Q(\"sum\",1,!1,!1,function(a,b){a=H(b.a(a));b=0;for(var c=I(a);c;c=I(a))b+=+B(c);return b},1,1,!0);Q(\"translate\",3,!1,!1,function(a,b,c,d){b=O(b,a);c=O(c,a);var e=O(d,a);a={};for(d=0;d<c.length;d++){var f=c.charAt(d);f in a||(a[f]=e.charAt(d))}c=\"\";for(d=0;d<b.length;d++)f=b.charAt(d),c+=f in a?a[f]:f;return c},3);Q(\"true\",2,!1,!1,function(){return!0},0);function G(a,b){this.h=a;this.c=void 0!==b?b:null;this.b=null;switch(a){case \"comment\":this.b=8;break;case \"text\":this.b=3;break;case \"processing-instruction\":this.b=7;break;case \"node\":break;default:throw Error(\"Unexpected argument\");}}function Fb(a){return\"comment\"==a||\"text\"==a||\"processing-instruction\"==a||\"node\"==a}G.prototype.a=function(a){return null===this.b||this.b==a.nodeType};G.prototype.f=function(){return this.h};\nG.prototype.toString=function(){var a=\"Kind Test: \"+this.h;null===this.c||(a+=K(this.c));return a};function Gb(a){J.call(this,3);this.c=a.substring(1,a.length-1)}m(Gb,J);Gb.prototype.a=function(){return this.c};Gb.prototype.toString=function(){return\"Literal: \"+this.c};function F(a,b){this.j=a.toLowerCase();a=\"*\"==this.j?\"*\":\"http://www.w3.org/1999/xhtml\";this.c=b?b.toLowerCase():a}F.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=void 0!==a.localName?a.localName:a.nodeName;return\"*\"!=this.j&&this.j!=b.toLowerCase()?!1:\"*\"==this.c?!0:this.c==(a.namespaceURI?a.namespaceURI.toLowerCase():\"http://www.w3.org/1999/xhtml\")};F.prototype.f=function(){return this.j};\nF.prototype.toString=function(){return\"Name Test: \"+(\"http://www.w3.org/1999/xhtml\"==this.c?\"\":this.c+\":\")+this.j};function Hb(a){J.call(this,1);this.c=a}m(Hb,J);Hb.prototype.a=function(){return this.c};Hb.prototype.toString=function(){return\"Number: \"+this.c};function Ib(a,b){J.call(this,a.i);this.h=a;this.c=b;this.g=a.g;this.b=a.b;1==this.c.length&&(a=this.c[0],a.A||a.c!=Jb||(a=a.o,\"*\"!=a.f()&&(this.f={name:a.f(),u:null})))}m(Ib,J);function Kb(){J.call(this,4)}m(Kb,J);Kb.prototype.a=function(a){var b=new E;a=a.a;9==a.nodeType?b.add(a):b.add(a.ownerDocument);return b};Kb.prototype.toString=function(){return\"Root Helper Expression\"};function Lb(){J.call(this,4)}m(Lb,J);Lb.prototype.a=function(a){var b=new E;b.add(a.a);return b};Lb.prototype.toString=function(){return\"Context Helper Expression\"};\nfunction Mb(a){return\"/\"==a||\"//\"==a}Ib.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof E))throw Error(\"Filter expression must evaluate to nodeset.\");a=this.c;for(var c=0,d=a.length;c<d&&b.l;c++){var e=a[c],f=H(b,e.c.s);if(e.g||e.c!=Nb)if(e.g||e.c!=Ob){var g=I(f);for(b=e.a(new ia(g));null!=(g=I(f));)g=e.a(new ia(g)),b=nb(b,g)}else g=I(f),b=e.a(new ia(g));else{for(g=I(f);(b=I(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new ia(g))}}return b};\nIb.prototype.toString=function(){var a=\"Path Expression:\"+K(this.h);if(this.c.length){var b=ma(this.c,function(c,d){return c+K(d)},\"Steps:\");a+=K(b)}return a};function Pb(a,b){this.a=a;this.s=!!b}\nfunction Bb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=H(b),f=b.l,g,h=0;g=I(e);h++){var l=a.s?f-h:h+1;g=d.a(new ia(g,l,f));if(\"number\"==typeof g)l=l==g;else if(\"string\"==typeof g||\"boolean\"==typeof g)l=!!g;else if(g instanceof E)l=0<g.l;else throw Error(\"Predicate.evaluate returned an unexpected type.\");if(!l){l=e;g=l.f;var v=l.a;if(!v)throw Error(\"Next must be called at least once before remove.\");var n=v.b;v=v.a;n?n.a=v:g.a=v;v?v.b=n:g.b=n;g.l--;l.a=null}}return b}\nPb.prototype.toString=function(){return ma(this.a,function(a,b){return a+K(b)},\"Predicates:\")};function R(a,b,c,d){J.call(this,4);this.c=a;this.o=b;this.h=c||new Pb([]);this.A=!!d;b=this.h;b=0<b.a.length?b.a[0].f:null;a.J&&b&&(a=b.name,a=x?a.toLowerCase():a,this.f={name:a,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.g||1==c.i||0==c.i){a=!0;break a}a=!1}this.g=a}m(R,J);\nR.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.u?O(c.u,a):null,f=1);if(this.A)if(this.g||this.c!=Qb)if(b=H((new R(Rb,new G(\"node\"))).a(a)),c=I(b))for(a=this.m(c,d,e,f);null!=(c=I(b));)a=nb(a,this.m(c,d,e,f));else a=new E;else a=eb(this.o,b,d,e),a=Bb(this.h,a,f);else a=this.m(a.a,d,e,f);return a};R.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=Bb(this.h,a,d)};\nR.prototype.toString=function(){var a=\"Step:\"+K(\"Operator: \"+(this.A?\"//\":\"/\"));this.c.j&&(a+=K(\"Axis: \"+this.c));a+=K(this.o);if(this.h.a.length){var b=ma(this.h.a,function(c,d){return c+K(d)},\"Predicates:\");a+=K(b)}return a};function Sb(a,b,c,d){this.j=a;this.v=b;this.s=c;this.J=d}Sb.prototype.toString=function(){return this.j};var Tb={};function S(a,b,c,d){if(Tb.hasOwnProperty(a))throw Error(\"Axis already created: \"+a);b=new Sb(a,b,c,!!d);return Tb[a]=b}\nS(\"ancestor\",function(a,b){for(var c=new E;b=b.parentNode;)a.a(b)&&ob(c,b);return c},!0);S(\"ancestor-or-self\",function(a,b){var c=new E;do a.a(b)&&ob(c,b);while(b=b.parentNode);return c},!0);\nvar Jb=S(\"attribute\",function(a,b){var c=new E,d=a.f();if(\"style\"==d&&x&&b.style)return c.add(new Pa(b.style,b,\"style\",b.style.cssText)),c;var e=b.attributes;if(e)if(a instanceof G&&null===a.b||\"*\"==d)for(a=0;d=e[a];a++)x?d.nodeValue&&c.add(Qa(b,d)):c.add(d);else(d=e.getNamedItem(d))&&(x?d.nodeValue&&c.add(Qa(b,d)):c.add(d));return c},!1),Qb=S(\"child\",function(a,b,c,d,e){return(x?kb:lb).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)},!1,!0);S(\"descendant\",eb,!1,!0);\nvar Rb=S(\"descendant-or-self\",function(a,b,c,d){var e=new E;C(b,c,d)&&a.a(b)&&e.add(b);return eb(a,b,c,d,e)},!1,!0),Nb=S(\"following\",function(a,b,c,d){var e=new E;do for(var f=b;f=f.nextSibling;)C(f,c,d)&&a.a(f)&&e.add(f),e=eb(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);S(\"following-sibling\",function(a,b){for(var c=new E;b=b.nextSibling;)a.a(b)&&c.add(b);return c},!1);S(\"namespace\",function(){return new E},!1);\nvar Ub=S(\"parent\",function(a,b){var c=new E;if(9==b.nodeType)return c;if(2==b.nodeType)return c.add(b.ownerElement),c;b=b.parentNode;a.a(b)&&c.add(b);return c},!1),Ob=S(\"preceding\",function(a,b,c,d){var e=new E,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,h=f.length;g<h;g++){var l=[];for(b=f[g];b=b.previousSibling;)l.unshift(b);for(var v=0,n=l.length;v<n;v++)b=l[v],C(b,c,d)&&a.a(b)&&e.add(b),e=eb(a,b,c,d,e)}return e},!0,!0);\nS(\"preceding-sibling\",function(a,b){for(var c=new E;b=b.previousSibling;)a.a(b)&&ob(c,b);return c},!0);var Vb=S(\"self\",function(a,b){var c=new E;a.a(b)&&c.add(b);return c},!1);function Wb(a){J.call(this,1);this.c=a;this.g=a.g;this.b=a.b}m(Wb,J);Wb.prototype.a=function(a){return-L(this.c,a)};Wb.prototype.toString=function(){return\"Unary Expression: -\"+K(this.c)};function Xb(a){J.call(this,4);this.c=a;sb(this,na(this.c,function(b){return b.g}));tb(this,na(this.c,function(b){return b.b}))}m(Xb,J);Xb.prototype.a=function(a){var b=new E;p(this.c,function(c){c=c.a(a);if(!(c instanceof E))throw Error(\"Path expression must evaluate to NodeSet.\");b=nb(b,c)});return b};Xb.prototype.toString=function(){return ma(this.c,function(a,b){return a+K(b)},\"Union Expression:\")};function Yb(a,b){this.a=a;this.b=b}function Zb(a){for(var b,c=[];;){T(a,\"Missing right hand side of binary expression.\");b=bc(a);var d=z(a.a);if(!d)break;var e=(d=zb[d]||null)&&d.D;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].D;)b=new vb(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new vb(c.pop(),c.pop(),b);return b}function T(a,b){if(Va(a.a))throw Error(b);}function cc(a,b){a=z(a.a);if(a!=b)throw Error(\"Bad token, expected: \"+b+\" got: \"+a);}\nfunction dc(a){a=z(a.a);if(\")\"!=a)throw Error(\"Bad token: \"+a);}function ec(a){a=z(a.a);if(2>a.length)throw Error(\"Unclosed literal string\");return new Gb(a)}\nfunction fc(a){var b=[];if(Mb(y(a.a))){var c=z(a.a);var d=y(a.a);if(\"/\"==c&&(Va(a.a)||\".\"!=d&&\"..\"!=d&&\"@\"!=d&&\"*\"!=d&&!/(?![0-9])[\\w]/.test(d)))return new Kb;d=new Kb;T(a,\"Missing next location step.\");c=gc(a,c);b.push(c)}else{a:{c=y(a.a);d=c.charAt(0);switch(d){case \"$\":throw Error(\"Variable reference not allowed in HTML XPath\");case \"(\":z(a.a);c=Zb(a);T(a,'unclosed \"(\"');cc(a,\")\");break;case '\"':case \"'\":c=ec(a);break;default:if(isNaN(+c))if(!Fb(c)&&/(?![0-9])[\\w]/.test(d)&&\"(\"==y(a.a,1)){c=z(a.a);\nc=Eb[c]||null;z(a.a);for(d=[];\")\"!=y(a.a);){T(a,\"Missing function argument list.\");d.push(Zb(a));if(\",\"!=y(a.a))break;z(a.a)}T(a,\"Unclosed function argument list.\");dc(a);c=new Cb(c,d)}else{c=null;break a}else c=new Hb(+z(a.a))}\"[\"==y(a.a)&&(d=new Pb(hc(a)),c=new Ab(c,d))}if(c)if(Mb(y(a.a)))d=c;else return c;else c=gc(a,\"/\"),d=new Lb,b.push(c)}for(;Mb(y(a.a));)c=z(a.a),T(a,\"Missing next location step.\"),c=gc(a,c),b.push(c);return new Ib(d,b)}\nfunction gc(a,b){if(\"/\"!=b&&\"//\"!=b)throw Error('Step op should be \"/\" or \"//\"');if(\".\"==y(a.a)){var c=new R(Vb,new G(\"node\"));z(a.a);return c}if(\"..\"==y(a.a))return c=new R(Ub,new G(\"node\")),z(a.a),c;if(\"@\"==y(a.a)){var d=Jb;z(a.a);T(a,\"Missing attribute name\")}else if(\"::\"==y(a.a,1)){if(!/(?![0-9])[\\w]/.test(y(a.a).charAt(0)))throw Error(\"Bad token: \"+z(a.a));var e=z(a.a);d=Tb[e]||null;if(!d)throw Error(\"No axis with name: \"+e);z(a.a);T(a,\"Missing node name\")}else d=Qb;e=y(a.a);if(/(?![0-9])[\\w\\*]/.test(e.charAt(0)))if(\"(\"==\ny(a.a,1)){if(!Fb(e))throw Error(\"Invalid node type: \"+e);e=z(a.a);if(!Fb(e))throw Error(\"Invalid type name: \"+e);cc(a,\"(\");T(a,\"Bad nodetype\");var f=y(a.a).charAt(0),g=null;if('\"'==f||\"'\"==f)g=ec(a);T(a,\"Bad nodetype\");dc(a);e=new G(e,g)}else if(e=z(a.a),f=e.indexOf(\":\"),-1==f)e=new F(e);else{g=e.substring(0,f);if(\"*\"==g)var h=\"*\";else if(h=a.b(g),!h)throw Error(\"Namespace prefix not declared: \"+g);e=e.substr(f+1);e=new F(e,h)}else throw Error(\"Bad token: \"+z(a.a));a=new Pb(hc(a),d.s);return c||new R(d,\ne,a,\"//\"==b)}function hc(a){for(var b=[];\"[\"==y(a.a);){z(a.a);T(a,\"Missing predicate expression.\");var c=Zb(a);b.push(c);T(a,\"Unclosed predicate expression.\");cc(a,\"]\")}return b}function bc(a){if(\"-\"==y(a.a))return z(a.a),new Wb(bc(a));var b=fc(a);if(\"|\"!=y(a.a))a=b;else{for(b=[b];\"|\"==z(a.a);)T(a,\"Missing next union location path.\"),b.push(fc(a));a.a.a--;a=new Xb(b)}return a};function ic(a){switch(a.nodeType){case 1:return ha(jc,a);case 9:return ic(a.documentElement);case 11:case 10:case 6:case 12:return kc;default:return a.parentNode?ic(a.parentNode):kc}}function kc(){return null}function jc(a,b){if(a.prefix==b)return a.namespaceURI||\"http://www.w3.org/1999/xhtml\";var c=a.getAttributeNode(\"xmlns:\"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?jc(a.parentNode,b):null};function lc(a,b){if(!a.length)throw Error(\"Empty XPath expression.\");a=Sa(a);if(Va(a))throw Error(\"Invalid XPath expression.\");b?\"function\"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=Zb(new Yb(a,b));if(!Va(a))throw Error(\"Bad token: \"+z(a));this.evaluate=function(d,e){d=c.a(new ia(d));return new U(d,e)}}\nfunction U(a,b){if(0==b)if(a instanceof E)b=4;else if(\"string\"==typeof a)b=2;else if(\"number\"==typeof a)b=1;else if(\"boolean\"==typeof a)b=3;else throw Error(\"Unexpected evaluation result.\");if(2!=b&&1!=b&&3!=b&&!(a instanceof E))throw Error(\"value could not be converted to the specified type\");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof E?qb(a):\"\"+a;break;case 1:this.numberValue=a instanceof E?+qb(a):+a;break;case 3:this.booleanValue=a instanceof E?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c=\nH(a);var d=[];for(var e=I(c);e;e=I(c))d.push(e instanceof Pa?e.a:e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:a=pb(a);this.singleNodeValue=a instanceof Pa?a.a:a;break;default:throw Error(\"Unknown XPathResult type.\");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error(\"iterateNext called with wrong result type\");return f>=d.length?null:d[f++]};this.snapshotItem=function(g){if(6!=b&&7!=b)throw Error(\"snapshotItem called with wrong result type\");return g>=d.length||\n0>g?null:d[g]}}U.ANY_TYPE=0;U.NUMBER_TYPE=1;U.STRING_TYPE=2;U.BOOLEAN_TYPE=3;U.UNORDERED_NODE_ITERATOR_TYPE=4;U.ORDERED_NODE_ITERATOR_TYPE=5;U.UNORDERED_NODE_SNAPSHOT_TYPE=6;U.ORDERED_NODE_SNAPSHOT_TYPE=7;U.ANY_UNORDERED_NODE_TYPE=8;U.FIRST_ORDERED_NODE_TYPE=9;function mc(a){this.lookupNamespaceURI=ic(a)}\nfunction nc(a,b){a=a||k;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=U,c.evaluate=function(d,e,f,g){return(new lc(d,f)).evaluate(e,g)},c.createExpression=function(d,e){return new lc(d,e)},c.createNSResolver=function(d){return new mc(d)}}ba(\"wgxpath.install\",nc);ba(\"wgxpath.install\",nc);var oc={aliceblue:\"#f0f8ff\",antiquewhite:\"#faebd7\",aqua:\"#00ffff\",aquamarine:\"#7fffd4\",azure:\"#f0ffff\",beige:\"#f5f5dc\",bisque:\"#ffe4c4\",black:\"#000000\",blanchedalmond:\"#ffebcd\",blue:\"#0000ff\",blueviolet:\"#8a2be2\",brown:\"#a52a2a\",burlywood:\"#deb887\",cadetblue:\"#5f9ea0\",chartreuse:\"#7fff00\",chocolate:\"#d2691e\",coral:\"#ff7f50\",cornflowerblue:\"#6495ed\",cornsilk:\"#fff8dc\",crimson:\"#dc143c\",cyan:\"#00ffff\",darkblue:\"#00008b\",darkcyan:\"#008b8b\",darkgoldenrod:\"#b8860b\",darkgray:\"#a9a9a9\",darkgreen:\"#006400\",\ndarkgrey:\"#a9a9a9\",darkkhaki:\"#bdb76b\",darkmagenta:\"#8b008b\",darkolivegreen:\"#556b2f\",darkorange:\"#ff8c00\",darkorchid:\"#9932cc\",darkred:\"#8b0000\",darksalmon:\"#e9967a\",darkseagreen:\"#8fbc8f\",darkslateblue:\"#483d8b\",darkslategray:\"#2f4f4f\",darkslategrey:\"#2f4f4f\",darkturquoise:\"#00ced1\",darkviolet:\"#9400d3\",deeppink:\"#ff1493\",deepskyblue:\"#00bfff\",dimgray:\"#696969\",dimgrey:\"#696969\",dodgerblue:\"#1e90ff\",firebrick:\"#b22222\",floralwhite:\"#fffaf0\",forestgreen:\"#228b22\",fuchsia:\"#ff00ff\",gainsboro:\"#dcdcdc\",\nghostwhite:\"#f8f8ff\",gold:\"#ffd700\",goldenrod:\"#daa520\",gray:\"#808080\",green:\"#008000\",greenyellow:\"#adff2f\",grey:\"#808080\",honeydew:\"#f0fff0\",hotpink:\"#ff69b4\",indianred:\"#cd5c5c\",indigo:\"#4b0082\",ivory:\"#fffff0\",khaki:\"#f0e68c\",lavender:\"#e6e6fa\",lavenderblush:\"#fff0f5\",lawngreen:\"#7cfc00\",lemonchiffon:\"#fffacd\",lightblue:\"#add8e6\",lightcoral:\"#f08080\",lightcyan:\"#e0ffff\",lightgoldenrodyellow:\"#fafad2\",lightgray:\"#d3d3d3\",lightgreen:\"#90ee90\",lightgrey:\"#d3d3d3\",lightpink:\"#ffb6c1\",lightsalmon:\"#ffa07a\",\nlightseagreen:\"#20b2aa\",lightskyblue:\"#87cefa\",lightslategray:\"#778899\",lightslategrey:\"#778899\",lightsteelblue:\"#b0c4de\",lightyellow:\"#ffffe0\",lime:\"#00ff00\",limegreen:\"#32cd32\",linen:\"#faf0e6\",magenta:\"#ff00ff\",maroon:\"#800000\",mediumaquamarine:\"#66cdaa\",mediumblue:\"#0000cd\",mediumorchid:\"#ba55d3\",mediumpurple:\"#9370db\",mediumseagreen:\"#3cb371\",mediumslateblue:\"#7b68ee\",mediumspringgreen:\"#00fa9a\",mediumturquoise:\"#48d1cc\",mediumvioletred:\"#c71585\",midnightblue:\"#191970\",mintcream:\"#f5fffa\",mistyrose:\"#ffe4e1\",\nmoccasin:\"#ffe4b5\",navajowhite:\"#ffdead\",navy:\"#000080\",oldlace:\"#fdf5e6\",olive:\"#808000\",olivedrab:\"#6b8e23\",orange:\"#ffa500\",orangered:\"#ff4500\",orchid:\"#da70d6\",palegoldenrod:\"#eee8aa\",palegreen:\"#98fb98\",paleturquoise:\"#afeeee\",palevioletred:\"#db7093\",papayawhip:\"#ffefd5\",peachpuff:\"#ffdab9\",peru:\"#cd853f\",pink:\"#ffc0cb\",plum:\"#dda0dd\",powderblue:\"#b0e0e6\",purple:\"#800080\",red:\"#ff0000\",rosybrown:\"#bc8f8f\",royalblue:\"#4169e1\",saddlebrown:\"#8b4513\",salmon:\"#fa8072\",sandybrown:\"#f4a460\",seagreen:\"#2e8b57\",\nseashell:\"#fff5ee\",sienna:\"#a0522d\",silver:\"#c0c0c0\",skyblue:\"#87ceeb\",slateblue:\"#6a5acd\",slategray:\"#708090\",slategrey:\"#708090\",snow:\"#fffafa\",springgreen:\"#00ff7f\",steelblue:\"#4682b4\",tan:\"#d2b48c\",teal:\"#008080\",thistle:\"#d8bfd8\",tomato:\"#ff6347\",turquoise:\"#40e0d0\",violet:\"#ee82ee\",wheat:\"#f5deb3\",white:\"#ffffff\",whitesmoke:\"#f5f5f5\",yellow:\"#ffff00\",yellowgreen:\"#9acd32\"};var pc=\"backgroundColor borderTopColor borderRightColor borderBottomColor borderLeftColor color outlineColor\".split(\" \"),qc=/#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/,rc=/^#(?:[0-9a-f]{3}){1,2}$/i,sc=/^(?:rgba)?\\((\\d{1,3}),\\s?(\\d{1,3}),\\s?(\\d{1,3}),\\s?(0|1|0\\.\\d*)\\)$/i,tc=/^(?:rgb)?\\((0|[1-9]\\d{0,2}),\\s?(0|[1-9]\\d{0,2}),\\s?(0|[1-9]\\d{0,2})\\)$/i;function uc(a,b){this.code=a;this.a=V[a]||vc;this.message=b||\"\";a=this.a.replace(/((?:^|\\s+)[a-z])/g,function(c){return c.toUpperCase().replace(/^[\\s\\xa0]+/g,\"\")});b=a.length-5;if(0>b||a.indexOf(\"Error\",b)!=b)a+=\"Error\";this.name=a;a=Error(this.message);a.name=this.name;this.stack=a.stack||\"\"}m(uc,Error);var vc=\"unknown error\",V={15:\"element not selectable\",11:\"element not visible\"};V[31]=vc;V[30]=vc;V[24]=\"invalid cookie domain\";V[29]=\"invalid element coordinates\";V[12]=\"invalid element state\";\nV[32]=\"invalid selector\";V[51]=\"invalid selector\";V[52]=\"invalid selector\";V[17]=\"javascript error\";V[405]=\"unsupported operation\";V[34]=\"move target out of bounds\";V[27]=\"no such alert\";V[7]=\"no such element\";V[8]=\"no such frame\";V[23]=\"no such window\";V[28]=\"script timeout\";V[33]=\"session not created\";V[10]=\"stale element reference\";V[21]=\"timeout\";V[25]=\"unable to set cookie\";V[26]=\"unexpected alert open\";V[13]=vc;V[9]=\"unknown command\";var wc=xa(),xc=Aa()||u(\"iPod\"),yc=u(\"iPad\"),zc=u(\"Android\")&&!(ya()||xa()||u(\"Opera\")||u(\"Silk\")),Ac=ya(),Bc=u(\"Safari\")&&!(ya()||u(\"Coast\")||u(\"Opera\")||u(\"Edge\")||u(\"Edg/\")||u(\"OPR\")||xa()||u(\"Silk\")||u(\"Android\"))&&!(Aa()||u(\"iPad\")||u(\"iPod\"));function Cc(a){return(a=a.exec(r))?a[1]:\"\"}(function(){if(wc)return Cc(/Firefox\\/([0-9.]+)/);if(w||Ea||Da)return Ia;if(Ac)return Aa()||u(\"iPad\")||u(\"iPod\")?Cc(/CriOS\\/([0-9.]+)/):Cc(/Chrome\\/([0-9.]+)/);if(Bc&&!(Aa()||u(\"iPad\")||u(\"iPod\")))return Cc(/Version\\/([0-9.]+)/);if(xc||yc){var a=/Version\\/(\\S+).*Mobile\\/(\\S+)/.exec(r);if(a)return a[1]+\".\"+a[2]}else if(zc)return(a=Cc(/Android\\s+([0-9.]+)/))?a:Cc(/Version\\/([0-9.]+)/);return\"\"})();var Dc=w&&!(9<=Number(Na));function W(a,b){b&&\"string\"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};var Ec=function(){var a={K:\"http://www.w3.org/2000/svg\"};return function(b){return a[b]||null}}();\nfunction Fc(a,b){var c=A(a);if(!c.documentElement)return null;(w||zc)&&nc(c?c.parentWindow||c.defaultView:window);try{var d=c.createNSResolver?c.createNSResolver(c.documentElement):Ec;if(w&&!Ma(7))return c.evaluate.call(c,b,a,d,9,null);if(!w||9<=Number(Na)){for(var e={},f=c.getElementsByTagName(\"*\"),g=0;g<f.length;++g){var h=f[g],l=h.namespaceURI;if(l&&!e[l]){var v=h.lookupPrefix(l);if(!v){var n=l.match(\".*/(\\\\w+)/?$\");v=n?n[1]:\"xhtml\"}e[l]=v}}var D={},M;for(M in e)D[e[M]]=M;d=function(N){return D[N]||\nnull}}try{return c.evaluate(b,a,d,9,null)}catch(N){if(\"TypeError\"===N.name)return d=c.createNSResolver?c.createNSResolver(c.documentElement):Ec,c.evaluate(b,a,d,9,null);throw N;}}catch(N){if(!Fa||\"NS_ERROR_ILLEGAL_VALUE\"!=N.name)throw new uc(32,\"Unable to locate an element with the xpath expression \"+b+\" because of the following error:\\n\"+N);}}\nfunction Gc(a,b){var c=function(){var d=Fc(b,a);return d?d.singleNodeValue||null:b.selectSingleNode?(d=A(b),d.setProperty&&d.setProperty(\"SelectionLanguage\",\"XPath\"),b.selectSingleNode(a)):null}();if(null!==c&&(!c||1!=c.nodeType))throw new uc(32,'The result of the xpath expression \"'+a+'\" is: '+c+\". It should be an element.\");return c};function Hc(a,b,c,d){this.c=a;this.a=b;this.b=c;this.f=d}Hc.prototype.ceil=function(){this.c=Math.ceil(this.c);this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.f=Math.ceil(this.f);return this};Hc.prototype.floor=function(){this.c=Math.floor(this.c);this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.f=Math.floor(this.f);return this};Hc.prototype.round=function(){this.c=Math.round(this.c);this.a=Math.round(this.a);this.b=Math.round(this.b);this.f=Math.round(this.f);return this};function X(a,b,c,d){this.a=a;this.b=b;this.width=c;this.height=d}X.prototype.ceil=function(){this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};X.prototype.floor=function(){this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};\nX.prototype.round=function(){this.a=Math.round(this.a);this.b=Math.round(this.b);this.width=Math.round(this.width);this.height=Math.round(this.height);return this};var Ic=\"function\"===typeof ShadowRoot;function Jc(a){for(a=a.parentNode;a&&1!=a.nodeType&&9!=a.nodeType&&11!=a.nodeType;)a=a.parentNode;return W(a)?a:null}\nfunction Y(a,b){b=za(b);if(\"float\"==b||\"cssFloat\"==b||\"styleFloat\"==b)b=Dc?\"styleFloat\":\"cssFloat\";a:{var c=b;var d=A(a);if(d.defaultView&&d.defaultView.getComputedStyle&&(d=d.defaultView.getComputedStyle(a,null))){c=d[c]||d.getPropertyValue(c)||\"\";break a}c=\"\"}a=c||Kc(a,b);if(null===a)a=null;else if(0<=ja(pc,b)){b:{var e=a.match(sc);if(e&&(b=Number(e[1]),c=Number(e[2]),d=Number(e[3]),e=Number(e[4]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d&&0<=e&&1>=e)){b=[b,c,d,e];break b}b=null}if(!b)b:{if(d=a.match(tc))if(b=\nNumber(d[1]),c=Number(d[2]),d=Number(d[3]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d){b=[b,c,d,1];break b}b=null}if(!b)b:{b=a.toLowerCase();c=oc[b.toLowerCase()];if(!c&&(c=\"#\"==b.charAt(0)?b:\"#\"+b,4==c.length&&(c=c.replace(qc,\"#$1$1$2$2$3$3\")),!rc.test(c))){b=null;break b}b=[parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16),1]}a=b?\"rgba(\"+b.join(\", \")+\")\":a}return a}\nfunction Kc(a,b){var c=a.currentStyle||a.style,d=c[b];void 0===d&&\"function\"==ca(c.getPropertyValue)&&(d=c.getPropertyValue(b));return\"inherit\"!=d?void 0!==d?d:null:(a=Jc(a))?Kc(a,b):null}\nfunction Lc(a,b,c){function d(g){var h=Mc(g);return 0<h.height&&0<h.width?!0:W(g,\"PATH\")&&(0<h.height||0<h.width)?(g=Y(g,\"stroke-width\"),!!g&&0<parseInt(g,10)):\"hidden\"!=Y(g,\"overflow\")&&na(g.childNodes,function(l){return 3==l.nodeType||W(l)&&d(l)})}function e(g){return Nc(g)==Z&&oa(g.childNodes,function(h){return!W(h)||e(h)||!d(h)})}if(!W(a))throw Error(\"Argument to isShown must be of type Element\");if(W(a,\"BODY\"))return!0;if(W(a,\"OPTION\")||W(a,\"OPTGROUP\"))return a=cb(a,function(g){return W(g,\"SELECT\")}),\n!!a&&Lc(a,!0,c);var f=Oc(a);if(f)return!!f.image&&0<f.rect.width&&0<f.rect.height&&Lc(f.image,b,c);if(W(a,\"INPUT\")&&\"hidden\"==a.type.toLowerCase()||W(a,\"NOSCRIPT\"))return!1;f=Y(a,\"visibility\");return\"collapse\"!=f&&\"hidden\"!=f&&c(a)&&(b||0!=Pc(a))&&d(a)?!e(a):!1}\nfunction Qc(a){function b(c){if(W(c)&&\"none\"==Y(c,\"display\"))return!1;var d;if((d=c.parentNode)&&d.shadowRoot&&void 0!==c.assignedSlot)d=c.assignedSlot?c.assignedSlot.parentNode:null;else if(c.getDestinationInsertionPoints){var e=c.getDestinationInsertionPoints();0<e.length&&(d=e[e.length-1])}if(Ic&&d instanceof ShadowRoot){if(d.host.shadowRoot&&d.host.shadowRoot!==d)return!1;d=d.host}return!d||9!=d.nodeType&&11!=d.nodeType?d&&W(d,\"DETAILS\")&&!d.open&&!W(c,\"SUMMARY\")?!1:!!d&&b(d):!0}return Lc(a,!1,\nb)}var Z=\"hidden\";\nfunction Nc(a){function b(q){function t(hb){if(hb==g)return!0;var $b=Y(hb,\"display\");return 0==$b.lastIndexOf(\"inline\",0)||\"contents\"==$b||\"absolute\"==ac&&\"static\"==Y(hb,\"position\")?!1:!0}var ac=Y(q,\"position\");if(\"fixed\"==ac)return v=!0,q==g?null:g;for(q=Jc(q);q&&!t(q);)q=Jc(q);return q}function c(q){var t=q;if(\"visible\"==l)if(q==g&&h)t=h;else if(q==h)return{x:\"visible\",y:\"visible\"};t={x:Y(t,\"overflow-x\"),y:Y(t,\"overflow-y\")};q==g&&(t.x=\"visible\"==t.x?\"auto\":t.x,t.y=\"visible\"==t.y?\"auto\":t.y);return t}\nfunction d(q){if(q==g){var t=(new db(f)).a;q=t.scrollingElement?t.scrollingElement:Ga||\"CSS1Compat\"!=t.compatMode?t.body||t.documentElement:t.documentElement;t=t.parentWindow||t.defaultView;q=w&&Ma(\"10\")&&t.pageYOffset!=q.scrollTop?new Wa(q.scrollLeft,q.scrollTop):new Wa(t.pageXOffset||q.scrollLeft,t.pageYOffset||q.scrollTop)}else q=new Wa(q.scrollLeft,q.scrollTop);return q}var e=Rc(a),f=A(a),g=f.documentElement,h=f.body,l=Y(g,\"overflow\"),v;for(a=b(a);a;a=b(a)){var n=c(a);if(\"visible\"!=n.x||\"visible\"!=\nn.y){var D=Mc(a);if(0==D.width||0==D.height)return Z;var M=e.a<D.a,N=e.b<D.b;if(M&&\"hidden\"==n.x||N&&\"hidden\"==n.y)return Z;if(M&&\"visible\"!=n.x||N&&\"visible\"!=n.y){M=d(a);N=e.b<D.b-M.y;if(e.a<D.a-M.x&&\"visible\"!=n.x||N&&\"visible\"!=n.x)return Z;e=Nc(a);return e==Z?Z:\"scroll\"}M=e.f>=D.a+D.width;D=e.c>=D.b+D.height;if(M&&\"hidden\"==n.x||D&&\"hidden\"==n.y)return Z;if(M&&\"visible\"!=n.x||D&&\"visible\"!=n.y){if(v&&(n=d(a),e.f>=g.scrollWidth-n.x||e.a>=g.scrollHeight-n.y))return Z;e=Nc(a);return e==Z?Z:\"scroll\"}}}return\"none\"}\nfunction Mc(a){var b=Oc(a);if(b)return b.rect;if(W(a,\"HTML\"))return a=A(a),a=((a?a.parentWindow||a.defaultView:window)||window).document,a=\"CSS1Compat\"==a.compatMode?a.documentElement:a.body,a=new Xa(a.clientWidth,a.clientHeight),new X(0,0,a.width,a.height);try{var c=a.getBoundingClientRect()}catch(d){return new X(0,0,0,0)}b=new X(c.left,c.top,c.right-c.left,c.bottom-c.top);w&&a.ownerDocument.body&&(a=A(a),b.a-=a.documentElement.clientLeft+a.body.clientLeft,b.b-=a.documentElement.clientTop+a.body.clientTop);\nreturn b}function Oc(a){var b=W(a,\"MAP\");if(!b&&!W(a,\"AREA\"))return null;var c=b?a:W(a.parentNode,\"MAP\")?a.parentNode:null,d=null,e=null;c&&c.name&&(d=Gc('/descendant::*[@usemap = \"#'+c.name+'\"]',A(c)))&&(e=Mc(d),b||\"default\"==a.shape.toLowerCase()||(a=Sc(a),b=Math.min(Math.max(a.a,0),e.width),c=Math.min(Math.max(a.b,0),e.height),e=new X(b+e.a,c+e.b,Math.min(a.width,e.width-b),Math.min(a.height,e.height-c))));return{image:d,rect:e||new X(0,0,0,0)}}\nfunction Sc(a){var b=a.shape.toLowerCase();a=a.coords.split(\",\");if(\"rect\"==b&&4==a.length){b=a[0];var c=a[1];return new X(b,c,a[2]-b,a[3]-c)}if(\"circle\"==b&&3==a.length)return b=a[2],new X(a[0]-b,a[1]-b,2*b,2*b);if(\"poly\"==b&&2<a.length){b=a[0];c=a[1];for(var d=b,e=c,f=2;f+1<a.length;f+=2)b=Math.min(b,a[f]),d=Math.max(d,a[f]),c=Math.min(c,a[f+1]),e=Math.max(e,a[f+1]);return new X(b,c,d-b,e-c)}return new X(0,0,0,0)}function Rc(a){a=Mc(a);return new Hc(a.b,a.a+a.width,a.b+a.height,a.a)}\nfunction Tc(a){return a.replace(/^[^\\S\\xa0]+|[^\\S\\xa0]+$/g,\"\")}\nfunction Uc(a,b,c){if(W(a,\"BR\"))b.push(\"\");else{var d=W(a,\"TD\"),e=Y(a,\"display\"),f=!d&&!(0<=ja(Vc,e)),g=void 0!==a.previousElementSibling?a.previousElementSibling:Ya(a.previousSibling);g=g?Y(g,\"display\"):\"\";var h=Y(a,\"float\")||Y(a,\"cssFloat\")||Y(a,\"styleFloat\");!f||\"run-in\"==g&&\"none\"==h||/^[\\s\\xa0]*$/.test(b[b.length-1]||\"\")||b.push(\"\");var l=Qc(a),v=null,n=null;l&&(v=Y(a,\"white-space\"),n=Y(a,\"text-transform\"));p(a.childNodes,function(D){c(D,b,l,v,n)});a=b[b.length-1]||\"\";!d&&\"table-cell\"!=e||!a||\nsa(a)||(b[b.length-1]+=\" \");f&&\"run-in\"!=e&&!/^[\\s\\xa0]*$/.test(a)&&b.push(\"\")}}function Wc(a,b){Uc(a,b,function(c,d,e,f,g){3==c.nodeType&&e?Xc(c,d,f,g):W(c)&&Wc(c,d)})}var Vc=\"inline inline-block inline-table none table-cell table-column table-column-group\".split(\" \");\nfunction Xc(a,b,c,d){a=a.nodeValue.replace(/[\\u200b\\u200e\\u200f]/g,\"\");a=a.replace(/(\\r\\n|\\r|\\n)/g,\"\\n\");if(\"normal\"==c||\"nowrap\"==c)a=a.replace(/\\n/g,\" \");a=\"pre\"==c||\"pre-wrap\"==c?a.replace(/[ \\f\\t\\v\\u2028\\u2029]/g,\"\\u00a0\"):a.replace(/[ \\f\\t\\v\\u2028\\u2029]+/g,\" \");\"capitalize\"==d?a=a.replace(w?/(^|\\s|\\b)(\\S)/g:/(^|[^\\d\\p{L}\\p{S}])([\\p{Ll}|\\p{S}])/gu,function(e,f,g){return f+g.toUpperCase()}):\"uppercase\"==d?a=a.toUpperCase():\"lowercase\"==d&&(a=a.toLowerCase());c=b.pop()||\"\";sa(c)&&0==a.lastIndexOf(\" \",\n0)&&(a=a.substr(1));b.push(c+a)}function Pc(a){if(Dc){if(\"relative\"==Y(a,\"position\"))return 1;a=Y(a,\"filter\");return(a=a.match(/^alpha\\(opacity=(\\d*)\\)/)||a.match(/^progid:DXImageTransform.Microsoft.Alpha\\(Opacity=(\\d*)\\)/))?Number(a[1])/100:1}return Yc(a)}function Yc(a){var b=1,c=Y(a,\"opacity\");c&&(b=Number(c));(a=Jc(a))&&(b*=Yc(a));return b}\nfunction Zc(a,b,c,d,e){if(3==a.nodeType&&c)Xc(a,b,d,e);else if(W(a))if(W(a,\"CONTENT\")||W(a,\"SLOT\")){for(var f=a;f.parentNode;)f=f.parentNode;f instanceof ShadowRoot?(f=W(a,\"CONTENT\")?a.getDistributedNodes():a.assignedNodes(),p(0<f.length?f:a.childNodes,function(g){Zc(g,b,c,d,e)})):$c(a,b)}else if(W(a,\"SHADOW\")){for(f=a;f.parentNode;)f=f.parentNode;if(f instanceof ShadowRoot&&(a=f))for(a=a.olderShadowRoot;a;)p(a.childNodes,function(g){Zc(g,b,c,d,e)}),a=a.olderShadowRoot}else $c(a,b)}\nfunction $c(a,b){a.shadowRoot&&p(a.shadowRoot.childNodes,function(c){Zc(c,b,!0,null,null)});Uc(a,b,function(c,d,e,f,g){var h=null;1==c.nodeType?h=c:3==c.nodeType&&(h=c);null!=h&&(null!=h.assignedSlot||h.getDestinationInsertionPoints&&0<h.getDestinationInsertionPoints().length)||Zc(c,d,e,f,g)})};ba(\"_\",function(a){var b=[];Ic?$c(a,b):Wc(a,b);a=la(b,Tc);return Tc(a.join(\"\\n\")).replace(/\\xa0/g,\" \")});; return this._.apply(null,arguments);}).apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}\n", + isElementDisplayed: "function(){return (function(){var k=this||self;function aa(a){return\"string\"==typeof a}function ba(a,b){a=a.split(\".\");var c=k;a[0]in c||\"undefined\"==typeof c.execScript||c.execScript(\"var \"+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b}\nfunction ca(a){var b=typeof a;if(\"object\"==b)if(a){if(a instanceof Array)return\"array\";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if(\"[object Window]\"==c)return\"object\";if(\"[object Array]\"==c||\"number\"==typeof a.length&&\"undefined\"!=typeof a.splice&&\"undefined\"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable(\"splice\"))return\"array\";if(\"[object Function]\"==c||\"undefined\"!=typeof a.call&&\"undefined\"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable(\"call\"))return\"function\"}else return\"null\";\nelse if(\"function\"==b&&\"undefined\"==typeof a.call)return\"object\";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}\nfunction fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf(\"native code\")?fa=da:fa=ea;return fa.apply(null,arguments)}function ha(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var d=c.slice();d.push.apply(d,arguments);return a.apply(this,d)}}function l(a,b){function c(){}c.prototype=b.prototype;a.prototype=new c;a.prototype.constructor=a};/*\n\n The MIT License\n\n Copyright (c) 2007 Cybozu Labs, Inc.\n Copyright (c) 2012 Google Inc.\n\n Permission is hereby granted, free of charge, to any person obtaining a copy\n of this software and associated documentation files (the \"Software\"), to\n deal in the Software without restriction, including without limitation the\n rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the Software is\n furnished to do so, subject to the following conditions:\n\n The above copyright notice and this permission notice shall be included in\n all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n IN THE SOFTWARE.\n*/\nfunction ia(a,b,c){this.a=a;this.b=b||1;this.f=c||1};var ja=Array.prototype.indexOf?function(a,b){return Array.prototype.indexOf.call(a,b,void 0)}:function(a,b){if(\"string\"===typeof a)return\"string\"!==typeof b||1!=b.length?-1:a.indexOf(b,0);for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1},n=Array.prototype.forEach?function(a,b){Array.prototype.forEach.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)},ka=Array.prototype.filter?function(a,b){return Array.prototype.filter.call(a,\nb,void 0)}:function(a,b){for(var c=a.length,d=[],e=0,f=\"string\"===typeof a?a.split(\"\"):a,g=0;g<c;g++)if(g in f){var h=f[g];b.call(void 0,h,g,a)&&(d[e++]=h)}return d},la=Array.prototype.reduce?function(a,b,c){return Array.prototype.reduce.call(a,b,c)}:function(a,b,c){var d=c;n(a,function(e,f){d=b.call(void 0,d,e,f,a)});return d},ma=Array.prototype.some?function(a,b){return Array.prototype.some.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)if(e in\nd&&b.call(void 0,d[e],e,a))return!0;return!1},na=Array.prototype.every?function(a,b){return Array.prototype.every.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)if(e in d&&!b.call(void 0,d[e],e,a))return!1;return!0};function oa(a,b){a:{for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a)){b=e;break a}b=-1}return 0>b?null:\"string\"===typeof a?a.charAt(b):a[b]}\nfunction pa(a){return Array.prototype.concat.apply([],arguments)}function qa(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};var ra=String.prototype.trim?function(a){return a.trim()}:function(a){return/^[\\s\\xa0]*([\\s\\S]*?)[\\s\\xa0]*$/.exec(a)[1]};function sa(a,b){return a<b?-1:a>b?1:0};var t;a:{var ta=k.navigator;if(ta){var ua=ta.userAgent;if(ua){t=ua;break a}}t=\"\"}function u(a){return-1!=t.indexOf(a)};function va(){return u(\"Firefox\")||u(\"FxiOS\")}function wa(){return(u(\"Chrome\")||u(\"CriOS\"))&&!u(\"Edge\")};function xa(a){return String(a).replace(/\\-([a-z])/g,function(b,c){return c.toUpperCase()})};function ya(){return u(\"iPhone\")&&!u(\"iPod\")&&!u(\"iPad\")};function za(a,b){var c=Aa;return Object.prototype.hasOwnProperty.call(c,a)?c[a]:c[a]=b(a)};var Ba=u(\"Opera\"),v=u(\"Trident\")||u(\"MSIE\"),Ca=u(\"Edge\"),Da=u(\"Gecko\")&&!(-1!=t.toLowerCase().indexOf(\"webkit\")&&!u(\"Edge\"))&&!(u(\"Trident\")||u(\"MSIE\"))&&!u(\"Edge\"),Ea=-1!=t.toLowerCase().indexOf(\"webkit\")&&!u(\"Edge\");function Fa(){var a=k.document;return a?a.documentMode:void 0}var Ga;\na:{var Ha=\"\",Ia=function(){var a=t;if(Da)return/rv:([^\\);]+)(\\)|;)/.exec(a);if(Ca)return/Edge\\/([\\d\\.]+)/.exec(a);if(v)return/\\b(?:MSIE|rv)[: ]([^\\);]+)(\\)|;)/.exec(a);if(Ea)return/WebKit\\/(\\S+)/.exec(a);if(Ba)return/(?:Version)[ \\/]?(\\S+)/.exec(a)}();Ia&&(Ha=Ia?Ia[1]:\"\");if(v){var Ja=Fa();if(null!=Ja&&Ja>parseFloat(Ha)){Ga=String(Ja);break a}}Ga=Ha}var Aa={};\nfunction Ka(a){return za(a,function(){for(var b=0,c=ra(String(Ga)).split(\".\"),d=ra(String(a)).split(\".\"),e=Math.max(c.length,d.length),f=0;0==b&&f<e;f++){var g=c[f]||\"\",h=d[f]||\"\";do{g=/(\\d*)(\\D*)(.*)/.exec(g)||[\"\",\"\",\"\",\"\"];h=/(\\d*)(\\D*)(.*)/.exec(h)||[\"\",\"\",\"\",\"\"];if(0==g[0].length&&0==h[0].length)break;b=sa(0==g[1].length?0:parseInt(g[1],10),0==h[1].length?0:parseInt(h[1],10))||sa(0==g[2].length,0==h[2].length)||sa(g[2],h[2]);g=g[3];h=h[3]}while(0==b)}return 0<=b})}var La;\nLa=k.document&&v?Fa():void 0;var x=v&&!(9<=Number(La)),Ma=v&&!(8<=Number(La));function Na(a,b,c,d){this.a=a;this.nodeName=c;this.nodeValue=d;this.nodeType=2;this.parentNode=this.ownerElement=b}function Oa(a,b){var c=Ma&&\"href\"==b.nodeName?a.getAttribute(b.nodeName,2):b.nodeValue;return new Na(b,a,b.nodeName,c)};function Pa(a){this.b=a;this.a=0}function Qa(a){a=a.match(Ra);for(var b=0;b<a.length;b++)Sa.test(a[b])&&a.splice(b,1);return new Pa(a)}var Ra=/\\$?(?:(?![0-9-\\.])(?:\\*|[\\w-\\.]+):)?(?![0-9-\\.])(?:\\*|[\\w-\\.]+)|\\/\\/|\\.\\.|::|\\d+(?:\\.\\d*)?|\\.\\d+|\"[^\"]*\"|'[^']*'|[!<>]=|\\s+|./g,Sa=/^\\s/;function y(a,b){return a.b[a.a+(b||0)]}function z(a){return a.b[a.a++]}function Ta(a){return a.b.length<=a.a};function Ua(a,b){this.x=void 0!==a?a:0;this.y=void 0!==b?b:0}Ua.prototype.ceil=function(){this.x=Math.ceil(this.x);this.y=Math.ceil(this.y);return this};Ua.prototype.floor=function(){this.x=Math.floor(this.x);this.y=Math.floor(this.y);return this};Ua.prototype.round=function(){this.x=Math.round(this.x);this.y=Math.round(this.y);return this};function Va(a,b){this.width=a;this.height=b}Va.prototype.aspectRatio=function(){return this.width/this.height};Va.prototype.ceil=function(){this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};Va.prototype.floor=function(){this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};Va.prototype.round=function(){this.width=Math.round(this.width);this.height=Math.round(this.height);return this};function Wa(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if(\"undefined\"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}\nfunction Xa(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if(v&&!(9<=Number(La))){if(9==a.nodeType)return-1;if(9==b.nodeType)return 1}if(\"sourceIndex\"in a||a.parentNode&&\"sourceIndex\"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?Ya(a,b):!c&&Wa(e,b)?-1*Za(a,b):!d&&Wa(f,a)?Za(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=A(a);c=d.createRange();\nc.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b);a.collapse(!0);return c.compareBoundaryPoints(k.Range.START_TO_END,a)}function Za(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return Ya(b,a)}function Ya(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function A(a){return 9==a.nodeType?a:a.ownerDocument||a.document}function $a(a,b){a&&(a=a.parentNode);for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null}\nfunction ab(a){this.a=a||k.document||document}ab.prototype.getElementsByTagName=function(a,b){return(b||this.a).getElementsByTagName(String(a))};function B(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?\"\":b);if(\"string\"!=typeof b)if(x&&\"title\"==a.nodeName.toLowerCase()&&1==c)b=a.text;else if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;c=0;var d=[];for(b=\"\";a;){do 1!=a.nodeType&&(b+=a.nodeValue),x&&\"title\"==a.nodeName.toLowerCase()&&(b+=a.text),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return b}\nfunction C(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}Ma&&\"class\"==b&&(b=\"className\");return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function bb(a,b,c,d,e){return(x?cb:db).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)}\nfunction cb(a,b,c,d,e){if(a instanceof F||8==a.b||c&&null===a.b){var f=b.all;if(!f)return e;a=eb(a);if(\"*\"!=a&&(f=b.getElementsByTagName(a),!f))return e;if(c){for(var g=[],h=0;b=f[h++];)C(b,c,d)&&g.push(b);f=g}for(h=0;b=f[h++];)\"*\"==a&&\"!\"==b.tagName||e.add(b);return e}gb(a,b,c,d,e);return e}\nfunction db(a,b,c,d,e){b.getElementsByName&&d&&\"name\"==c&&!v?(b=b.getElementsByName(d),n(b,function(f){a.a(f)&&e.add(f)})):b.getElementsByClassName&&d&&\"class\"==c?(b=b.getElementsByClassName(d),n(b,function(f){f.className==d&&a.a(f)&&e.add(f)})):a instanceof G?gb(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),n(b,function(f){C(f,c,d)&&e.add(f)}));return e}\nfunction hb(a,b,c,d,e){var f;if((a instanceof F||8==a.b||c&&null===a.b)&&(f=b.childNodes)){var g=eb(a);if(\"*\"!=g&&(f=ka(f,function(h){return h.tagName&&h.tagName.toLowerCase()==g}),!f))return e;c&&(f=ka(f,function(h){return C(h,c,d)}));n(f,function(h){\"*\"==g&&(\"!\"==h.tagName||\"*\"==g&&1!=h.nodeType)||e.add(h)});return e}return ib(a,b,c,d,e)}function ib(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b);return e}\nfunction gb(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b),gb(a,b,c,d,e)}function eb(a){if(a instanceof G){if(8==a.b)return\"!\";if(null===a.b)return\"*\"}return a.f()};function E(){this.b=this.a=null;this.l=0}function jb(a){this.f=a;this.a=this.b=null}function kb(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;){e=c.f;var g=b.f;e==g||e instanceof Na&&g instanceof Na&&e.a==g.a?(e=c,c=c.a,b=b.a):0<Xa(c.f,b.f)?(e=b,b=b.a):(e=c,c=c.a);(e.b=d)?d.a=e:a.a=e;d=e;f++}for(e=c||b;e;)e.b=d,d=d.a=e,f++,e=e.a;a.b=d;a.l=f;return a}function lb(a,b){b=new jb(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}\nE.prototype.add=function(a){a=new jb(a);a.b=this.b;this.a?this.b.a=a:this.a=this.b=a;this.b=a;this.l++};function mb(a){return(a=a.a)?a.f:null}function nb(a){return(a=mb(a))?B(a):\"\"}function H(a,b){return new ob(a,!!b)}function ob(a,b){this.f=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function I(a){var b=a.b;if(null==b)return null;var c=a.a=b;a.b=a.s?b.b:b.a;return c.f};function J(a){this.i=a;this.b=this.g=!1;this.f=null}function K(a){return\"\\n \"+a.toString().split(\"\\n\").join(\"\\n \")}function pb(a,b){a.g=b}function qb(a,b){a.b=b}function N(a,b){a=a.a(b);return a instanceof E?+nb(a):+a}function O(a,b){a=a.a(b);return a instanceof E?nb(a):\"\"+a}function rb(a,b){a=a.a(b);return a instanceof E?!!a.l:!!a};function sb(a,b,c){J.call(this,a.i);this.c=a;this.h=b;this.o=c;this.g=b.g||c.g;this.b=b.b||c.b;this.c==tb&&(c.b||c.g||4==c.i||0==c.i||!b.f?b.b||b.g||4==b.i||0==b.i||!c.f||(this.f={name:c.f.name,u:b}):this.f={name:b.f.name,u:c})}l(sb,J);\nfunction ub(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof E&&c instanceof E){b=H(b);for(d=I(b);d;d=I(b))for(e=H(c),f=I(e);f;f=I(e))if(a(B(d),B(f)))return!0;return!1}if(b instanceof E||c instanceof E){b instanceof E?(e=b,d=c):(e=c,d=b);f=H(e);for(var g=typeof d,h=I(f);h;h=I(f)){switch(g){case \"number\":h=+B(h);break;case \"boolean\":h=!!B(h);break;case \"string\":h=B(h);break;default:throw Error(\"Illegal primitive type for comparison.\");}if(e==b&&a(h,d)||e==c&&a(d,h))return!0}return!1}return e?\"boolean\"==\ntypeof b||\"boolean\"==typeof c?a(!!b,!!c):\"number\"==typeof b||\"number\"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}sb.prototype.a=function(a){return this.c.m(this.h,this.o,a)};sb.prototype.toString=function(){var a=\"Binary Expression: \"+this.c;a+=K(this.h);return a+=K(this.o)};function vb(a,b,c,d){this.I=a;this.D=b;this.i=c;this.m=d}vb.prototype.toString=function(){return this.I};var wb={};\nfunction P(a,b,c,d){if(wb.hasOwnProperty(a))throw Error(\"Binary operator already created: \"+a);a=new vb(a,b,c,d);return wb[a.toString()]=a}P(\"div\",6,1,function(a,b,c){return N(a,c)/N(b,c)});P(\"mod\",6,1,function(a,b,c){return N(a,c)%N(b,c)});P(\"*\",6,1,function(a,b,c){return N(a,c)*N(b,c)});P(\"+\",5,1,function(a,b,c){return N(a,c)+N(b,c)});P(\"-\",5,1,function(a,b,c){return N(a,c)-N(b,c)});P(\"<\",4,2,function(a,b,c){return ub(function(d,e){return d<e},a,b,c)});\nP(\">\",4,2,function(a,b,c){return ub(function(d,e){return d>e},a,b,c)});P(\"<=\",4,2,function(a,b,c){return ub(function(d,e){return d<=e},a,b,c)});P(\">=\",4,2,function(a,b,c){return ub(function(d,e){return d>=e},a,b,c)});var tb=P(\"=\",3,2,function(a,b,c){return ub(function(d,e){return d==e},a,b,c,!0)});P(\"!=\",3,2,function(a,b,c){return ub(function(d,e){return d!=e},a,b,c,!0)});P(\"and\",2,2,function(a,b,c){return rb(a,c)&&rb(b,c)});P(\"or\",1,2,function(a,b,c){return rb(a,c)||rb(b,c)});function xb(a,b){if(b.a.length&&4!=a.i)throw Error(\"Primary expression must evaluate to nodeset if filter has predicate(s).\");J.call(this,a.i);this.c=a;this.h=b;this.g=a.g;this.b=a.b}l(xb,J);xb.prototype.a=function(a){a=this.c.a(a);return yb(this.h,a)};xb.prototype.toString=function(){var a=\"Filter:\"+K(this.c);return a+=K(this.h)};function zb(a,b){if(b.length<a.C)throw Error(\"Function \"+a.j+\" expects at least\"+a.C+\" arguments, \"+b.length+\" given\");if(null!==a.B&&b.length>a.B)throw Error(\"Function \"+a.j+\" expects at most \"+a.B+\" arguments, \"+b.length+\" given\");a.H&&n(b,function(c,d){if(4!=c.i)throw Error(\"Argument \"+d+\" to function \"+a.j+\" is not of type Nodeset: \"+c);});J.call(this,a.i);this.v=a;this.c=b;pb(this,a.g||ma(b,function(c){return c.g}));qb(this,a.G&&!b.length||a.F&&!!b.length||ma(b,function(c){return c.b}))}\nl(zb,J);zb.prototype.a=function(a){return this.v.m.apply(null,pa(a,this.c))};zb.prototype.toString=function(){var a=\"Function: \"+this.v;if(this.c.length){var b=la(this.c,function(c,d){return c+K(d)},\"Arguments:\");a+=K(b)}return a};function Ab(a,b,c,d,e,f,g,h){this.j=a;this.i=b;this.g=c;this.G=d;this.F=!1;this.m=e;this.C=f;this.B=void 0!==g?g:f;this.H=!!h}Ab.prototype.toString=function(){return this.j};var Bb={};\nfunction Q(a,b,c,d,e,f,g,h){if(Bb.hasOwnProperty(a))throw Error(\"Function already created: \"+a+\".\");Bb[a]=new Ab(a,b,c,d,e,f,g,h)}Q(\"boolean\",2,!1,!1,function(a,b){return rb(b,a)},1);Q(\"ceiling\",1,!1,!1,function(a,b){return Math.ceil(N(b,a))},1);Q(\"concat\",3,!1,!1,function(a,b){return la(qa(arguments,1),function(c,d){return c+O(d,a)},\"\")},2,null);Q(\"contains\",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return-1!=b.indexOf(a)},2);Q(\"count\",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0);\nQ(\"false\",2,!1,!1,function(){return!1},0);Q(\"floor\",1,!1,!1,function(a,b){return Math.floor(N(b,a))},1);Q(\"id\",4,!1,!1,function(a,b){function c(h){if(x){var m=e.all[h];if(m){if(m.nodeType&&h==m.id)return m;if(m.length)return oa(m,function(w){return h==w.id})}return null}return e.getElementById(h)}var d=a.a,e=9==d.nodeType?d:d.ownerDocument;a=O(b,a).split(/\\s+/);var f=[];n(a,function(h){h=c(h);!h||0<=ja(f,h)||f.push(h)});f.sort(Xa);var g=new E;n(f,function(h){g.add(h)});return g},1);\nQ(\"lang\",2,!1,!1,function(){return!1},1);Q(\"last\",1,!0,!1,function(a){if(1!=arguments.length)throw Error(\"Function last expects ()\");return a.f},0);Q(\"local-name\",3,!1,!0,function(a,b){return(a=b?mb(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():\"\"},0,1,!0);Q(\"name\",3,!1,!0,function(a,b){return(a=b?mb(b.a(a)):a.a)?a.nodeName.toLowerCase():\"\"},0,1,!0);Q(\"namespace-uri\",3,!0,!1,function(){return\"\"},0,1,!0);\nQ(\"normalize-space\",3,!1,!0,function(a,b){return(b?O(b,a):B(a.a)).replace(/[\\s\\xa0]+/g,\" \").replace(/^\\s+|\\s+$/g,\"\")},0,1);Q(\"not\",2,!1,!1,function(a,b){return!rb(b,a)},1);Q(\"number\",1,!1,!0,function(a,b){return b?N(b,a):+B(a.a)},0,1);Q(\"position\",1,!0,!1,function(a){return a.b},0);Q(\"round\",1,!1,!1,function(a,b){return Math.round(N(b,a))},1);Q(\"starts-with\",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return 0==b.lastIndexOf(a,0)},2);Q(\"string\",3,!1,!0,function(a,b){return b?O(b,a):B(a.a)},0,1);\nQ(\"string-length\",1,!1,!0,function(a,b){return(b?O(b,a):B(a.a)).length},0,1);Q(\"substring\",3,!1,!1,function(a,b,c,d){c=N(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return\"\";d=d?N(d,a):Infinity;if(isNaN(d)||-Infinity===d)return\"\";c=Math.round(c)-1;var e=Math.max(c,0);a=O(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);Q(\"substring-after\",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);c=b.indexOf(a);return-1==c?\"\":b.substring(c+a.length)},2);\nQ(\"substring-before\",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);a=b.indexOf(a);return-1==a?\"\":b.substring(0,a)},2);Q(\"sum\",1,!1,!1,function(a,b){a=H(b.a(a));b=0;for(var c=I(a);c;c=I(a))b+=+B(c);return b},1,1,!0);Q(\"translate\",3,!1,!1,function(a,b,c,d){b=O(b,a);c=O(c,a);var e=O(d,a);a={};for(d=0;d<c.length;d++){var f=c.charAt(d);f in a||(a[f]=e.charAt(d))}c=\"\";for(d=0;d<b.length;d++)f=b.charAt(d),c+=f in a?a[f]:f;return c},3);Q(\"true\",2,!1,!1,function(){return!0},0);function G(a,b){this.h=a;this.c=void 0!==b?b:null;this.b=null;switch(a){case \"comment\":this.b=8;break;case \"text\":this.b=3;break;case \"processing-instruction\":this.b=7;break;case \"node\":break;default:throw Error(\"Unexpected argument\");}}function Cb(a){return\"comment\"==a||\"text\"==a||\"processing-instruction\"==a||\"node\"==a}G.prototype.a=function(a){return null===this.b||this.b==a.nodeType};G.prototype.f=function(){return this.h};\nG.prototype.toString=function(){var a=\"Kind Test: \"+this.h;null===this.c||(a+=K(this.c));return a};function Db(a){J.call(this,3);this.c=a.substring(1,a.length-1)}l(Db,J);Db.prototype.a=function(){return this.c};Db.prototype.toString=function(){return\"Literal: \"+this.c};function F(a,b){this.j=a.toLowerCase();a=\"*\"==this.j?\"*\":\"http://www.w3.org/1999/xhtml\";this.c=b?b.toLowerCase():a}F.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=void 0!==a.localName?a.localName:a.nodeName;return\"*\"!=this.j&&this.j!=b.toLowerCase()?!1:\"*\"==this.c?!0:this.c==(a.namespaceURI?a.namespaceURI.toLowerCase():\"http://www.w3.org/1999/xhtml\")};F.prototype.f=function(){return this.j};\nF.prototype.toString=function(){return\"Name Test: \"+(\"http://www.w3.org/1999/xhtml\"==this.c?\"\":this.c+\":\")+this.j};function Eb(a){J.call(this,1);this.c=a}l(Eb,J);Eb.prototype.a=function(){return this.c};Eb.prototype.toString=function(){return\"Number: \"+this.c};function Fb(a,b){J.call(this,a.i);this.h=a;this.c=b;this.g=a.g;this.b=a.b;1==this.c.length&&(a=this.c[0],a.A||a.c!=Gb||(a=a.o,\"*\"!=a.f()&&(this.f={name:a.f(),u:null})))}l(Fb,J);function Hb(){J.call(this,4)}l(Hb,J);Hb.prototype.a=function(a){var b=new E;a=a.a;9==a.nodeType?b.add(a):b.add(a.ownerDocument);return b};Hb.prototype.toString=function(){return\"Root Helper Expression\"};function Ib(){J.call(this,4)}l(Ib,J);Ib.prototype.a=function(a){var b=new E;b.add(a.a);return b};Ib.prototype.toString=function(){return\"Context Helper Expression\"};\nfunction Jb(a){return\"/\"==a||\"//\"==a}Fb.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof E))throw Error(\"Filter expression must evaluate to nodeset.\");a=this.c;for(var c=0,d=a.length;c<d&&b.l;c++){var e=a[c],f=H(b,e.c.s);if(e.g||e.c!=Kb)if(e.g||e.c!=Lb){var g=I(f);for(b=e.a(new ia(g));null!=(g=I(f));)g=e.a(new ia(g)),b=kb(b,g)}else g=I(f),b=e.a(new ia(g));else{for(g=I(f);(b=I(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new ia(g))}}return b};\nFb.prototype.toString=function(){var a=\"Path Expression:\"+K(this.h);if(this.c.length){var b=la(this.c,function(c,d){return c+K(d)},\"Steps:\");a+=K(b)}return a};function Mb(a,b){this.a=a;this.s=!!b}\nfunction yb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=H(b),f=b.l,g,h=0;g=I(e);h++){var m=a.s?f-h:h+1;g=d.a(new ia(g,m,f));if(\"number\"==typeof g)m=m==g;else if(\"string\"==typeof g||\"boolean\"==typeof g)m=!!g;else if(g instanceof E)m=0<g.l;else throw Error(\"Predicate.evaluate returned an unexpected type.\");if(!m){m=e;g=m.f;var w=m.a;if(!w)throw Error(\"Next must be called at least once before remove.\");var r=w.b;w=w.a;r?r.a=w:g.a=w;w?w.b=r:g.b=r;g.l--;m.a=null}}return b}\nMb.prototype.toString=function(){return la(this.a,function(a,b){return a+K(b)},\"Predicates:\")};function R(a,b,c,d){J.call(this,4);this.c=a;this.o=b;this.h=c||new Mb([]);this.A=!!d;b=this.h;b=0<b.a.length?b.a[0].f:null;a.J&&b&&(a=b.name,a=x?a.toLowerCase():a,this.f={name:a,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.g||1==c.i||0==c.i){a=!0;break a}a=!1}this.g=a}l(R,J);\nR.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.u?O(c.u,a):null,f=1);if(this.A)if(this.g||this.c!=Nb)if(b=H((new R(Ob,new G(\"node\"))).a(a)),c=I(b))for(a=this.m(c,d,e,f);null!=(c=I(b));)a=kb(a,this.m(c,d,e,f));else a=new E;else a=bb(this.o,b,d,e),a=yb(this.h,a,f);else a=this.m(a.a,d,e,f);return a};R.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=yb(this.h,a,d)};\nR.prototype.toString=function(){var a=\"Step:\"+K(\"Operator: \"+(this.A?\"//\":\"/\"));this.c.j&&(a+=K(\"Axis: \"+this.c));a+=K(this.o);if(this.h.a.length){var b=la(this.h.a,function(c,d){return c+K(d)},\"Predicates:\");a+=K(b)}return a};function Pb(a,b,c,d){this.j=a;this.v=b;this.s=c;this.J=d}Pb.prototype.toString=function(){return this.j};var Qb={};function S(a,b,c,d){if(Qb.hasOwnProperty(a))throw Error(\"Axis already created: \"+a);b=new Pb(a,b,c,!!d);return Qb[a]=b}\nS(\"ancestor\",function(a,b){for(var c=new E;b=b.parentNode;)a.a(b)&&lb(c,b);return c},!0);S(\"ancestor-or-self\",function(a,b){var c=new E;do a.a(b)&&lb(c,b);while(b=b.parentNode);return c},!0);\nvar Gb=S(\"attribute\",function(a,b){var c=new E,d=a.f();if(\"style\"==d&&x&&b.style)return c.add(new Na(b.style,b,\"style\",b.style.cssText)),c;var e=b.attributes;if(e)if(a instanceof G&&null===a.b||\"*\"==d)for(a=0;d=e[a];a++)x?d.nodeValue&&c.add(Oa(b,d)):c.add(d);else(d=e.getNamedItem(d))&&(x?d.nodeValue&&c.add(Oa(b,d)):c.add(d));return c},!1),Nb=S(\"child\",function(a,b,c,d,e){return(x?hb:ib).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)},!1,!0);S(\"descendant\",bb,!1,!0);\nvar Ob=S(\"descendant-or-self\",function(a,b,c,d){var e=new E;C(b,c,d)&&a.a(b)&&e.add(b);return bb(a,b,c,d,e)},!1,!0),Kb=S(\"following\",function(a,b,c,d){var e=new E;do for(var f=b;f=f.nextSibling;)C(f,c,d)&&a.a(f)&&e.add(f),e=bb(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);S(\"following-sibling\",function(a,b){for(var c=new E;b=b.nextSibling;)a.a(b)&&c.add(b);return c},!1);S(\"namespace\",function(){return new E},!1);\nvar Rb=S(\"parent\",function(a,b){var c=new E;if(9==b.nodeType)return c;if(2==b.nodeType)return c.add(b.ownerElement),c;b=b.parentNode;a.a(b)&&c.add(b);return c},!1),Lb=S(\"preceding\",function(a,b,c,d){var e=new E,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,h=f.length;g<h;g++){var m=[];for(b=f[g];b=b.previousSibling;)m.unshift(b);for(var w=0,r=m.length;w<r;w++)b=m[w],C(b,c,d)&&a.a(b)&&e.add(b),e=bb(a,b,c,d,e)}return e},!0,!0);\nS(\"preceding-sibling\",function(a,b){for(var c=new E;b=b.previousSibling;)a.a(b)&&lb(c,b);return c},!0);var Sb=S(\"self\",function(a,b){var c=new E;a.a(b)&&c.add(b);return c},!1);function Tb(a){J.call(this,1);this.c=a;this.g=a.g;this.b=a.b}l(Tb,J);Tb.prototype.a=function(a){return-N(this.c,a)};Tb.prototype.toString=function(){return\"Unary Expression: -\"+K(this.c)};function Ub(a){J.call(this,4);this.c=a;pb(this,ma(this.c,function(b){return b.g}));qb(this,ma(this.c,function(b){return b.b}))}l(Ub,J);Ub.prototype.a=function(a){var b=new E;n(this.c,function(c){c=c.a(a);if(!(c instanceof E))throw Error(\"Path expression must evaluate to NodeSet.\");b=kb(b,c)});return b};Ub.prototype.toString=function(){return la(this.c,function(a,b){return a+K(b)},\"Union Expression:\")};function Vb(a,b){this.a=a;this.b=b}function Yb(a){for(var b,c=[];;){T(a,\"Missing right hand side of binary expression.\");b=Zb(a);var d=z(a.a);if(!d)break;var e=(d=wb[d]||null)&&d.D;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].D;)b=new sb(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new sb(c.pop(),c.pop(),b);return b}function T(a,b){if(Ta(a.a))throw Error(b);}function $b(a,b){a=z(a.a);if(a!=b)throw Error(\"Bad token, expected: \"+b+\" got: \"+a);}\nfunction ac(a){a=z(a.a);if(\")\"!=a)throw Error(\"Bad token: \"+a);}function bc(a){a=z(a.a);if(2>a.length)throw Error(\"Unclosed literal string\");return new Db(a)}\nfunction cc(a){var b=[];if(Jb(y(a.a))){var c=z(a.a);var d=y(a.a);if(\"/\"==c&&(Ta(a.a)||\".\"!=d&&\"..\"!=d&&\"@\"!=d&&\"*\"!=d&&!/(?![0-9])[\\w]/.test(d)))return new Hb;d=new Hb;T(a,\"Missing next location step.\");c=dc(a,c);b.push(c)}else{a:{c=y(a.a);d=c.charAt(0);switch(d){case \"$\":throw Error(\"Variable reference not allowed in HTML XPath\");case \"(\":z(a.a);c=Yb(a);T(a,'unclosed \"(\"');$b(a,\")\");break;case '\"':case \"'\":c=bc(a);break;default:if(isNaN(+c))if(!Cb(c)&&/(?![0-9])[\\w]/.test(d)&&\"(\"==y(a.a,1)){c=z(a.a);\nc=Bb[c]||null;z(a.a);for(d=[];\")\"!=y(a.a);){T(a,\"Missing function argument list.\");d.push(Yb(a));if(\",\"!=y(a.a))break;z(a.a)}T(a,\"Unclosed function argument list.\");ac(a);c=new zb(c,d)}else{c=null;break a}else c=new Eb(+z(a.a))}\"[\"==y(a.a)&&(d=new Mb(ec(a)),c=new xb(c,d))}if(c)if(Jb(y(a.a)))d=c;else return c;else c=dc(a,\"/\"),d=new Ib,b.push(c)}for(;Jb(y(a.a));)c=z(a.a),T(a,\"Missing next location step.\"),c=dc(a,c),b.push(c);return new Fb(d,b)}\nfunction dc(a,b){if(\"/\"!=b&&\"//\"!=b)throw Error('Step op should be \"/\" or \"//\"');if(\".\"==y(a.a)){var c=new R(Sb,new G(\"node\"));z(a.a);return c}if(\"..\"==y(a.a))return c=new R(Rb,new G(\"node\")),z(a.a),c;if(\"@\"==y(a.a)){var d=Gb;z(a.a);T(a,\"Missing attribute name\")}else if(\"::\"==y(a.a,1)){if(!/(?![0-9])[\\w]/.test(y(a.a).charAt(0)))throw Error(\"Bad token: \"+z(a.a));var e=z(a.a);d=Qb[e]||null;if(!d)throw Error(\"No axis with name: \"+e);z(a.a);T(a,\"Missing node name\")}else d=Nb;e=y(a.a);if(/(?![0-9])[\\w\\*]/.test(e.charAt(0)))if(\"(\"==\ny(a.a,1)){if(!Cb(e))throw Error(\"Invalid node type: \"+e);e=z(a.a);if(!Cb(e))throw Error(\"Invalid type name: \"+e);$b(a,\"(\");T(a,\"Bad nodetype\");var f=y(a.a).charAt(0),g=null;if('\"'==f||\"'\"==f)g=bc(a);T(a,\"Bad nodetype\");ac(a);e=new G(e,g)}else if(e=z(a.a),f=e.indexOf(\":\"),-1==f)e=new F(e);else{g=e.substring(0,f);if(\"*\"==g)var h=\"*\";else if(h=a.b(g),!h)throw Error(\"Namespace prefix not declared: \"+g);e=e.substr(f+1);e=new F(e,h)}else throw Error(\"Bad token: \"+z(a.a));a=new Mb(ec(a),d.s);return c||new R(d,\ne,a,\"//\"==b)}function ec(a){for(var b=[];\"[\"==y(a.a);){z(a.a);T(a,\"Missing predicate expression.\");var c=Yb(a);b.push(c);T(a,\"Unclosed predicate expression.\");$b(a,\"]\")}return b}function Zb(a){if(\"-\"==y(a.a))return z(a.a),new Tb(Zb(a));var b=cc(a);if(\"|\"!=y(a.a))a=b;else{for(b=[b];\"|\"==z(a.a);)T(a,\"Missing next union location path.\"),b.push(cc(a));a.a.a--;a=new Ub(b)}return a};function fc(a){switch(a.nodeType){case 1:return ha(gc,a);case 9:return fc(a.documentElement);case 11:case 10:case 6:case 12:return hc;default:return a.parentNode?fc(a.parentNode):hc}}function hc(){return null}function gc(a,b){if(a.prefix==b)return a.namespaceURI||\"http://www.w3.org/1999/xhtml\";var c=a.getAttributeNode(\"xmlns:\"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?gc(a.parentNode,b):null};function ic(a,b){if(!a.length)throw Error(\"Empty XPath expression.\");a=Qa(a);if(Ta(a))throw Error(\"Invalid XPath expression.\");b?\"function\"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=Yb(new Vb(a,b));if(!Ta(a))throw Error(\"Bad token: \"+z(a));this.evaluate=function(d,e){d=c.a(new ia(d));return new U(d,e)}}\nfunction U(a,b){if(0==b)if(a instanceof E)b=4;else if(\"string\"==typeof a)b=2;else if(\"number\"==typeof a)b=1;else if(\"boolean\"==typeof a)b=3;else throw Error(\"Unexpected evaluation result.\");if(2!=b&&1!=b&&3!=b&&!(a instanceof E))throw Error(\"value could not be converted to the specified type\");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof E?nb(a):\"\"+a;break;case 1:this.numberValue=a instanceof E?+nb(a):+a;break;case 3:this.booleanValue=a instanceof E?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c=\nH(a);var d=[];for(var e=I(c);e;e=I(c))d.push(e instanceof Na?e.a:e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:a=mb(a);this.singleNodeValue=a instanceof Na?a.a:a;break;default:throw Error(\"Unknown XPathResult type.\");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error(\"iterateNext called with wrong result type\");return f>=d.length?null:d[f++]};this.snapshotItem=function(g){if(6!=b&&7!=b)throw Error(\"snapshotItem called with wrong result type\");return g>=d.length||\n0>g?null:d[g]}}U.ANY_TYPE=0;U.NUMBER_TYPE=1;U.STRING_TYPE=2;U.BOOLEAN_TYPE=3;U.UNORDERED_NODE_ITERATOR_TYPE=4;U.ORDERED_NODE_ITERATOR_TYPE=5;U.UNORDERED_NODE_SNAPSHOT_TYPE=6;U.ORDERED_NODE_SNAPSHOT_TYPE=7;U.ANY_UNORDERED_NODE_TYPE=8;U.FIRST_ORDERED_NODE_TYPE=9;function jc(a){this.lookupNamespaceURI=fc(a)}\nfunction kc(a,b){a=a||k;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=U,c.evaluate=function(d,e,f,g){return(new ic(d,f)).evaluate(e,g)},c.createExpression=function(d,e){return new ic(d,e)},c.createNSResolver=function(d){return new jc(d)}}ba(\"wgxpath.install\",kc);ba(\"wgxpath.install\",kc);var lc={aliceblue:\"#f0f8ff\",antiquewhite:\"#faebd7\",aqua:\"#00ffff\",aquamarine:\"#7fffd4\",azure:\"#f0ffff\",beige:\"#f5f5dc\",bisque:\"#ffe4c4\",black:\"#000000\",blanchedalmond:\"#ffebcd\",blue:\"#0000ff\",blueviolet:\"#8a2be2\",brown:\"#a52a2a\",burlywood:\"#deb887\",cadetblue:\"#5f9ea0\",chartreuse:\"#7fff00\",chocolate:\"#d2691e\",coral:\"#ff7f50\",cornflowerblue:\"#6495ed\",cornsilk:\"#fff8dc\",crimson:\"#dc143c\",cyan:\"#00ffff\",darkblue:\"#00008b\",darkcyan:\"#008b8b\",darkgoldenrod:\"#b8860b\",darkgray:\"#a9a9a9\",darkgreen:\"#006400\",\ndarkgrey:\"#a9a9a9\",darkkhaki:\"#bdb76b\",darkmagenta:\"#8b008b\",darkolivegreen:\"#556b2f\",darkorange:\"#ff8c00\",darkorchid:\"#9932cc\",darkred:\"#8b0000\",darksalmon:\"#e9967a\",darkseagreen:\"#8fbc8f\",darkslateblue:\"#483d8b\",darkslategray:\"#2f4f4f\",darkslategrey:\"#2f4f4f\",darkturquoise:\"#00ced1\",darkviolet:\"#9400d3\",deeppink:\"#ff1493\",deepskyblue:\"#00bfff\",dimgray:\"#696969\",dimgrey:\"#696969\",dodgerblue:\"#1e90ff\",firebrick:\"#b22222\",floralwhite:\"#fffaf0\",forestgreen:\"#228b22\",fuchsia:\"#ff00ff\",gainsboro:\"#dcdcdc\",\nghostwhite:\"#f8f8ff\",gold:\"#ffd700\",goldenrod:\"#daa520\",gray:\"#808080\",green:\"#008000\",greenyellow:\"#adff2f\",grey:\"#808080\",honeydew:\"#f0fff0\",hotpink:\"#ff69b4\",indianred:\"#cd5c5c\",indigo:\"#4b0082\",ivory:\"#fffff0\",khaki:\"#f0e68c\",lavender:\"#e6e6fa\",lavenderblush:\"#fff0f5\",lawngreen:\"#7cfc00\",lemonchiffon:\"#fffacd\",lightblue:\"#add8e6\",lightcoral:\"#f08080\",lightcyan:\"#e0ffff\",lightgoldenrodyellow:\"#fafad2\",lightgray:\"#d3d3d3\",lightgreen:\"#90ee90\",lightgrey:\"#d3d3d3\",lightpink:\"#ffb6c1\",lightsalmon:\"#ffa07a\",\nlightseagreen:\"#20b2aa\",lightskyblue:\"#87cefa\",lightslategray:\"#778899\",lightslategrey:\"#778899\",lightsteelblue:\"#b0c4de\",lightyellow:\"#ffffe0\",lime:\"#00ff00\",limegreen:\"#32cd32\",linen:\"#faf0e6\",magenta:\"#ff00ff\",maroon:\"#800000\",mediumaquamarine:\"#66cdaa\",mediumblue:\"#0000cd\",mediumorchid:\"#ba55d3\",mediumpurple:\"#9370db\",mediumseagreen:\"#3cb371\",mediumslateblue:\"#7b68ee\",mediumspringgreen:\"#00fa9a\",mediumturquoise:\"#48d1cc\",mediumvioletred:\"#c71585\",midnightblue:\"#191970\",mintcream:\"#f5fffa\",mistyrose:\"#ffe4e1\",\nmoccasin:\"#ffe4b5\",navajowhite:\"#ffdead\",navy:\"#000080\",oldlace:\"#fdf5e6\",olive:\"#808000\",olivedrab:\"#6b8e23\",orange:\"#ffa500\",orangered:\"#ff4500\",orchid:\"#da70d6\",palegoldenrod:\"#eee8aa\",palegreen:\"#98fb98\",paleturquoise:\"#afeeee\",palevioletred:\"#db7093\",papayawhip:\"#ffefd5\",peachpuff:\"#ffdab9\",peru:\"#cd853f\",pink:\"#ffc0cb\",plum:\"#dda0dd\",powderblue:\"#b0e0e6\",purple:\"#800080\",red:\"#ff0000\",rosybrown:\"#bc8f8f\",royalblue:\"#4169e1\",saddlebrown:\"#8b4513\",salmon:\"#fa8072\",sandybrown:\"#f4a460\",seagreen:\"#2e8b57\",\nseashell:\"#fff5ee\",sienna:\"#a0522d\",silver:\"#c0c0c0\",skyblue:\"#87ceeb\",slateblue:\"#6a5acd\",slategray:\"#708090\",slategrey:\"#708090\",snow:\"#fffafa\",springgreen:\"#00ff7f\",steelblue:\"#4682b4\",tan:\"#d2b48c\",teal:\"#008080\",thistle:\"#d8bfd8\",tomato:\"#ff6347\",turquoise:\"#40e0d0\",violet:\"#ee82ee\",wheat:\"#f5deb3\",white:\"#ffffff\",whitesmoke:\"#f5f5f5\",yellow:\"#ffff00\",yellowgreen:\"#9acd32\"};var mc=\"backgroundColor borderTopColor borderRightColor borderBottomColor borderLeftColor color outlineColor\".split(\" \"),nc=/#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/,oc=/^#(?:[0-9a-f]{3}){1,2}$/i,pc=/^(?:rgba)?\\((\\d{1,3}),\\s?(\\d{1,3}),\\s?(\\d{1,3}),\\s?(0|1|0\\.\\d*)\\)$/i,qc=/^(?:rgb)?\\((0|[1-9]\\d{0,2}),\\s?(0|[1-9]\\d{0,2}),\\s?(0|[1-9]\\d{0,2})\\)$/i;function rc(a,b){this.code=a;this.a=V[a]||sc;this.message=b||\"\";a=this.a.replace(/((?:^|\\s+)[a-z])/g,function(c){return c.toUpperCase().replace(/^[\\s\\xa0]+/g,\"\")});b=a.length-5;if(0>b||a.indexOf(\"Error\",b)!=b)a+=\"Error\";this.name=a;a=Error(this.message);a.name=this.name;this.stack=a.stack||\"\"}l(rc,Error);var sc=\"unknown error\",V={15:\"element not selectable\",11:\"element not visible\"};V[31]=sc;V[30]=sc;V[24]=\"invalid cookie domain\";V[29]=\"invalid element coordinates\";V[12]=\"invalid element state\";\nV[32]=\"invalid selector\";V[51]=\"invalid selector\";V[52]=\"invalid selector\";V[17]=\"javascript error\";V[405]=\"unsupported operation\";V[34]=\"move target out of bounds\";V[27]=\"no such alert\";V[7]=\"no such element\";V[8]=\"no such frame\";V[23]=\"no such window\";V[28]=\"script timeout\";V[33]=\"session not created\";V[10]=\"stale element reference\";V[21]=\"timeout\";V[25]=\"unable to set cookie\";V[26]=\"unexpected alert open\";V[13]=sc;V[9]=\"unknown command\";var tc=va(),uc=ya()||u(\"iPod\"),vc=u(\"iPad\"),wc=u(\"Android\")&&!(wa()||va()||u(\"Opera\")||u(\"Silk\")),xc=wa(),yc=u(\"Safari\")&&!(wa()||u(\"Coast\")||u(\"Opera\")||u(\"Edge\")||u(\"Edg/\")||u(\"OPR\")||va()||u(\"Silk\")||u(\"Android\"))&&!(ya()||u(\"iPad\")||u(\"iPod\"));function zc(a){return(a=a.exec(t))?a[1]:\"\"}(function(){if(tc)return zc(/Firefox\\/([0-9.]+)/);if(v||Ca||Ba)return Ga;if(xc)return ya()||u(\"iPad\")||u(\"iPod\")?zc(/CriOS\\/([0-9.]+)/):zc(/Chrome\\/([0-9.]+)/);if(yc&&!(ya()||u(\"iPad\")||u(\"iPod\")))return zc(/Version\\/([0-9.]+)/);if(uc||vc){var a=/Version\\/(\\S+).*Mobile\\/(\\S+)/.exec(t);if(a)return a[1]+\".\"+a[2]}else if(wc)return(a=zc(/Android\\s+([0-9.]+)/))?a:zc(/Version\\/([0-9.]+)/);return\"\"})();var Ac=v&&!(9<=Number(La));function W(a,b){b&&\"string\"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};var Bc=function(){var a={K:\"http://www.w3.org/2000/svg\"};return function(b){return a[b]||null}}();\nfunction Cc(a,b){var c=A(a);if(!c.documentElement)return null;(v||wc)&&kc(c?c.parentWindow||c.defaultView:window);try{var d=c.createNSResolver?c.createNSResolver(c.documentElement):Bc;if(v&&!Ka(7))return c.evaluate.call(c,b,a,d,9,null);if(!v||9<=Number(La)){for(var e={},f=c.getElementsByTagName(\"*\"),g=0;g<f.length;++g){var h=f[g],m=h.namespaceURI;if(m&&!e[m]){var w=h.lookupPrefix(m);if(!w){var r=m.match(\".*/(\\\\w+)/?$\");w=r?r[1]:\"xhtml\"}e[m]=w}}var D={},L;for(L in e)D[e[L]]=L;d=function(M){return D[M]||\nnull}}try{return c.evaluate(b,a,d,9,null)}catch(M){if(\"TypeError\"===M.name)return d=c.createNSResolver?c.createNSResolver(c.documentElement):Bc,c.evaluate(b,a,d,9,null);throw M;}}catch(M){if(!Da||\"NS_ERROR_ILLEGAL_VALUE\"!=M.name)throw new rc(32,\"Unable to locate an element with the xpath expression \"+b+\" because of the following error:\\n\"+M);}}\nfunction Dc(a,b){var c=function(){var d=Cc(b,a);return d?d.singleNodeValue||null:b.selectSingleNode?(d=A(b),d.setProperty&&d.setProperty(\"SelectionLanguage\",\"XPath\"),b.selectSingleNode(a)):null}();if(null!==c&&(!c||1!=c.nodeType))throw new rc(32,'The result of the xpath expression \"'+a+'\" is: '+c+\". It should be an element.\");return c};function Ec(a,b,c,d){this.c=a;this.a=b;this.b=c;this.f=d}Ec.prototype.ceil=function(){this.c=Math.ceil(this.c);this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.f=Math.ceil(this.f);return this};Ec.prototype.floor=function(){this.c=Math.floor(this.c);this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.f=Math.floor(this.f);return this};Ec.prototype.round=function(){this.c=Math.round(this.c);this.a=Math.round(this.a);this.b=Math.round(this.b);this.f=Math.round(this.f);return this};function X(a,b,c,d){this.a=a;this.b=b;this.width=c;this.height=d}X.prototype.ceil=function(){this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};X.prototype.floor=function(){this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};\nX.prototype.round=function(){this.a=Math.round(this.a);this.b=Math.round(this.b);this.width=Math.round(this.width);this.height=Math.round(this.height);return this};var Fc=\"function\"===typeof ShadowRoot;function Gc(a){for(a=a.parentNode;a&&1!=a.nodeType&&9!=a.nodeType&&11!=a.nodeType;)a=a.parentNode;return W(a)?a:null}\nfunction Y(a,b){b=xa(b);if(\"float\"==b||\"cssFloat\"==b||\"styleFloat\"==b)b=Ac?\"styleFloat\":\"cssFloat\";a:{var c=b;var d=A(a);if(d.defaultView&&d.defaultView.getComputedStyle&&(d=d.defaultView.getComputedStyle(a,null))){c=d[c]||d.getPropertyValue(c)||\"\";break a}c=\"\"}a=c||Hc(a,b);if(null===a)a=null;else if(0<=ja(mc,b)){b:{var e=a.match(pc);if(e&&(b=Number(e[1]),c=Number(e[2]),d=Number(e[3]),e=Number(e[4]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d&&0<=e&&1>=e)){b=[b,c,d,e];break b}b=null}if(!b)b:{if(d=a.match(qc))if(b=\nNumber(d[1]),c=Number(d[2]),d=Number(d[3]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d){b=[b,c,d,1];break b}b=null}if(!b)b:{b=a.toLowerCase();c=lc[b.toLowerCase()];if(!c&&(c=\"#\"==b.charAt(0)?b:\"#\"+b,4==c.length&&(c=c.replace(nc,\"#$1$1$2$2$3$3\")),!oc.test(c))){b=null;break b}b=[parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16),1]}a=b?\"rgba(\"+b.join(\", \")+\")\":a}return a}\nfunction Hc(a,b){var c=a.currentStyle||a.style,d=c[b];void 0===d&&\"function\"==ca(c.getPropertyValue)&&(d=c.getPropertyValue(b));return\"inherit\"!=d?void 0!==d?d:null:(a=Gc(a))?Hc(a,b):null}\nfunction Ic(a,b,c){function d(g){var h=Jc(g);return 0<h.height&&0<h.width?!0:W(g,\"PATH\")&&(0<h.height||0<h.width)?(g=Y(g,\"stroke-width\"),!!g&&0<parseInt(g,10)):\"hidden\"!=Y(g,\"overflow\")&&ma(g.childNodes,function(m){return 3==m.nodeType||W(m)&&d(m)})}function e(g){return Kc(g)==Z&&na(g.childNodes,function(h){return!W(h)||e(h)||!d(h)})}if(!W(a))throw Error(\"Argument to isShown must be of type Element\");if(W(a,\"BODY\"))return!0;if(W(a,\"OPTION\")||W(a,\"OPTGROUP\"))return a=$a(a,function(g){return W(g,\"SELECT\")}),\n!!a&&Ic(a,!0,c);var f=Lc(a);if(f)return!!f.image&&0<f.rect.width&&0<f.rect.height&&Ic(f.image,b,c);if(W(a,\"INPUT\")&&\"hidden\"==a.type.toLowerCase()||W(a,\"NOSCRIPT\"))return!1;f=Y(a,\"visibility\");return\"collapse\"!=f&&\"hidden\"!=f&&c(a)&&(b||0!=Mc(a))&&d(a)?!e(a):!1}var Z=\"hidden\";\nfunction Kc(a){function b(p){function q(fb){if(fb==g)return!0;var Wb=Y(fb,\"display\");return 0==Wb.lastIndexOf(\"inline\",0)||\"contents\"==Wb||\"absolute\"==Xb&&\"static\"==Y(fb,\"position\")?!1:!0}var Xb=Y(p,\"position\");if(\"fixed\"==Xb)return w=!0,p==g?null:g;for(p=Gc(p);p&&!q(p);)p=Gc(p);return p}function c(p){var q=p;if(\"visible\"==m)if(p==g&&h)q=h;else if(p==h)return{x:\"visible\",y:\"visible\"};q={x:Y(q,\"overflow-x\"),y:Y(q,\"overflow-y\")};p==g&&(q.x=\"visible\"==q.x?\"auto\":q.x,q.y=\"visible\"==q.y?\"auto\":q.y);return q}\nfunction d(p){if(p==g){var q=(new ab(f)).a;p=q.scrollingElement?q.scrollingElement:Ea||\"CSS1Compat\"!=q.compatMode?q.body||q.documentElement:q.documentElement;q=q.parentWindow||q.defaultView;p=v&&Ka(\"10\")&&q.pageYOffset!=p.scrollTop?new Ua(p.scrollLeft,p.scrollTop):new Ua(q.pageXOffset||p.scrollLeft,q.pageYOffset||p.scrollTop)}else p=new Ua(p.scrollLeft,p.scrollTop);return p}var e=Nc(a),f=A(a),g=f.documentElement,h=f.body,m=Y(g,\"overflow\"),w;for(a=b(a);a;a=b(a)){var r=c(a);if(\"visible\"!=r.x||\"visible\"!=\nr.y){var D=Jc(a);if(0==D.width||0==D.height)return Z;var L=e.a<D.a,M=e.b<D.b;if(L&&\"hidden\"==r.x||M&&\"hidden\"==r.y)return Z;if(L&&\"visible\"!=r.x||M&&\"visible\"!=r.y){L=d(a);M=e.b<D.b-L.y;if(e.a<D.a-L.x&&\"visible\"!=r.x||M&&\"visible\"!=r.x)return Z;e=Kc(a);return e==Z?Z:\"scroll\"}L=e.f>=D.a+D.width;D=e.c>=D.b+D.height;if(L&&\"hidden\"==r.x||D&&\"hidden\"==r.y)return Z;if(L&&\"visible\"!=r.x||D&&\"visible\"!=r.y){if(w&&(r=d(a),e.f>=g.scrollWidth-r.x||e.a>=g.scrollHeight-r.y))return Z;e=Kc(a);return e==Z?Z:\"scroll\"}}}return\"none\"}\nfunction Jc(a){var b=Lc(a);if(b)return b.rect;if(W(a,\"HTML\"))return a=A(a),a=((a?a.parentWindow||a.defaultView:window)||window).document,a=\"CSS1Compat\"==a.compatMode?a.documentElement:a.body,a=new Va(a.clientWidth,a.clientHeight),new X(0,0,a.width,a.height);try{var c=a.getBoundingClientRect()}catch(d){return new X(0,0,0,0)}b=new X(c.left,c.top,c.right-c.left,c.bottom-c.top);v&&a.ownerDocument.body&&(a=A(a),b.a-=a.documentElement.clientLeft+a.body.clientLeft,b.b-=a.documentElement.clientTop+a.body.clientTop);\nreturn b}function Lc(a){var b=W(a,\"MAP\");if(!b&&!W(a,\"AREA\"))return null;var c=b?a:W(a.parentNode,\"MAP\")?a.parentNode:null,d=null,e=null;c&&c.name&&(d=Dc('/descendant::*[@usemap = \"#'+c.name+'\"]',A(c)))&&(e=Jc(d),b||\"default\"==a.shape.toLowerCase()||(a=Oc(a),b=Math.min(Math.max(a.a,0),e.width),c=Math.min(Math.max(a.b,0),e.height),e=new X(b+e.a,c+e.b,Math.min(a.width,e.width-b),Math.min(a.height,e.height-c))));return{image:d,rect:e||new X(0,0,0,0)}}\nfunction Oc(a){var b=a.shape.toLowerCase();a=a.coords.split(\",\");if(\"rect\"==b&&4==a.length){b=a[0];var c=a[1];return new X(b,c,a[2]-b,a[3]-c)}if(\"circle\"==b&&3==a.length)return b=a[2],new X(a[0]-b,a[1]-b,2*b,2*b);if(\"poly\"==b&&2<a.length){b=a[0];c=a[1];for(var d=b,e=c,f=2;f+1<a.length;f+=2)b=Math.min(b,a[f]),d=Math.max(d,a[f]),c=Math.min(c,a[f+1]),e=Math.max(e,a[f+1]);return new X(b,c,d-b,e-c)}return new X(0,0,0,0)}function Nc(a){a=Jc(a);return new Ec(a.b,a.a+a.width,a.b+a.height,a.a)}\nfunction Mc(a){if(Ac){if(\"relative\"==Y(a,\"position\"))return 1;a=Y(a,\"filter\");return(a=a.match(/^alpha\\(opacity=(\\d*)\\)/)||a.match(/^progid:DXImageTransform.Microsoft.Alpha\\(Opacity=(\\d*)\\)/))?Number(a[1])/100:1}return Pc(a)}function Pc(a){var b=1,c=Y(a,\"opacity\");c&&(b=Number(c));(a=Gc(a))&&(b*=Pc(a));return b};ba(\"_\",function(a,b){function c(d){if(W(d)&&\"none\"==Y(d,\"display\"))return!1;var e;if((e=d.parentNode)&&e.shadowRoot&&void 0!==d.assignedSlot)e=d.assignedSlot?d.assignedSlot.parentNode:null;else if(d.getDestinationInsertionPoints){var f=d.getDestinationInsertionPoints();0<f.length&&(e=f[f.length-1])}if(Fc&&e instanceof ShadowRoot){if(e.host.shadowRoot&&e.host.shadowRoot!==e)return!1;e=e.host}return!e||9!=e.nodeType&&11!=e.nodeType?e&&W(e,\"DETAILS\")&&!e.open&&!W(d,\"SUMMARY\")?!1:!!e&&c(e):!0}return Ic(a,\n!!b,c)});; return this._.apply(null,arguments);}).apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}\n", + isElementEnabled: "function(){return (function(){var k=this||self;function aa(a){return\"string\"==typeof a}function ba(a,b){a=a.split(\".\");var c=k;a[0]in c||\"undefined\"==typeof c.execScript||c.execScript(\"var \"+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b}\nfunction ca(a){var b=typeof a;if(\"object\"==b)if(a){if(a instanceof Array)return\"array\";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if(\"[object Window]\"==c)return\"object\";if(\"[object Array]\"==c||\"number\"==typeof a.length&&\"undefined\"!=typeof a.splice&&\"undefined\"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable(\"splice\"))return\"array\";if(\"[object Function]\"==c||\"undefined\"!=typeof a.call&&\"undefined\"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable(\"call\"))return\"function\"}else return\"null\";\nelse if(\"function\"==b&&\"undefined\"==typeof a.call)return\"object\";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}\nfunction fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf(\"native code\")?fa=da:fa=ea;return fa.apply(null,arguments)}function ha(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var d=c.slice();d.push.apply(d,arguments);return a.apply(this,d)}}function l(a,b){function c(){}c.prototype=b.prototype;a.prototype=new c;a.prototype.constructor=a};/*\n\n The MIT License\n\n Copyright (c) 2007 Cybozu Labs, Inc.\n Copyright (c) 2012 Google Inc.\n\n Permission is hereby granted, free of charge, to any person obtaining a copy\n of this software and associated documentation files (the \"Software\"), to\n deal in the Software without restriction, including without limitation the\n rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the Software is\n furnished to do so, subject to the following conditions:\n\n The above copyright notice and this permission notice shall be included in\n all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n IN THE SOFTWARE.\n*/\nfunction m(a,b,c){this.a=a;this.b=b||1;this.f=c||1};var ia=Array.prototype.indexOf?function(a,b){return Array.prototype.indexOf.call(a,b,void 0)}:function(a,b){if(\"string\"===typeof a)return\"string\"!==typeof b||1!=b.length?-1:a.indexOf(b,0);for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1},n=Array.prototype.forEach?function(a,b){Array.prototype.forEach.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)},ja=Array.prototype.filter?function(a,b){return Array.prototype.filter.call(a,\nb,void 0)}:function(a,b){for(var c=a.length,d=[],e=0,f=\"string\"===typeof a?a.split(\"\"):a,g=0;g<c;g++)if(g in f){var h=f[g];b.call(void 0,h,g,a)&&(d[e++]=h)}return d},p=Array.prototype.reduce?function(a,b,c){return Array.prototype.reduce.call(a,b,c)}:function(a,b,c){var d=c;n(a,function(e,f){d=b.call(void 0,d,e,f,a)});return d},r=Array.prototype.some?function(a,b){return Array.prototype.some.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)if(e in d&&\nb.call(void 0,d[e],e,a))return!0;return!1};function ka(a,b){a:{for(var c=a.length,d=\"string\"===typeof a?a.split(\"\"):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a)){b=e;break a}b=-1}return 0>b?null:\"string\"===typeof a?a.charAt(b):a[b]}function la(a){return Array.prototype.concat.apply([],arguments)}function ma(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};var t;a:{var na=k.navigator;if(na){var oa=na.userAgent;if(oa){t=oa;break a}}t=\"\"}function u(a){return-1!=t.indexOf(a)};function pa(){return u(\"Firefox\")||u(\"FxiOS\")}function qa(){return(u(\"Chrome\")||u(\"CriOS\"))&&!u(\"Edge\")};function ra(){return u(\"iPhone\")&&!u(\"iPod\")&&!u(\"iPad\")};var sa=u(\"Opera\"),v=u(\"Trident\")||u(\"MSIE\"),ta=u(\"Edge\"),ua=u(\"Gecko\")&&!(-1!=t.toLowerCase().indexOf(\"webkit\")&&!u(\"Edge\"))&&!(u(\"Trident\")||u(\"MSIE\"))&&!u(\"Edge\"),va=-1!=t.toLowerCase().indexOf(\"webkit\")&&!u(\"Edge\");function wa(){var a=k.document;return a?a.documentMode:void 0}var xa;\na:{var ya=\"\",za=function(){var a=t;if(ua)return/rv:([^\\);]+)(\\)|;)/.exec(a);if(ta)return/Edge\\/([\\d\\.]+)/.exec(a);if(v)return/\\b(?:MSIE|rv)[: ]([^\\);]+)(\\)|;)/.exec(a);if(va)return/WebKit\\/(\\S+)/.exec(a);if(sa)return/(?:Version)[ \\/]?(\\S+)/.exec(a)}();za&&(ya=za?za[1]:\"\");if(v){var Aa=wa();if(null!=Aa&&Aa>parseFloat(ya)){xa=String(Aa);break a}}xa=ya}var Ba;Ba=k.document&&v?wa():void 0;var w=v&&!(9<=Number(Ba)),Ca=v&&!(8<=Number(Ba));function y(a,b,c,d){this.a=a;this.nodeName=c;this.nodeValue=d;this.nodeType=2;this.parentNode=this.ownerElement=b}function Da(a,b){var c=Ca&&\"href\"==b.nodeName?a.getAttribute(b.nodeName,2):b.nodeValue;return new y(b,a,b.nodeName,c)};function Ea(a){this.b=a;this.a=0}function Fa(a){a=a.match(Ga);for(var b=0;b<a.length;b++)Ha.test(a[b])&&a.splice(b,1);return new Ea(a)}var Ga=/\\$?(?:(?![0-9-\\.])(?:\\*|[\\w-\\.]+):)?(?![0-9-\\.])(?:\\*|[\\w-\\.]+)|\\/\\/|\\.\\.|::|\\d+(?:\\.\\d*)?|\\.\\d+|\"[^\"]*\"|'[^']*'|[!<>]=|\\s+|./g,Ha=/^\\s/;function z(a,b){return a.b[a.a+(b||0)]}function A(a){return a.b[a.a++]}function Ia(a){return a.b.length<=a.a};function Ja(a){for(;a&&1!=a.nodeType;)a=a.previousSibling;return a}function Ka(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if(\"undefined\"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}\nfunction La(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if(v&&!(9<=Number(Ba))){if(9==a.nodeType)return-1;if(9==b.nodeType)return 1}if(\"sourceIndex\"in a||a.parentNode&&\"sourceIndex\"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?Ma(a,b):!c&&Ka(e,b)?-1*Na(a,b):!d&&Ka(f,a)?Na(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=9==a.nodeType?\na:a.ownerDocument||a.document;c=d.createRange();c.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b);a.collapse(!0);return c.compareBoundaryPoints(k.Range.START_TO_END,a)}function Na(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return Ma(b,a)}function Ma(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function Oa(a,b){for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null};function B(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?\"\":b);if(\"string\"!=typeof b)if(w&&\"title\"==a.nodeName.toLowerCase()&&1==c)b=a.text;else if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;c=0;var d=[];for(b=\"\";a;){do 1!=a.nodeType&&(b+=a.nodeValue),w&&\"title\"==a.nodeName.toLowerCase()&&(b+=a.text),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return b}\nfunction C(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}Ca&&\"class\"==b&&(b=\"className\");return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function D(a,b,c,d,e){return(w?Pa:Qa).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)}\nfunction Pa(a,b,c,d,e){if(a instanceof F||8==a.b||c&&null===a.b){var f=b.all;if(!f)return e;a=Ra(a);if(\"*\"!=a&&(f=b.getElementsByTagName(a),!f))return e;if(c){for(var g=[],h=0;b=f[h++];)C(b,c,d)&&g.push(b);f=g}for(h=0;b=f[h++];)\"*\"==a&&\"!\"==b.tagName||e.add(b);return e}Sa(a,b,c,d,e);return e}\nfunction Qa(a,b,c,d,e){b.getElementsByName&&d&&\"name\"==c&&!v?(b=b.getElementsByName(d),n(b,function(f){a.a(f)&&e.add(f)})):b.getElementsByClassName&&d&&\"class\"==c?(b=b.getElementsByClassName(d),n(b,function(f){f.className==d&&a.a(f)&&e.add(f)})):a instanceof G?Sa(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),n(b,function(f){C(f,c,d)&&e.add(f)}));return e}\nfunction Ta(a,b,c,d,e){var f;if((a instanceof F||8==a.b||c&&null===a.b)&&(f=b.childNodes)){var g=Ra(a);if(\"*\"!=g&&(f=ja(f,function(h){return h.tagName&&h.tagName.toLowerCase()==g}),!f))return e;c&&(f=ja(f,function(h){return C(h,c,d)}));n(f,function(h){\"*\"==g&&(\"!\"==h.tagName||\"*\"==g&&1!=h.nodeType)||e.add(h)});return e}return Ua(a,b,c,d,e)}function Ua(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b);return e}\nfunction Sa(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b),Sa(a,b,c,d,e)}function Ra(a){if(a instanceof G){if(8==a.b)return\"!\";if(null===a.b)return\"*\"}return a.f()};function E(){this.b=this.a=null;this.l=0}function Va(a){this.f=a;this.a=this.b=null}function Wa(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;){e=c.f;var g=b.f;e==g||e instanceof y&&g instanceof y&&e.a==g.a?(e=c,c=c.a,b=b.a):0<La(c.f,b.f)?(e=b,b=b.a):(e=c,c=c.a);(e.b=d)?d.a=e:a.a=e;d=e;f++}for(e=c||b;e;)e.b=d,d=d.a=e,f++,e=e.a;a.b=d;a.l=f;return a}function Xa(a,b){b=new Va(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}\nE.prototype.add=function(a){a=new Va(a);a.b=this.b;this.a?this.b.a=a:this.a=this.b=a;this.b=a;this.l++};function Ya(a){return(a=a.a)?a.f:null}function Za(a){return(a=Ya(a))?B(a):\"\"}function H(a,b){return new $a(a,!!b)}function $a(a,b){this.f=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function I(a){var b=a.b;if(null==b)return null;var c=a.a=b;a.b=a.s?b.b:b.a;return c.f};function J(a){this.i=a;this.b=this.g=!1;this.f=null}function K(a){return\"\\n \"+a.toString().split(\"\\n\").join(\"\\n \")}function ab(a,b){a.g=b}function bb(a,b){a.b=b}function L(a,b){a=a.a(b);return a instanceof E?+Za(a):+a}function M(a,b){a=a.a(b);return a instanceof E?Za(a):\"\"+a}function N(a,b){a=a.a(b);return a instanceof E?!!a.l:!!a};function O(a,b,c){J.call(this,a.i);this.c=a;this.h=b;this.o=c;this.g=b.g||c.g;this.b=b.b||c.b;this.c==cb&&(c.b||c.g||4==c.i||0==c.i||!b.f?b.b||b.g||4==b.i||0==b.i||!c.f||(this.f={name:c.f.name,u:b}):this.f={name:b.f.name,u:c})}l(O,J);\nfunction P(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof E&&c instanceof E){b=H(b);for(d=I(b);d;d=I(b))for(e=H(c),f=I(e);f;f=I(e))if(a(B(d),B(f)))return!0;return!1}if(b instanceof E||c instanceof E){b instanceof E?(e=b,d=c):(e=c,d=b);f=H(e);for(var g=typeof d,h=I(f);h;h=I(f)){switch(g){case \"number\":h=+B(h);break;case \"boolean\":h=!!B(h);break;case \"string\":h=B(h);break;default:throw Error(\"Illegal primitive type for comparison.\");}if(e==b&&a(h,d)||e==c&&a(d,h))return!0}return!1}return e?\"boolean\"==\ntypeof b||\"boolean\"==typeof c?a(!!b,!!c):\"number\"==typeof b||\"number\"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}O.prototype.a=function(a){return this.c.m(this.h,this.o,a)};O.prototype.toString=function(){var a=\"Binary Expression: \"+this.c;a+=K(this.h);return a+=K(this.o)};function db(a,b,c,d){this.I=a;this.D=b;this.i=c;this.m=d}db.prototype.toString=function(){return this.I};var eb={};\nfunction Q(a,b,c,d){if(eb.hasOwnProperty(a))throw Error(\"Binary operator already created: \"+a);a=new db(a,b,c,d);return eb[a.toString()]=a}Q(\"div\",6,1,function(a,b,c){return L(a,c)/L(b,c)});Q(\"mod\",6,1,function(a,b,c){return L(a,c)%L(b,c)});Q(\"*\",6,1,function(a,b,c){return L(a,c)*L(b,c)});Q(\"+\",5,1,function(a,b,c){return L(a,c)+L(b,c)});Q(\"-\",5,1,function(a,b,c){return L(a,c)-L(b,c)});Q(\"<\",4,2,function(a,b,c){return P(function(d,e){return d<e},a,b,c)});\nQ(\">\",4,2,function(a,b,c){return P(function(d,e){return d>e},a,b,c)});Q(\"<=\",4,2,function(a,b,c){return P(function(d,e){return d<=e},a,b,c)});Q(\">=\",4,2,function(a,b,c){return P(function(d,e){return d>=e},a,b,c)});var cb=Q(\"=\",3,2,function(a,b,c){return P(function(d,e){return d==e},a,b,c,!0)});Q(\"!=\",3,2,function(a,b,c){return P(function(d,e){return d!=e},a,b,c,!0)});Q(\"and\",2,2,function(a,b,c){return N(a,c)&&N(b,c)});Q(\"or\",1,2,function(a,b,c){return N(a,c)||N(b,c)});function fb(a,b){if(b.a.length&&4!=a.i)throw Error(\"Primary expression must evaluate to nodeset if filter has predicate(s).\");J.call(this,a.i);this.c=a;this.h=b;this.g=a.g;this.b=a.b}l(fb,J);fb.prototype.a=function(a){a=this.c.a(a);return gb(this.h,a)};fb.prototype.toString=function(){var a=\"Filter:\"+K(this.c);return a+=K(this.h)};function hb(a,b){if(b.length<a.C)throw Error(\"Function \"+a.j+\" expects at least\"+a.C+\" arguments, \"+b.length+\" given\");if(null!==a.B&&b.length>a.B)throw Error(\"Function \"+a.j+\" expects at most \"+a.B+\" arguments, \"+b.length+\" given\");a.H&&n(b,function(c,d){if(4!=c.i)throw Error(\"Argument \"+d+\" to function \"+a.j+\" is not of type Nodeset: \"+c);});J.call(this,a.i);this.v=a;this.c=b;ab(this,a.g||r(b,function(c){return c.g}));bb(this,a.G&&!b.length||a.F&&!!b.length||r(b,function(c){return c.b}))}l(hb,J);\nhb.prototype.a=function(a){return this.v.m.apply(null,la(a,this.c))};hb.prototype.toString=function(){var a=\"Function: \"+this.v;if(this.c.length){var b=p(this.c,function(c,d){return c+K(d)},\"Arguments:\");a+=K(b)}return a};function ib(a,b,c,d,e,f,g,h){this.j=a;this.i=b;this.g=c;this.G=d;this.F=!1;this.m=e;this.C=f;this.B=void 0!==g?g:f;this.H=!!h}ib.prototype.toString=function(){return this.j};var jb={};\nfunction R(a,b,c,d,e,f,g,h){if(jb.hasOwnProperty(a))throw Error(\"Function already created: \"+a+\".\");jb[a]=new ib(a,b,c,d,e,f,g,h)}R(\"boolean\",2,!1,!1,function(a,b){return N(b,a)},1);R(\"ceiling\",1,!1,!1,function(a,b){return Math.ceil(L(b,a))},1);R(\"concat\",3,!1,!1,function(a,b){return p(ma(arguments,1),function(c,d){return c+M(d,a)},\"\")},2,null);R(\"contains\",2,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);return-1!=b.indexOf(a)},2);R(\"count\",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0);\nR(\"false\",2,!1,!1,function(){return!1},0);R(\"floor\",1,!1,!1,function(a,b){return Math.floor(L(b,a))},1);R(\"id\",4,!1,!1,function(a,b){function c(h){if(w){var q=e.all[h];if(q){if(q.nodeType&&h==q.id)return q;if(q.length)return ka(q,function(x){return h==x.id})}return null}return e.getElementById(h)}var d=a.a,e=9==d.nodeType?d:d.ownerDocument;a=M(b,a).split(/\\s+/);var f=[];n(a,function(h){h=c(h);!h||0<=ia(f,h)||f.push(h)});f.sort(La);var g=new E;n(f,function(h){g.add(h)});return g},1);\nR(\"lang\",2,!1,!1,function(){return!1},1);R(\"last\",1,!0,!1,function(a){if(1!=arguments.length)throw Error(\"Function last expects ()\");return a.f},0);R(\"local-name\",3,!1,!0,function(a,b){return(a=b?Ya(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():\"\"},0,1,!0);R(\"name\",3,!1,!0,function(a,b){return(a=b?Ya(b.a(a)):a.a)?a.nodeName.toLowerCase():\"\"},0,1,!0);R(\"namespace-uri\",3,!0,!1,function(){return\"\"},0,1,!0);\nR(\"normalize-space\",3,!1,!0,function(a,b){return(b?M(b,a):B(a.a)).replace(/[\\s\\xa0]+/g,\" \").replace(/^\\s+|\\s+$/g,\"\")},0,1);R(\"not\",2,!1,!1,function(a,b){return!N(b,a)},1);R(\"number\",1,!1,!0,function(a,b){return b?L(b,a):+B(a.a)},0,1);R(\"position\",1,!0,!1,function(a){return a.b},0);R(\"round\",1,!1,!1,function(a,b){return Math.round(L(b,a))},1);R(\"starts-with\",2,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);return 0==b.lastIndexOf(a,0)},2);R(\"string\",3,!1,!0,function(a,b){return b?M(b,a):B(a.a)},0,1);\nR(\"string-length\",1,!1,!0,function(a,b){return(b?M(b,a):B(a.a)).length},0,1);R(\"substring\",3,!1,!1,function(a,b,c,d){c=L(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return\"\";d=d?L(d,a):Infinity;if(isNaN(d)||-Infinity===d)return\"\";c=Math.round(c)-1;var e=Math.max(c,0);a=M(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);R(\"substring-after\",3,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);c=b.indexOf(a);return-1==c?\"\":b.substring(c+a.length)},2);\nR(\"substring-before\",3,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);a=b.indexOf(a);return-1==a?\"\":b.substring(0,a)},2);R(\"sum\",1,!1,!1,function(a,b){a=H(b.a(a));b=0;for(var c=I(a);c;c=I(a))b+=+B(c);return b},1,1,!0);R(\"translate\",3,!1,!1,function(a,b,c,d){b=M(b,a);c=M(c,a);var e=M(d,a);a={};for(d=0;d<c.length;d++){var f=c.charAt(d);f in a||(a[f]=e.charAt(d))}c=\"\";for(d=0;d<b.length;d++)f=b.charAt(d),c+=f in a?a[f]:f;return c},3);R(\"true\",2,!1,!1,function(){return!0},0);function G(a,b){this.h=a;this.c=void 0!==b?b:null;this.b=null;switch(a){case \"comment\":this.b=8;break;case \"text\":this.b=3;break;case \"processing-instruction\":this.b=7;break;case \"node\":break;default:throw Error(\"Unexpected argument\");}}function kb(a){return\"comment\"==a||\"text\"==a||\"processing-instruction\"==a||\"node\"==a}G.prototype.a=function(a){return null===this.b||this.b==a.nodeType};G.prototype.f=function(){return this.h};\nG.prototype.toString=function(){var a=\"Kind Test: \"+this.h;null===this.c||(a+=K(this.c));return a};function lb(a){J.call(this,3);this.c=a.substring(1,a.length-1)}l(lb,J);lb.prototype.a=function(){return this.c};lb.prototype.toString=function(){return\"Literal: \"+this.c};function F(a,b){this.j=a.toLowerCase();a=\"*\"==this.j?\"*\":\"http://www.w3.org/1999/xhtml\";this.c=b?b.toLowerCase():a}F.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=void 0!==a.localName?a.localName:a.nodeName;return\"*\"!=this.j&&this.j!=b.toLowerCase()?!1:\"*\"==this.c?!0:this.c==(a.namespaceURI?a.namespaceURI.toLowerCase():\"http://www.w3.org/1999/xhtml\")};F.prototype.f=function(){return this.j};\nF.prototype.toString=function(){return\"Name Test: \"+(\"http://www.w3.org/1999/xhtml\"==this.c?\"\":this.c+\":\")+this.j};function mb(a){J.call(this,1);this.c=a}l(mb,J);mb.prototype.a=function(){return this.c};mb.prototype.toString=function(){return\"Number: \"+this.c};function nb(a,b){J.call(this,a.i);this.h=a;this.c=b;this.g=a.g;this.b=a.b;1==this.c.length&&(a=this.c[0],a.A||a.c!=ob||(a=a.o,\"*\"!=a.f()&&(this.f={name:a.f(),u:null})))}l(nb,J);function S(){J.call(this,4)}l(S,J);S.prototype.a=function(a){var b=new E;a=a.a;9==a.nodeType?b.add(a):b.add(a.ownerDocument);return b};S.prototype.toString=function(){return\"Root Helper Expression\"};function pb(){J.call(this,4)}l(pb,J);pb.prototype.a=function(a){var b=new E;b.add(a.a);return b};pb.prototype.toString=function(){return\"Context Helper Expression\"};\nfunction qb(a){return\"/\"==a||\"//\"==a}nb.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof E))throw Error(\"Filter expression must evaluate to nodeset.\");a=this.c;for(var c=0,d=a.length;c<d&&b.l;c++){var e=a[c],f=H(b,e.c.s);if(e.g||e.c!=rb)if(e.g||e.c!=sb){var g=I(f);for(b=e.a(new m(g));null!=(g=I(f));)g=e.a(new m(g)),b=Wa(b,g)}else g=I(f),b=e.a(new m(g));else{for(g=I(f);(b=I(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new m(g))}}return b};\nnb.prototype.toString=function(){var a=\"Path Expression:\"+K(this.h);if(this.c.length){var b=p(this.c,function(c,d){return c+K(d)},\"Steps:\");a+=K(b)}return a};function tb(a,b){this.a=a;this.s=!!b}\nfunction gb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=H(b),f=b.l,g,h=0;g=I(e);h++){var q=a.s?f-h:h+1;g=d.a(new m(g,q,f));if(\"number\"==typeof g)q=q==g;else if(\"string\"==typeof g||\"boolean\"==typeof g)q=!!g;else if(g instanceof E)q=0<g.l;else throw Error(\"Predicate.evaluate returned an unexpected type.\");if(!q){q=e;g=q.f;var x=q.a;if(!x)throw Error(\"Next must be called at least once before remove.\");var T=x.b;x=x.a;T?T.a=x:g.a=x;x?x.b=T:g.b=T;g.l--;q.a=null}}return b}\ntb.prototype.toString=function(){return p(this.a,function(a,b){return a+K(b)},\"Predicates:\")};function U(a,b,c,d){J.call(this,4);this.c=a;this.o=b;this.h=c||new tb([]);this.A=!!d;b=this.h;b=0<b.a.length?b.a[0].f:null;a.J&&b&&(a=b.name,a=w?a.toLowerCase():a,this.f={name:a,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.g||1==c.i||0==c.i){a=!0;break a}a=!1}this.g=a}l(U,J);\nU.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.u?M(c.u,a):null,f=1);if(this.A)if(this.g||this.c!=ub)if(b=H((new U(vb,new G(\"node\"))).a(a)),c=I(b))for(a=this.m(c,d,e,f);null!=(c=I(b));)a=Wa(a,this.m(c,d,e,f));else a=new E;else a=D(this.o,b,d,e),a=gb(this.h,a,f);else a=this.m(a.a,d,e,f);return a};U.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=gb(this.h,a,d)};\nU.prototype.toString=function(){var a=\"Step:\"+K(\"Operator: \"+(this.A?\"//\":\"/\"));this.c.j&&(a+=K(\"Axis: \"+this.c));a+=K(this.o);if(this.h.a.length){var b=p(this.h.a,function(c,d){return c+K(d)},\"Predicates:\");a+=K(b)}return a};function wb(a,b,c,d){this.j=a;this.v=b;this.s=c;this.J=d}wb.prototype.toString=function(){return this.j};var xb={};function V(a,b,c,d){if(xb.hasOwnProperty(a))throw Error(\"Axis already created: \"+a);b=new wb(a,b,c,!!d);return xb[a]=b}\nV(\"ancestor\",function(a,b){for(var c=new E;b=b.parentNode;)a.a(b)&&Xa(c,b);return c},!0);V(\"ancestor-or-self\",function(a,b){var c=new E;do a.a(b)&&Xa(c,b);while(b=b.parentNode);return c},!0);\nvar ob=V(\"attribute\",function(a,b){var c=new E,d=a.f();if(\"style\"==d&&w&&b.style)return c.add(new y(b.style,b,\"style\",b.style.cssText)),c;var e=b.attributes;if(e)if(a instanceof G&&null===a.b||\"*\"==d)for(a=0;d=e[a];a++)w?d.nodeValue&&c.add(Da(b,d)):c.add(d);else(d=e.getNamedItem(d))&&(w?d.nodeValue&&c.add(Da(b,d)):c.add(d));return c},!1),ub=V(\"child\",function(a,b,c,d,e){return(w?Ta:Ua).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)},!1,!0);V(\"descendant\",D,!1,!0);\nvar vb=V(\"descendant-or-self\",function(a,b,c,d){var e=new E;C(b,c,d)&&a.a(b)&&e.add(b);return D(a,b,c,d,e)},!1,!0),rb=V(\"following\",function(a,b,c,d){var e=new E;do for(var f=b;f=f.nextSibling;)C(f,c,d)&&a.a(f)&&e.add(f),e=D(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);V(\"following-sibling\",function(a,b){for(var c=new E;b=b.nextSibling;)a.a(b)&&c.add(b);return c},!1);V(\"namespace\",function(){return new E},!1);\nvar yb=V(\"parent\",function(a,b){var c=new E;if(9==b.nodeType)return c;if(2==b.nodeType)return c.add(b.ownerElement),c;b=b.parentNode;a.a(b)&&c.add(b);return c},!1),sb=V(\"preceding\",function(a,b,c,d){var e=new E,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,h=f.length;g<h;g++){var q=[];for(b=f[g];b=b.previousSibling;)q.unshift(b);for(var x=0,T=q.length;x<T;x++)b=q[x],C(b,c,d)&&a.a(b)&&e.add(b),e=D(a,b,c,d,e)}return e},!0,!0);\nV(\"preceding-sibling\",function(a,b){for(var c=new E;b=b.previousSibling;)a.a(b)&&Xa(c,b);return c},!0);var zb=V(\"self\",function(a,b){var c=new E;a.a(b)&&c.add(b);return c},!1);function Ab(a){J.call(this,1);this.c=a;this.g=a.g;this.b=a.b}l(Ab,J);Ab.prototype.a=function(a){return-L(this.c,a)};Ab.prototype.toString=function(){return\"Unary Expression: -\"+K(this.c)};function Bb(a){J.call(this,4);this.c=a;ab(this,r(this.c,function(b){return b.g}));bb(this,r(this.c,function(b){return b.b}))}l(Bb,J);Bb.prototype.a=function(a){var b=new E;n(this.c,function(c){c=c.a(a);if(!(c instanceof E))throw Error(\"Path expression must evaluate to NodeSet.\");b=Wa(b,c)});return b};Bb.prototype.toString=function(){return p(this.c,function(a,b){return a+K(b)},\"Union Expression:\")};function Cb(a,b){this.a=a;this.b=b}function Db(a){for(var b,c=[];;){W(a,\"Missing right hand side of binary expression.\");b=Eb(a);var d=A(a.a);if(!d)break;var e=(d=eb[d]||null)&&d.D;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].D;)b=new O(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new O(c.pop(),c.pop(),b);return b}function W(a,b){if(Ia(a.a))throw Error(b);}function Fb(a,b){a=A(a.a);if(a!=b)throw Error(\"Bad token, expected: \"+b+\" got: \"+a);}\nfunction Gb(a){a=A(a.a);if(\")\"!=a)throw Error(\"Bad token: \"+a);}function Hb(a){a=A(a.a);if(2>a.length)throw Error(\"Unclosed literal string\");return new lb(a)}\nfunction Ib(a){var b=[];if(qb(z(a.a))){var c=A(a.a);var d=z(a.a);if(\"/\"==c&&(Ia(a.a)||\".\"!=d&&\"..\"!=d&&\"@\"!=d&&\"*\"!=d&&!/(?![0-9])[\\w]/.test(d)))return new S;d=new S;W(a,\"Missing next location step.\");c=Jb(a,c);b.push(c)}else{a:{c=z(a.a);d=c.charAt(0);switch(d){case \"$\":throw Error(\"Variable reference not allowed in HTML XPath\");case \"(\":A(a.a);c=Db(a);W(a,'unclosed \"(\"');Fb(a,\")\");break;case '\"':case \"'\":c=Hb(a);break;default:if(isNaN(+c))if(!kb(c)&&/(?![0-9])[\\w]/.test(d)&&\"(\"==z(a.a,1)){c=A(a.a);\nc=jb[c]||null;A(a.a);for(d=[];\")\"!=z(a.a);){W(a,\"Missing function argument list.\");d.push(Db(a));if(\",\"!=z(a.a))break;A(a.a)}W(a,\"Unclosed function argument list.\");Gb(a);c=new hb(c,d)}else{c=null;break a}else c=new mb(+A(a.a))}\"[\"==z(a.a)&&(d=new tb(Kb(a)),c=new fb(c,d))}if(c)if(qb(z(a.a)))d=c;else return c;else c=Jb(a,\"/\"),d=new pb,b.push(c)}for(;qb(z(a.a));)c=A(a.a),W(a,\"Missing next location step.\"),c=Jb(a,c),b.push(c);return new nb(d,b)}\nfunction Jb(a,b){if(\"/\"!=b&&\"//\"!=b)throw Error('Step op should be \"/\" or \"//\"');if(\".\"==z(a.a)){var c=new U(zb,new G(\"node\"));A(a.a);return c}if(\"..\"==z(a.a))return c=new U(yb,new G(\"node\")),A(a.a),c;if(\"@\"==z(a.a)){var d=ob;A(a.a);W(a,\"Missing attribute name\")}else if(\"::\"==z(a.a,1)){if(!/(?![0-9])[\\w]/.test(z(a.a).charAt(0)))throw Error(\"Bad token: \"+A(a.a));var e=A(a.a);d=xb[e]||null;if(!d)throw Error(\"No axis with name: \"+e);A(a.a);W(a,\"Missing node name\")}else d=ub;e=z(a.a);if(/(?![0-9])[\\w\\*]/.test(e.charAt(0)))if(\"(\"==\nz(a.a,1)){if(!kb(e))throw Error(\"Invalid node type: \"+e);e=A(a.a);if(!kb(e))throw Error(\"Invalid type name: \"+e);Fb(a,\"(\");W(a,\"Bad nodetype\");var f=z(a.a).charAt(0),g=null;if('\"'==f||\"'\"==f)g=Hb(a);W(a,\"Bad nodetype\");Gb(a);e=new G(e,g)}else if(e=A(a.a),f=e.indexOf(\":\"),-1==f)e=new F(e);else{g=e.substring(0,f);if(\"*\"==g)var h=\"*\";else if(h=a.b(g),!h)throw Error(\"Namespace prefix not declared: \"+g);e=e.substr(f+1);e=new F(e,h)}else throw Error(\"Bad token: \"+A(a.a));a=new tb(Kb(a),d.s);return c||new U(d,\ne,a,\"//\"==b)}function Kb(a){for(var b=[];\"[\"==z(a.a);){A(a.a);W(a,\"Missing predicate expression.\");var c=Db(a);b.push(c);W(a,\"Unclosed predicate expression.\");Fb(a,\"]\")}return b}function Eb(a){if(\"-\"==z(a.a))return A(a.a),new Ab(Eb(a));var b=Ib(a);if(\"|\"!=z(a.a))a=b;else{for(b=[b];\"|\"==A(a.a);)W(a,\"Missing next union location path.\"),b.push(Ib(a));a.a.a--;a=new Bb(b)}return a};function Lb(a){switch(a.nodeType){case 1:return ha(Mb,a);case 9:return Lb(a.documentElement);case 11:case 10:case 6:case 12:return Nb;default:return a.parentNode?Lb(a.parentNode):Nb}}function Nb(){return null}function Mb(a,b){if(a.prefix==b)return a.namespaceURI||\"http://www.w3.org/1999/xhtml\";var c=a.getAttributeNode(\"xmlns:\"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?Mb(a.parentNode,b):null};function Ob(a,b){if(!a.length)throw Error(\"Empty XPath expression.\");a=Fa(a);if(Ia(a))throw Error(\"Invalid XPath expression.\");b?\"function\"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=Db(new Cb(a,b));if(!Ia(a))throw Error(\"Bad token: \"+A(a));this.evaluate=function(d,e){d=c.a(new m(d));return new X(d,e)}}\nfunction X(a,b){if(0==b)if(a instanceof E)b=4;else if(\"string\"==typeof a)b=2;else if(\"number\"==typeof a)b=1;else if(\"boolean\"==typeof a)b=3;else throw Error(\"Unexpected evaluation result.\");if(2!=b&&1!=b&&3!=b&&!(a instanceof E))throw Error(\"value could not be converted to the specified type\");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof E?Za(a):\"\"+a;break;case 1:this.numberValue=a instanceof E?+Za(a):+a;break;case 3:this.booleanValue=a instanceof E?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c=\nH(a);var d=[];for(var e=I(c);e;e=I(c))d.push(e instanceof y?e.a:e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:a=Ya(a);this.singleNodeValue=a instanceof y?a.a:a;break;default:throw Error(\"Unknown XPathResult type.\");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error(\"iterateNext called with wrong result type\");return f>=d.length?null:d[f++]};this.snapshotItem=function(g){if(6!=b&&7!=b)throw Error(\"snapshotItem called with wrong result type\");return g>=d.length||\n0>g?null:d[g]}}X.ANY_TYPE=0;X.NUMBER_TYPE=1;X.STRING_TYPE=2;X.BOOLEAN_TYPE=3;X.UNORDERED_NODE_ITERATOR_TYPE=4;X.ORDERED_NODE_ITERATOR_TYPE=5;X.UNORDERED_NODE_SNAPSHOT_TYPE=6;X.ORDERED_NODE_SNAPSHOT_TYPE=7;X.ANY_UNORDERED_NODE_TYPE=8;X.FIRST_ORDERED_NODE_TYPE=9;function Pb(a){this.lookupNamespaceURI=Lb(a)}\nfunction Qb(a,b){a=a||k;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=X,c.evaluate=function(d,e,f,g){return(new Ob(d,f)).evaluate(e,g)},c.createExpression=function(d,e){return new Ob(d,e)},c.createNSResolver=function(d){return new Pb(d)}}ba(\"wgxpath.install\",Qb);ba(\"wgxpath.install\",Qb);var Rb=pa(),Sb=ra()||u(\"iPod\"),Tb=u(\"iPad\"),Ub=u(\"Android\")&&!(qa()||pa()||u(\"Opera\")||u(\"Silk\")),Vb=qa(),Wb=u(\"Safari\")&&!(qa()||u(\"Coast\")||u(\"Opera\")||u(\"Edge\")||u(\"Edg/\")||u(\"OPR\")||pa()||u(\"Silk\")||u(\"Android\"))&&!(ra()||u(\"iPad\")||u(\"iPod\"));function Y(a){return(a=a.exec(t))?a[1]:\"\"}(function(){if(Rb)return Y(/Firefox\\/([0-9.]+)/);if(v||ta||sa)return xa;if(Vb)return ra()||u(\"iPad\")||u(\"iPod\")?Y(/CriOS\\/([0-9.]+)/):Y(/Chrome\\/([0-9.]+)/);if(Wb&&!(ra()||u(\"iPad\")||u(\"iPod\")))return Y(/Version\\/([0-9.]+)/);if(Sb||Tb){var a=/Version\\/(\\S+).*Mobile\\/(\\S+)/.exec(t);if(a)return a[1]+\".\"+a[2]}else if(Ub)return(a=Y(/Android\\s+([0-9.]+)/))?a:Y(/Version\\/([0-9.]+)/);return\"\"})();function Z(a,b){b&&\"string\"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};var Xb=\"BUTTON INPUT OPTGROUP OPTION SELECT TEXTAREA\".split(\" \");function Yb(a){return r(Xb,function(b){return Z(a,b)})?a.disabled?!1:a.parentNode&&1==a.parentNode.nodeType&&Z(a,\"OPTGROUP\")||Z(a,\"OPTION\")?Yb(a.parentNode):!Oa(a,function(b){var c=b.parentNode;if(c&&Z(c,\"FIELDSET\")&&c.disabled){if(!Z(b,\"LEGEND\"))return!0;for(;b=void 0!==b.previousElementSibling?b.previousElementSibling:Ja(b.previousSibling);)if(Z(b,\"LEGEND\"))return!0}return!1}):!0};ba(\"_\",Yb);; return this._.apply(null,arguments);}).apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}\n", +}; + +atom.getVisibleText = async function (element, window) { + return executeInContent("getVisibleText", element, window); +} + +atom.isElementDisplayed = function (element, window) { + return executeInContent("isElementDisplayed", element, window); +} + +atom.isElementEnabled = function (element, window) { + return executeInContent("isElementEnabled", element, window); +} + +function executeInContent(name, element, window) { + const sandbox = lazy.sandbox.createMutable(window); + + return lazy.evaluate.sandbox( + sandbox, + `return (${ATOMS[name]})(arguments[0]);`, + [element] + ); +} diff --git a/remote/marionette/browser.sys.mjs b/remote/marionette/browser.sys.mjs new file mode 100644 index 0000000000..fd5aac21a3 --- /dev/null +++ b/remote/marionette/browser.sys.mjs @@ -0,0 +1,385 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", + MessageManagerDestroyedPromise: + "chrome://remote/content/marionette/sync.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +/** @namespace */ +export const browser = {}; + +/** + * Variations of Marionette contexts. + * + * Choosing a context through the <tt>Marionette:SetContext</tt> + * command directs all subsequent browsing context scoped commands + * to that context. + * + * @class Marionette.Context + */ +export class Context { + /** + * Gets the correct context from a string. + * + * @param {string} s + * Context string serialisation. + * + * @returns {Context} + * Context. + * + * @throws {TypeError} + * If <var>s</var> is not a context. + */ + static fromString(s) { + switch (s) { + case "chrome": + return Context.Chrome; + + case "content": + return Context.Content; + + default: + throw new TypeError(`Unknown context: ${s}`); + } + } +} + +Context.Chrome = "chrome"; +Context.Content = "content"; + +/** + * Creates a browsing context wrapper. + * + * Browsing contexts handle interactions with the browser, according to + * the current environment. + */ +browser.Context = class { + /** + * @param {ChromeWindow} window + * ChromeWindow that contains the top-level browsing context. + * @param {GeckoDriver} driver + * Reference to driver instance. + */ + constructor(window, driver) { + this.window = window; + this.driver = driver; + + // In Firefox this is <xul:tabbrowser> (not <xul:browser>!) + // and MobileTabBrowser in GeckoView. + this.tabBrowser = lazy.TabManager.getTabBrowser(this.window); + + // Used to set curFrameId upon new session + this.newSession = true; + + // A reference to the tab corresponding to the current window handle, + // if any. Specifically, this.tab refers to the last tab that Marionette + // switched to in this browser window. Note that this may not equal the + // currently selected tab. For example, if Marionette switches to tab + // A, and then clicks on a button that opens a new tab B in the same + // browser window, this.tab will still point to tab A, despite tab B + // being the currently selected tab. + this.tab = null; + } + + /** + * Returns the content browser for the currently selected tab. + * If there is no tab selected, null will be returned. + */ + get contentBrowser() { + if (this.tab) { + return lazy.TabManager.getBrowserForTab(this.tab); + } else if ( + this.tabBrowser && + this.driver.isReftestBrowser(this.tabBrowser) + ) { + return this.tabBrowser; + } + + return null; + } + + get messageManager() { + if (this.contentBrowser) { + return this.contentBrowser.messageManager; + } + + return null; + } + + /** + * Checks if the browsing context has been discarded. + * + * The browsing context will have been discarded if the content + * browser, represented by the <code><xul:browser></code>, + * has been detached. + * + * @returns {boolean} + * True if browsing context has been discarded, false otherwise. + */ + get closed() { + return this.contentBrowser === null; + } + + /** + * Gets the position and dimensions of the top-level browsing context. + * + * @returns {Map.<string, number>} + * Object with |x|, |y|, |width|, and |height| properties. + */ + get rect() { + return { + x: this.window.screenX, + y: this.window.screenY, + width: this.window.outerWidth, + height: this.window.outerHeight, + }; + } + + /** + * Retrieves the current tabmodal UI object. According to the browser + * associated with the currently selected tab. + */ + getTabModal() { + let br = this.contentBrowser; + if (!br.hasAttribute("tabmodalPromptShowing")) { + return null; + } + + // The modal is a direct sibling of the browser element. + // See tabbrowser.xml's getTabModalPromptBox. + let modalElements = br.parentNode.getElementsByTagName("tabmodalprompt"); + + return br.tabModalPromptBox.getPrompt(modalElements[0]); + } + + /** + * Close the current window. + * + * @returns {Promise} + * A promise which is resolved when the current window has been closed. + */ + async closeWindow() { + return lazy.windowManager.closeWindow(this.window); + } + + /** + * Focus the current window. + * + * @returns {Promise} + * A promise which is resolved when the current window has been focused. + */ + async focusWindow() { + await lazy.windowManager.focusWindow(this.window); + + // Also focus the currently selected tab if present. + this.contentBrowser?.focus(); + } + + /** + * Open a new browser window. + * + * @returns {Promise} + * A promise resolving to the newly created chrome window. + */ + openBrowserWindow(focus = false, isPrivate = false) { + return lazy.windowManager.openBrowserWindow({ + openerWindow: this.window, + focus, + isPrivate, + }); + } + + /** + * Close the current tab. + * + * @returns {Promise} + * A promise which is resolved when the current tab has been closed. + * + * @throws UnsupportedOperationError + * If tab handling for the current application isn't supported. + */ + async closeTab() { + // If the current window is not a browser then close it directly. Do the + // same if only one remaining tab is open, or no tab selected at all. + // + // Note: For GeckoView there will always be a single tab only. But for + // consistency with other platforms a specific condition has been added + // below as well even it's not really used. + if ( + !this.tabBrowser || + !this.tabBrowser.tabs || + this.tabBrowser.tabs.length === 1 || + !this.tab + ) { + return this.closeWindow(); + } + + let destroyed = new lazy.MessageManagerDestroyedPromise( + this.messageManager + ); + let tabClosed; + + if (lazy.AppInfo.isAndroid) { + await lazy.TabManager.removeTab(this.tab); + } else if (lazy.AppInfo.isFirefox) { + tabClosed = new lazy.EventPromise(this.tab, "TabClose"); + await this.tabBrowser.removeTab(this.tab); + } else { + throw new lazy.error.UnsupportedOperationError( + `closeTab() not supported for ${lazy.AppInfo.name}` + ); + } + + return Promise.all([destroyed, tabClosed]); + } + + /** + * Open a new tab in the currently selected chrome window. + */ + async openTab(focus = false) { + let tab = null; + + // Bug 1795841 - For Firefox the TabManager cannot be used yet. As such + // handle opening a tab differently for Android. + if (lazy.AppInfo.isAndroid) { + tab = await lazy.TabManager.addTab({ focus, window: this.window }); + } else if (lazy.AppInfo.isFirefox) { + const opened = new lazy.EventPromise(this.window, "TabOpen"); + this.window.BrowserOpenTab({ url: "about:blank" }); + await opened; + + tab = this.tabBrowser.selectedTab; + + // The new tab is always selected by default. If focus is not wanted, + // the previously tab needs to be selected again. + if (!focus) { + await lazy.TabManager.selectTab(this.tab); + } + } else { + throw new lazy.error.UnsupportedOperationError( + `openTab() not supported for ${lazy.AppInfo.name}` + ); + } + + return tab; + } + + /** + * Set the current tab. + * + * @param {number=} index + * Tab index to switch to. If the parameter is undefined, + * the currently selected tab will be used. + * @param {ChromeWindow=} window + * Switch to this window before selecting the tab. + * @param {boolean=} focus + * A boolean value which determins whether to focus + * the window. Defaults to true. + * + * @returns {Tab} + * The selected tab. + * + * @throws UnsupportedOperationError + * If tab handling for the current application isn't supported. + */ + async switchToTab(index, window = undefined, focus = true) { + if (window) { + this.window = window; + this.tabBrowser = lazy.TabManager.getTabBrowser(this.window); + } + + if (!this.tabBrowser || this.driver.isReftestBrowser(this.tabBrowser)) { + return null; + } + + if (typeof index == "undefined") { + this.tab = this.tabBrowser.selectedTab; + } else { + this.tab = this.tabBrowser.tabs[index]; + } + + if (focus) { + await lazy.TabManager.selectTab(this.tab); + } + + // By accessing the content browser's message manager a new browsing + // context is created for browserless tabs, which is needed to successfully + // run the WebDriver's is browsing context open step. This is temporary + // until we find a better solution on bug 1812258. + this.messageManager; + + return this.tab; + } + + /** + * Registers a new frame, and sets its current frame id to this frame + * if it is not already assigned, and if a) we already have a session + * or b) we're starting a new session and it is the right start frame. + * + * @param {XULBrowser} target + * The <xul:browser> that was the target of the originating message. + */ + register(target) { + if (!this.tabBrowser) { + return; + } + + // If we're setting up a new session on Firefox, we only process the + // registration for this frame if it belongs to the current tab. + if (!this.tab) { + this.switchToTab(); + } + } +}; + +/** + * Marionette representation of the {@link ChromeWindow} window state. + * + * @enum {string} + */ +export const WindowState = { + Maximized: "maximized", + Minimized: "minimized", + Normal: "normal", + Fullscreen: "fullscreen", + + /** + * Converts {@link Window.windowState} to WindowState. + * + * @param {number} windowState + * Attribute from {@link Window.windowState}. + * + * @returns {WindowState} + * JSON representation. + * + * @throws {TypeError} + * If <var>windowState</var> was unknown. + */ + from(windowState) { + switch (windowState) { + case 1: + return WindowState.Maximized; + + case 2: + return WindowState.Minimized; + + case 3: + return WindowState.Normal; + + case 4: + return WindowState.Fullscreen; + + default: + throw new TypeError(`Unknown window state: ${windowState}`); + } + }, +}; diff --git a/remote/marionette/cert.sys.mjs b/remote/marionette/cert.sys.mjs new file mode 100644 index 0000000000..c2cdf7b748 --- /dev/null +++ b/remote/marionette/cert.sys.mjs @@ -0,0 +1,57 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "sss", + "@mozilla.org/ssservice;1", + "nsISiteSecurityService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "certOverrideService", + "@mozilla.org/security/certoverride;1", + "nsICertOverrideService" +); + +const CERT_PINNING_ENFORCEMENT_PREF = "security.cert_pinning.enforcement_level"; +const HSTS_PRELOAD_LIST_PREF = "network.stricttransportsecurity.preloadlist"; + +/** @namespace */ +export const allowAllCerts = {}; + +/** + * Disable all security check and allow all certs. + */ +allowAllCerts.enable = function () { + // make it possible to register certificate overrides for domains + // that use HSTS or HPKP + Services.prefs.setBoolPref(HSTS_PRELOAD_LIST_PREF, false); + Services.prefs.setIntPref(CERT_PINNING_ENFORCEMENT_PREF, 0); + + lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + true + ); +}; + +/** + * Enable all security check. + */ +allowAllCerts.disable = function () { + lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + false + ); + + Services.prefs.clearUserPref(HSTS_PRELOAD_LIST_PREF); + Services.prefs.clearUserPref(CERT_PINNING_ENFORCEMENT_PREF); + + // clear collected HSTS and HPKP state + // through the site security service + lazy.sss.clearAll(); +}; diff --git a/remote/marionette/chrome/reftest.xhtml b/remote/marionette/chrome/reftest.xhtml new file mode 100644 index 0000000000..ec4145832d --- /dev/null +++ b/remote/marionette/chrome/reftest.xhtml @@ -0,0 +1,8 @@ +<window + id="reftest" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + hidechrome="true" + style="background-color: white; overflow: hidden" +> + <script src="reftest-content.js"></script> +</window> diff --git a/remote/marionette/chrome/test.xhtml b/remote/marionette/chrome/test.xhtml new file mode 100644 index 0000000000..1a94c69617 --- /dev/null +++ b/remote/marionette/chrome/test.xhtml @@ -0,0 +1,59 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE window [ ]> +<window + id="winTest" + title="Title Test" + windowtype="Test Type" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <dialog id="dia"> + <vbox id="things"> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput" + size="6" + value="test" + label="input" + /> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput2" + size="6" + value="test" + label="input" + /> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput3" + class="asdf" + size="6" + value="test" + label="input" + /> + <checkbox id="testBox" label="box" /> + </vbox> + + <iframe + id="iframe" + name="iframename" + src="chrome://remote/content/marionette/test2.xhtml" + /> + <iframe + id="iframe" + name="iframename" + src="chrome://remote/content/marionette/test_nested_iframe.xhtml" + /> + <hbox id="testXulBox" /> + <browser + id="aBrowser" + src="chrome://remote/content/marionette/test2.xhtml" + /> + </dialog> +</window> diff --git a/remote/marionette/chrome/test2.xhtml b/remote/marionette/chrome/test2.xhtml new file mode 100644 index 0000000000..17d528c800 --- /dev/null +++ b/remote/marionette/chrome/test2.xhtml @@ -0,0 +1,36 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE window [ ]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog id="dia"> + <vbox id="things"> + <checkbox id="testBox" label="box" /> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput" + size="6" + value="test" + label="input" + /> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput2" + size="6" + value="test" + label="input" + /> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput3" + class="asdf" + size="6" + value="test" + label="input" + /> + </vbox> + </dialog> +</window> diff --git a/remote/marionette/chrome/test_dialog.dtd b/remote/marionette/chrome/test_dialog.dtd new file mode 100644 index 0000000000..414cb0ee81 --- /dev/null +++ b/remote/marionette/chrome/test_dialog.dtd @@ -0,0 +1,7 @@ +<!-- 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/. --> + +<!ENTITY testDialog.title "Test Dialog"> + +<!ENTITY settings.label "Settings"> diff --git a/remote/marionette/chrome/test_dialog.properties b/remote/marionette/chrome/test_dialog.properties new file mode 100644 index 0000000000..ade7b6bde3 --- /dev/null +++ b/remote/marionette/chrome/test_dialog.properties @@ -0,0 +1,7 @@ +# 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/. + +testDialog.title=Test Dialog + +settings.label=Settings diff --git a/remote/marionette/chrome/test_dialog.xhtml b/remote/marionette/chrome/test_dialog.xhtml new file mode 100644 index 0000000000..0bb0140115 --- /dev/null +++ b/remote/marionette/chrome/test_dialog.xhtml @@ -0,0 +1,38 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE testdialog [ <!ENTITY % dialogDTD SYSTEM "chrome://remote/content/marionette/test_dialog.dtd"> +%dialogDTD; ]> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="&testDialog.title;" +> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <dialog id="testDialog" buttons="accept,cancel"> + <vbox flex="1" style="min-width: 300px; min-height: 500px"> + <label>&settings.label;</label> + <separator class="thin" /> + <richlistbox id="test-list" flex="1"> + <richlistitem id="item-choose" orient="horizontal" selected="true"> + <label id="choose-label" value="First Entry" flex="1" /> + <button id="choose-button" oncommand="" label="Choose..." /> + </richlistitem> + </richlistbox> + <separator class="thin" /> + <checkbox id="check-box" label="Test Mode 2" /> + <hbox align="center"> + <label id="text-box-label" control="text-box">Name:</label> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="text-box" + style="-moz-box-flex: 1" + /> + </hbox> + </vbox> + </dialog> +</window> diff --git a/remote/marionette/chrome/test_menupopup.xhtml b/remote/marionette/chrome/test_menupopup.xhtml new file mode 100644 index 0000000000..f9908072e8 --- /dev/null +++ b/remote/marionette/chrome/test_menupopup.xhtml @@ -0,0 +1,34 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE window [ ]> +<window + id="test-window" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <popupset id="options-popupset"> + <menupopup id="options-menupopup" position="before_end"> + <menuitem id="option-enabled" type="checkbox" label="enabled" /> + <menuitem + id="option-hidden" + type="checkbox" + label="hidden" + hidden="true" + /> + <menuitem + id="option-disabled" + type="checkbox" + label="disabled" + disabled="true" + /> + </menupopup> + </popupset> + <hbox align="center" style="height: 300px"> + <button id="options-button" popup="options-menupopup" label="button" /> + </hbox> +</window> diff --git a/remote/marionette/chrome/test_nested_iframe.xhtml b/remote/marionette/chrome/test_nested_iframe.xhtml new file mode 100644 index 0000000000..5c45fa54c9 --- /dev/null +++ b/remote/marionette/chrome/test_nested_iframe.xhtml @@ -0,0 +1,8 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE window [ ]> + +<iframe id="iframe" name="iframename" src="test2.xhtml" /> diff --git a/remote/marionette/chrome/test_no_xul.xhtml b/remote/marionette/chrome/test_no_xul.xhtml new file mode 100644 index 0000000000..48ef900226 --- /dev/null +++ b/remote/marionette/chrome/test_no_xul.xhtml @@ -0,0 +1,58 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!-- Test file for a non XUL window by using a XHTML document instead. --> + +<html + id="winTest" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns="http://www.w3.org/1999/xhtml" +> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <head> + <title>Title Test</title> + </head> + + <body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <vbox id="things"> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput" + size="6" + value="test" + label="input" + /> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput2" + size="6" + value="test" + label="input" + /> + <input + xmlns="http://www.w3.org/1999/xhtml" + id="textInput3" + class="asdf" + size="6" + value="test" + label="input" + /> + <input type="checkbox" id="testBox" label="box" /> + </vbox> + + <html:iframe + id="iframe" + name="iframename" + src="chrome://remote/content/marionette/test2.xhtml" + /> + <html:iframe + id="iframe" + name="iframename" + src="chrome://remote/content/marionette/test_nested_iframe.xhtml" + /> + </body> +</html> diff --git a/remote/marionette/cookie.sys.mjs b/remote/marionette/cookie.sys.mjs new file mode 100644 index 0000000000..117ccc33ed --- /dev/null +++ b/remote/marionette/cookie.sys.mjs @@ -0,0 +1,296 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +const IPV4_PORT_EXPR = /:\d+$/; + +const SAMESITE_MAP = new Map([ + ["None", Ci.nsICookie.SAMESITE_NONE], + ["Lax", Ci.nsICookie.SAMESITE_LAX], + ["Strict", Ci.nsICookie.SAMESITE_STRICT], +]); + +/** @namespace */ +export const cookie = { + manager: Services.cookies, +}; + +/** + * @name Cookie + * + * @returns {Object<string, (number|boolean|string)>} + */ + +/** + * Unmarshal a JSON Object to a cookie representation. + * + * Effectively this will run validation checks on ``json``, which + * will produce the errors expected by WebDriver if the input is + * not valid. + * + * @param {Object<string, (number | boolean | string)>} json + * Cookie to be deserialised. ``name`` and ``value`` are required + * fields which must be strings. The ``path`` and ``domain`` fields + * are optional, but must be a string if provided. The ``secure``, + * and ``httpOnly`` are similarly optional, but must be booleans. + * Likewise, the ``expiry`` field is optional but must be + * unsigned integer. + * + * @returns {Cookie} + * Valid cookie object. + * + * @throws {InvalidArgumentError} + * If any of the properties are invalid. + */ +cookie.fromJSON = function (json) { + let newCookie = {}; + + lazy.assert.object(json, lazy.pprint`Expected cookie object, got ${json}`); + + newCookie.name = lazy.assert.string(json.name, "Cookie name must be string"); + newCookie.value = lazy.assert.string( + json.value, + "Cookie value must be string" + ); + + if (typeof json.path != "undefined") { + newCookie.path = lazy.assert.string( + json.path, + "Cookie path must be string" + ); + } + if (typeof json.domain != "undefined") { + newCookie.domain = lazy.assert.string( + json.domain, + "Cookie domain must be string" + ); + } + if (typeof json.secure != "undefined") { + newCookie.secure = lazy.assert.boolean( + json.secure, + "Cookie secure flag must be boolean" + ); + } + if (typeof json.httpOnly != "undefined") { + newCookie.httpOnly = lazy.assert.boolean( + json.httpOnly, + "Cookie httpOnly flag must be boolean" + ); + } + if (typeof json.expiry != "undefined") { + newCookie.expiry = lazy.assert.positiveInteger( + json.expiry, + "Cookie expiry must be a positive integer" + ); + } + if (typeof json.sameSite != "undefined") { + newCookie.sameSite = lazy.assert.in( + json.sameSite, + Array.from(SAMESITE_MAP.keys()), + "Cookie SameSite flag must be one of None, Lax, or Strict" + ); + } + + return newCookie; +}; + +/** + * Insert cookie to the cookie store. + * + * @param {Cookie} newCookie + * Cookie to add. + * @param {object} options + * @param {string=} options.restrictToHost + * Perform test that ``newCookie``'s domain matches this. + * @param {string=} options.protocol + * The protocol of the caller. It can be `http:` or `https:`. + * + * @throws {TypeError} + * If ``name``, ``value``, or ``domain`` are not present and + * of the correct type. + * @throws {InvalidCookieDomainError} + * If ``restrictToHost`` is set and ``newCookie``'s domain does + * not match. + * @throws {UnableToSetCookieError} + * If an error occurred while trying to save the cookie. + */ +cookie.add = function ( + newCookie, + { restrictToHost = null, protocol = null } = {} +) { + lazy.assert.string(newCookie.name, "Cookie name must be string"); + lazy.assert.string(newCookie.value, "Cookie value must be string"); + + if (typeof newCookie.path == "undefined") { + newCookie.path = "/"; + } + + let hostOnly = false; + if (typeof newCookie.domain == "undefined") { + hostOnly = true; + newCookie.domain = restrictToHost; + } + lazy.assert.string(newCookie.domain, "Cookie domain must be string"); + if (newCookie.domain.substring(0, 1) === ".") { + newCookie.domain = newCookie.domain.substring(1); + } + + if (typeof newCookie.secure == "undefined") { + newCookie.secure = false; + } + if (typeof newCookie.httpOnly == "undefined") { + newCookie.httpOnly = false; + } + if (typeof newCookie.expiry == "undefined") { + // The XPCOM interface requires the expiry field even for session cookies. + newCookie.expiry = Number.MAX_SAFE_INTEGER; + newCookie.session = true; + } else { + newCookie.session = false; + } + newCookie.sameSite = SAMESITE_MAP.get(newCookie.sameSite || "None"); + + let isIpAddress = false; + try { + Services.eTLD.getPublicSuffixFromHost(newCookie.domain); + } catch (e) { + switch (e.result) { + case Cr.NS_ERROR_HOST_IS_IP_ADDRESS: + isIpAddress = true; + break; + default: + throw new lazy.error.InvalidCookieDomainError(newCookie.domain); + } + } + + if (!hostOnly && !isIpAddress) { + // only store this as a domain cookie if the domain was specified in the + // request and it wasn't an IP address. + newCookie.domain = "." + newCookie.domain; + } + + if (restrictToHost) { + if ( + !restrictToHost.endsWith(newCookie.domain) && + "." + restrictToHost !== newCookie.domain && + restrictToHost !== newCookie.domain + ) { + throw new lazy.error.InvalidCookieDomainError( + `Cookies may only be set ` + + `for the current domain (${restrictToHost})` + ); + } + } + + let schemeType = Ci.nsICookie.SCHEME_UNSET; + switch (protocol) { + case "http:": + schemeType = Ci.nsICookie.SCHEME_HTTP; + break; + case "https:": + schemeType = Ci.nsICookie.SCHEME_HTTPS; + break; + default: + // Any other protocol that is supported by the cookie service. + break; + } + + // remove port from domain, if present. + // unfortunately this catches IPv6 addresses by mistake + // TODO: Bug 814416 + newCookie.domain = newCookie.domain.replace(IPV4_PORT_EXPR, ""); + + try { + cookie.manager.add( + newCookie.domain, + newCookie.path, + newCookie.name, + newCookie.value, + newCookie.secure, + newCookie.httpOnly, + newCookie.session, + newCookie.expiry, + {} /* origin attributes */, + newCookie.sameSite, + schemeType + ); + } catch (e) { + throw new lazy.error.UnableToSetCookieError(e); + } +}; + +/** + * Remove cookie from the cookie store. + * + * @param {Cookie} toDelete + * Cookie to remove. + */ +cookie.remove = function (toDelete) { + cookie.manager.remove( + toDelete.domain, + toDelete.name, + toDelete.path, + {} /* originAttributes */ + ); +}; + +/** + * Iterates over the cookies for the current ``host``. You may + * optionally filter for specific paths on that ``host`` by specifying + * a path in ``currentPath``. + * + * @param {string} host + * Hostname to retrieve cookies for. + * @param {string=} [currentPath="/"] currentPath + * Optionally filter the cookies for ``host`` for the specific path. + * Defaults to ``/``, meaning all cookies for ``host`` are included. + * + * @returns {Iterable.<Cookie>} + * Iterator. + */ +cookie.iter = function* (host, currentPath = "/") { + lazy.assert.string(host, "host must be string"); + lazy.assert.string(currentPath, "currentPath must be string"); + + const isForCurrentPath = path => currentPath.includes(path); + + let cookies = cookie.manager.getCookiesFromHost(host, {}); + for (let cookie of cookies) { + // take the hostname and progressively shorten + let hostname = host; + do { + if ( + (cookie.host == "." + hostname || cookie.host == hostname) && + isForCurrentPath(cookie.path) + ) { + let data = { + name: cookie.name, + value: cookie.value, + path: cookie.path, + domain: cookie.host, + secure: cookie.isSecure, + httpOnly: cookie.isHttpOnly, + }; + + if (!cookie.isSession) { + data.expiry = cookie.expiry; + } + + data.sameSite = [...SAMESITE_MAP].find( + ([, value]) => cookie.sameSite === value + )[0]; + + yield data; + } + hostname = hostname.replace(/^.*?\./, ""); + } while (hostname.includes(".")); + } +}; diff --git a/remote/marionette/driver.sys.mjs b/remote/marionette/driver.sys.mjs new file mode 100644 index 0000000000..154d2cde83 --- /dev/null +++ b/remote/marionette/driver.sys.mjs @@ -0,0 +1,3575 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Addon: "chrome://remote/content/marionette/addon.sys.mjs", + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + browser: "chrome://remote/content/marionette/browser.sys.mjs", + capture: "chrome://remote/content/shared/Capture.sys.mjs", + Context: "chrome://remote/content/marionette/browser.sys.mjs", + cookie: "chrome://remote/content/marionette/cookie.sys.mjs", + DebounceCallback: "chrome://remote/content/marionette/sync.sys.mjs", + disableEventsActor: + "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + enableEventsActor: + "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", + getMarionetteCommandsActorProxy: + "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs", + IdlePromise: "chrome://remote/content/marionette/sync.sys.mjs", + l10n: "chrome://remote/content/marionette/l10n.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + Marionette: "chrome://remote/content/components/Marionette.sys.mjs", + MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs", + modal: "chrome://remote/content/shared/Prompt.sys.mjs", + navigate: "chrome://remote/content/marionette/navigate.sys.mjs", + permissions: "chrome://remote/content/marionette/permissions.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + print: "chrome://remote/content/shared/PDF.sys.mjs", + PromptListener: + "chrome://remote/content/shared/listeners/PromptListener.sys.mjs", + quit: "chrome://remote/content/shared/Browser.sys.mjs", + reftest: "chrome://remote/content/marionette/reftest.sys.mjs", + registerCommandsActor: + "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs", + RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", + ShadowRoot: "chrome://remote/content/marionette/web-reference.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs", + Timeouts: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", + UnhandledPromptBehavior: + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", + unregisterCommandsActor: + "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs", + waitForInitialNavigationCompleted: + "chrome://remote/content/shared/Navigate.sys.mjs", + webauthn: "chrome://remote/content/marionette/webauthn.sys.mjs", + WebDriverSession: "chrome://remote/content/shared/webdriver/Session.sys.mjs", + WebElement: "chrome://remote/content/marionette/web-reference.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", + WindowState: "chrome://remote/content/marionette/browser.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +ChromeUtils.defineLazyGetter( + lazy, + "supportedStrategies", + () => + new Set([ + lazy.dom.Strategy.ClassName, + lazy.dom.Strategy.Selector, + lazy.dom.Strategy.ID, + lazy.dom.Strategy.Name, + lazy.dom.Strategy.LinkText, + lazy.dom.Strategy.PartialLinkText, + lazy.dom.Strategy.TagName, + lazy.dom.Strategy.XPath, + ]) +); + +// Timeout used to abort fullscreen, maximize, and minimize +// commands if no window manager is present. +const TIMEOUT_NO_WINDOW_MANAGER = 5000; + +// Observer topic to wait for until the browser window is ready. +const TOPIC_BROWSER_READY = "browser-delayed-startup-finished"; +// Observer topic to perform clean up when application quit is requested. +const TOPIC_QUIT_APPLICATION_REQUESTED = "quit-application-requested"; + +/** + * The Marionette WebDriver services provides a standard conforming + * implementation of the W3C WebDriver specification. + * + * @see {@link https://w3c.github.io/webdriver/webdriver-spec.html} + * @namespace driver + */ + +/** + * Implements (parts of) the W3C WebDriver protocol. GeckoDriver lives + * in chrome space and mediates calls to the current browsing context's actor. + * + * Throughout this prototype, functions with the argument <var>cmd</var>'s + * documentation refers to the contents of the <code>cmd.parameter</code> + * object. + * + * @class GeckoDriver + * + * @param {MarionetteServer} server + * The instance of Marionette server. + */ +export function GeckoDriver(server) { + this._server = server; + + // WebDriver Session + this._currentSession = null; + + // Flag to indicate that the application is shutting down + this._isShuttingDown = false; + + this.browsers = {}; + + // points to current browser + this.curBrowser = null; + // top-most chrome window + this.mainFrame = null; + + // Use content context by default + this.context = lazy.Context.Content; + + // used for modal dialogs + this.dialog = null; + this.promptListener = null; +} + +/** + * The current context decides if commands are executed in chrome- or + * content space. + */ +Object.defineProperty(GeckoDriver.prototype, "context", { + get() { + return this._context; + }, + + set(context) { + this._context = lazy.Context.fromString(context); + }, +}); + +/** + * The current WebDriver Session. + */ +Object.defineProperty(GeckoDriver.prototype, "currentSession", { + get() { + if (lazy.RemoteAgent.webDriverBiDi) { + return lazy.RemoteAgent.webDriverBiDi.session; + } + + return this._currentSession; + }, +}); + +/** + * Returns the current URL of the ChromeWindow or content browser, + * depending on context. + * + * @returns {URL} + * Read-only property containing the currently loaded URL. + */ +Object.defineProperty(GeckoDriver.prototype, "currentURL", { + get() { + const browsingContext = this.getBrowsingContext({ top: true }); + return new URL(browsingContext.currentWindowGlobal.documentURI.spec); + }, +}); + +/** + * Returns the title of the ChromeWindow or content browser, + * depending on context. + * + * @returns {string} + * Read-only property containing the title of the loaded URL. + */ +Object.defineProperty(GeckoDriver.prototype, "title", { + get() { + const browsingContext = this.getBrowsingContext({ top: true }); + return browsingContext.currentWindowGlobal.documentTitle; + }, +}); + +Object.defineProperty(GeckoDriver.prototype, "windowType", { + get() { + return this.curBrowser.window.document.documentElement.getAttribute( + "windowtype" + ); + }, +}); + +GeckoDriver.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", +]); + +/** + * Callback used to observe the closing of modal dialogs + * during the session's lifetime. + */ +GeckoDriver.prototype.handleClosedModalDialog = function () { + this.dialog = null; +}; + +/** + * Callback used to observe the creation of new modal dialogs + * during the session's lifetime. + */ +GeckoDriver.prototype.handleOpenModalDialog = function (eventName, data) { + this.dialog = data.prompt; + + if (this.dialog.promptType === "beforeunload") { + lazy.logger.trace(`Implicitly accepted "beforeunload" prompt`); + this.dialog.accept(); + return; + } + + if (!this._isShuttingDown) { + this.getActor().notifyDialogOpened(this.dialog); + } +}; + +/** + * Get the current visible URL. + */ +GeckoDriver.prototype._getCurrentURL = function () { + const browsingContext = this.getBrowsingContext({ top: true }); + return new URL(browsingContext.currentURI.spec); +}; + +/** + * Get the current "MarionetteCommands" parent actor. + * + * @param {object} options + * @param {boolean=} options.top + * If set to true use the window's top-level browsing context for the actor, + * otherwise the one from the currently selected frame. Defaults to false. + * + * @returns {MarionetteCommandsParent} + * The parent actor. + */ +GeckoDriver.prototype.getActor = function (options = {}) { + return lazy.getMarionetteCommandsActorProxy(() => + this.getBrowsingContext(options) + ); +}; + +/** + * Get the selected BrowsingContext for the current context. + * + * @param {object} options + * @param {Context=} options.context + * Context (content or chrome) for which to retrieve the browsing context. + * Defaults to the current one. + * @param {boolean=} options.parent + * If set to true return the window's parent browsing context, + * otherwise the one from the currently selected frame. Defaults to false. + * @param {boolean=} options.top + * If set to true return the window's top-level browsing context, + * otherwise the one from the currently selected frame. Defaults to false. + * + * @returns {BrowsingContext} + * The browsing context, or `null` if none is available + */ +GeckoDriver.prototype.getBrowsingContext = function (options = {}) { + const { context = this.context, parent = false, top = false } = options; + + let browsingContext = null; + if (context === lazy.Context.Chrome) { + browsingContext = this.currentSession?.chromeBrowsingContext; + } else { + browsingContext = this.currentSession?.contentBrowsingContext; + } + + if (browsingContext && parent) { + browsingContext = browsingContext.parent; + } + + if (browsingContext && top) { + browsingContext = browsingContext.top; + } + + return browsingContext; +}; + +/** + * Get the currently selected window. + * + * It will return the outer {@link ChromeWindow} previously selected by + * window handle through {@link #switchToWindow}, or the first window that + * was registered. + * + * @param {object} options + * @param {Context=} options.context + * Optional name of the context to use for finding the window. + * It will be required if a command always needs a specific context, + * whether which context is currently set. Defaults to the current + * context. + * + * @returns {ChromeWindow} + * The current top-level browsing context. + */ +GeckoDriver.prototype.getCurrentWindow = function (options = {}) { + const { context = this.context } = options; + + let win = null; + switch (context) { + case lazy.Context.Chrome: + if (this.curBrowser) { + win = this.curBrowser.window; + } + break; + + case lazy.Context.Content: + if (this.curBrowser && this.curBrowser.contentBrowser) { + win = this.curBrowser.window; + } + break; + } + + return win; +}; + +GeckoDriver.prototype.isReftestBrowser = function (element) { + return ( + this._reftest && + element && + element.tagName === "xul:browser" && + element.parentElement && + element.parentElement.id === "reftest" + ); +}; + +/** + * Create a new browsing context for window and add to known browsers. + * + * @param {ChromeWindow} win + * Window for which we will create a browsing context. + * + * @returns {string} + * Returns the unique server-assigned ID of the window. + */ +GeckoDriver.prototype.addBrowser = function (win) { + let context = new lazy.browser.Context(win, this); + let winId = lazy.windowManager.getIdForWindow(win); + + this.browsers[winId] = context; + this.curBrowser = this.browsers[winId]; +}; + +/** + * Handles registration of new content browsers. Depending on + * their type they are either accepted or ignored. + * + * @param {XULBrowser} browserElement + */ +GeckoDriver.prototype.registerBrowser = function (browserElement) { + // We want to ignore frames that are XUL browsers that aren't in the "main" + // tabbrowser, but accept things on Fennec (which doesn't have a + // xul:tabbrowser), and accept HTML iframes (because tests depend on it), + // as well as XUL frames. Ideally this should be cleaned up and we should + // keep track of browsers a different way. + if ( + !lazy.AppInfo.isFirefox || + browserElement.namespaceURI != XUL_NS || + browserElement.nodeName != "browser" || + browserElement.getTabBrowser() + ) { + this.curBrowser.register(browserElement); + } +}; + +/** + * Create a new WebDriver session. + * + * @param {object} cmd + * @param {Object<string, *>=} cmd.parameters + * JSON Object containing any of the recognised capabilities as listed + * on the `WebDriverSession` class. + * + * @returns {object} + * Session ID and capabilities offered by the WebDriver service. + * + * @throws {SessionNotCreatedError} + * If, for whatever reason, a session could not be created. + */ +GeckoDriver.prototype.newSession = async function (cmd) { + if (this.currentSession) { + throw new lazy.error.SessionNotCreatedError( + "Maximum number of active sessions" + ); + } + + const { parameters: capabilities } = cmd; + + try { + // If the WebDriver BiDi protocol is active always use the Remote Agent + // to handle the WebDriver session. If it's not the case then Marionette + // itself needs to handle it, and has to nullify the "webSocketUrl" + // capability. + if (lazy.RemoteAgent.webDriverBiDi) { + await lazy.RemoteAgent.webDriverBiDi.createSession(capabilities); + } else { + this._currentSession = new lazy.WebDriverSession(capabilities); + this._currentSession.capabilities.delete("webSocketUrl"); + } + + // Don't wait for the initial window when Marionette is in windowless mode + if (!this.currentSession.capabilities.get("moz:windowless")) { + // Creating a WebDriver session too early can cause issues with + // clients in not being able to find any available window handle. + // Also when closing the application while it's still starting up can + // cause shutdown hangs. As such Marionette will return a new session + // once the initial application window has finished initializing. + lazy.logger.debug(`Waiting for initial application window`); + await lazy.Marionette.browserStartupFinished; + + const appWin = + await lazy.windowManager.waitForInitialApplicationWindowLoaded(); + + if (lazy.MarionettePrefs.clickToStart) { + Services.prompt.alert( + appWin, + "", + "Click to start execution of marionette tests" + ); + } + + this.addBrowser(appWin); + this.mainFrame = appWin; + + // Setup observer for modal dialogs + this.promptListener = new lazy.PromptListener(() => this.curBrowser); + this.promptListener.on("closed", this.handleClosedModalDialog.bind(this)); + this.promptListener.on("opened", this.handleOpenModalDialog.bind(this)); + this.promptListener.startListening(); + + for (let win of lazy.windowManager.windows) { + this.registerWindow(win, { registerBrowsers: true }); + } + + if (this.mainFrame) { + this.currentSession.chromeBrowsingContext = + this.mainFrame.browsingContext; + this.mainFrame.focus(); + } + + if (this.curBrowser.tab) { + const browsingContext = this.curBrowser.contentBrowser.browsingContext; + this.currentSession.contentBrowsingContext = browsingContext; + + // Bug 1838381 - Only use a longer unload timeout for desktop, because + // on Android only the initial document is loaded, and loading a + // specific page during startup doesn't succeed. + const options = {}; + if (!lazy.AppInfo.isAndroid) { + options.unloadTimeout = 5000; + } + + await lazy.waitForInitialNavigationCompleted( + browsingContext.webProgress, + options + ); + + this.curBrowser.contentBrowser.focus(); + } + + // Check if there is already an open dialog for the selected browser window. + this.dialog = lazy.modal.findPrompt(this.curBrowser); + } + + lazy.registerCommandsActor(this.currentSession.id); + lazy.enableEventsActor(); + + Services.obs.addObserver(this, TOPIC_BROWSER_READY); + } catch (e) { + throw new lazy.error.SessionNotCreatedError(e); + } + + return { + sessionId: this.currentSession.id, + capabilities: this.currentSession.capabilities, + }; +}; + +/** + * Start observing the specified window. + * + * @param {ChromeWindow} win + * Chrome window to register event listeners for. + * @param {object=} options + * @param {boolean=} options.registerBrowsers + * If true, register all content browsers of found tabs. Defaults to false. + */ +GeckoDriver.prototype.registerWindow = function (win, options = {}) { + const { registerBrowsers = false } = options; + const tabBrowser = lazy.TabManager.getTabBrowser(win); + + if (registerBrowsers && tabBrowser) { + for (const tab of tabBrowser.tabs) { + const contentBrowser = lazy.TabManager.getBrowserForTab(tab); + this.registerBrowser(contentBrowser); + } + } + + // Listen for any kind of top-level process switch + tabBrowser?.addEventListener("XULFrameLoaderCreated", this); +}; + +/** + * Stop observing the specified window. + * + * @param {ChromeWindow} win + * Chrome window to unregister event listeners for. + */ +GeckoDriver.prototype.stopObservingWindow = function (win) { + const tabBrowser = lazy.TabManager.getTabBrowser(win); + + tabBrowser?.removeEventListener("XULFrameLoaderCreated", this); +}; + +GeckoDriver.prototype.handleEvent = function ({ target, type }) { + switch (type) { + case "XULFrameLoaderCreated": + if (target === this.curBrowser.contentBrowser) { + lazy.logger.trace( + "Remoteness change detected. Set new top-level browsing context " + + `to ${target.browsingContext.id}` + ); + + this.currentSession.contentBrowsingContext = target.browsingContext; + } + break; + } +}; + +GeckoDriver.prototype.observe = async function (subject, topic, data) { + switch (topic) { + case TOPIC_BROWSER_READY: + this.registerWindow(subject); + break; + + case TOPIC_QUIT_APPLICATION_REQUESTED: + // Run Marionette specific cleanup steps before allowing + // the application to shutdown + await this._server.setAcceptConnections(false); + this.deleteSession(); + break; + } +}; + +/** + * Send the current session's capabilities to the client. + * + * Capabilities informs the client of which WebDriver features are + * supported by Firefox and Marionette. They are immutable for the + * length of the session. + * + * The return value is an immutable map of string keys + * ("capabilities") to values, which may be of types boolean, + * numerical or string. + */ +GeckoDriver.prototype.getSessionCapabilities = function () { + return { capabilities: this.currentSession.capabilities }; +}; + +/** + * Sets the context of the subsequent commands. + * + * All subsequent requests to commands that in some way involve + * interaction with a browsing context will target the chosen browsing + * context. + * + * @param {object} cmd + * @param {string} cmd.parameters.value + * Name of the context to be switched to. Must be one of "chrome" or + * "content". + * + * @throws {InvalidArgumentError} + * If <var>value</var> is not a string. + * @throws {WebDriverError} + * If <var>value</var> is not a valid browsing context. + */ +GeckoDriver.prototype.setContext = function (cmd) { + let value = lazy.assert.string(cmd.parameters.value); + + this.context = value; +}; + +/** + * Gets the context type that is Marionette's current target for + * browsing context scoped commands. + * + * You may choose a context through the {@link #setContext} command. + * + * The default browsing context is {@link Context.Content}. + * + * @returns {Context} + * Current context. + */ +GeckoDriver.prototype.getContext = function () { + return this.context; +}; + +/** + * Executes a JavaScript function in the context of the current browsing + * context, if in content space, or in chrome space otherwise, and returns + * the return value of the function. + * + * It is important to note that if the <var>sandboxName</var> parameter + * is left undefined, the script will be evaluated in a mutable sandbox, + * causing any change it makes on the global state of the document to have + * lasting side-effects. + * + * @param {object} cmd + * @param {string} cmd.parameters.script + * Script to evaluate as a function body. + * @param {Array.<(string|boolean|number|object|WebReference)>} cmd.parameters.args + * Arguments exposed to the script in <code>arguments</code>. + * The array items must be serialisable to the WebDriver protocol. + * @param {string=} cmd.parameters.sandbox + * Name of the sandbox to evaluate the script in. The sandbox is + * cached for later re-use on the same Window object if + * <var>newSandbox</var> is false. If he parameter is undefined, + * the script is evaluated in a mutable sandbox. If the parameter + * is "system", it will be evaluted in a sandbox with elevated system + * privileges, equivalent to chrome space. + * @param {boolean=} cmd.parameters.newSandbox + * Forces the script to be evaluated in a fresh sandbox. Note that if + * it is undefined, the script will normally be evaluted in a fresh + * sandbox. + * @param {string=} cmd.parameters.filename + * Filename of the client's program where this script is evaluated. + * @param {number=} cmd.parameters.line + * Line in the client's program where this script is evaluated. + * + * @returns {(string|boolean|number|object|WebReference)} + * Return value from the script, or null which signifies either the + * JavaScript notion of null or undefined. + * + * @throws {JavaScriptError} + * If an {@link Error} was thrown whilst evaluating the script. + * @throws {NoSuchElementError} + * If an element that was passed as part of <var>args</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {ScriptTimeoutError} + * If the script was interrupted due to reaching the session's + * script timeout. + * @throws {StaleElementReferenceError} + * If an element that was passed as part of <var>args</var> or that is + * returned as result has gone stale. + */ +GeckoDriver.prototype.executeScript = function (cmd) { + let { script, args } = cmd.parameters; + let opts = { + script: cmd.parameters.script, + args: cmd.parameters.args, + sandboxName: cmd.parameters.sandbox, + newSandbox: cmd.parameters.newSandbox, + file: cmd.parameters.filename, + line: cmd.parameters.line, + }; + + return this.execute_(script, args, opts); +}; + +/** + * Executes a JavaScript function in the context of the current browsing + * context, if in content space, or in chrome space otherwise, and returns + * the object passed to the callback. + * + * The callback is always the last argument to the <var>arguments</var> + * list passed to the function scope of the script. It can be retrieved + * as such: + * + * <pre><code> + * let callback = arguments[arguments.length - 1]; + * callback("foo"); + * // "foo" is returned + * </code></pre> + * + * It is important to note that if the <var>sandboxName</var> parameter + * is left undefined, the script will be evaluated in a mutable sandbox, + * causing any change it makes on the global state of the document to have + * lasting side-effects. + * + * @param {object} cmd + * @param {string} cmd.parameters.script + * Script to evaluate as a function body. + * @param {Array.<(string|boolean|number|object|WebReference)>} cmd.parameters.args + * Arguments exposed to the script in <code>arguments</code>. + * The array items must be serialisable to the WebDriver protocol. + * @param {string=} cmd.parameters.sandbox + * Name of the sandbox to evaluate the script in. The sandbox is + * cached for later re-use on the same Window object if + * <var>newSandbox</var> is false. If the parameter is undefined, + * the script is evaluated in a mutable sandbox. If the parameter + * is "system", it will be evaluted in a sandbox with elevated system + * privileges, equivalent to chrome space. + * @param {boolean=} cmd.parameters.newSandbox + * Forces the script to be evaluated in a fresh sandbox. Note that if + * it is undefined, the script will normally be evaluted in a fresh + * sandbox. + * @param {string=} cmd.parameters.filename + * Filename of the client's program where this script is evaluated. + * @param {number=} cmd.parameters.line + * Line in the client's program where this script is evaluated. + * + * @returns {(string|boolean|number|object|WebReference)} + * Return value from the script, or null which signifies either the + * JavaScript notion of null or undefined. + * + * @throws {JavaScriptError} + * If an Error was thrown whilst evaluating the script. + * @throws {NoSuchElementError} + * If an element that was passed as part of <var>args</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {ScriptTimeoutError} + * If the script was interrupted due to reaching the session's + * script timeout. + * @throws {StaleElementReferenceError} + * If an element that was passed as part of <var>args</var> or that is + * returned as result has gone stale. + */ +GeckoDriver.prototype.executeAsyncScript = function (cmd) { + let { script, args } = cmd.parameters; + let opts = { + script: cmd.parameters.script, + args: cmd.parameters.args, + sandboxName: cmd.parameters.sandbox, + newSandbox: cmd.parameters.newSandbox, + file: cmd.parameters.filename, + line: cmd.parameters.line, + async: true, + }; + + return this.execute_(script, args, opts); +}; + +GeckoDriver.prototype.execute_ = async function ( + script, + args = [], + { + sandboxName = null, + newSandbox = false, + file = "", + line = 0, + async = false, + } = {} +) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + lazy.assert.string( + script, + lazy.pprint`Expected "script" to be a string: ${script}` + ); + lazy.assert.array( + args, + lazy.pprint`Expected script args to be an array: ${args}` + ); + if (sandboxName !== null) { + lazy.assert.string( + sandboxName, + lazy.pprint`Expected sandbox name to be a string: ${sandboxName}` + ); + } + lazy.assert.boolean( + newSandbox, + lazy.pprint`Expected newSandbox to be boolean: ${newSandbox}` + ); + lazy.assert.string(file, lazy.pprint`Expected file to be a string: ${file}`); + lazy.assert.number(line, lazy.pprint`Expected line to be a number: ${line}`); + + let opts = { + timeout: this.currentSession.timeouts.script, + sandboxName, + newSandbox, + file, + line, + async, + }; + + return this.getActor().executeScript(script, args, opts); +}; + +/** + * Navigate to given URL. + * + * Navigates the current browsing context to the given URL and waits for + * the document to load or the session's page timeout duration to elapse + * before returning. + * + * The command will return with a failure if there is an error loading + * the document or the URL is blocked. This can occur if it fails to + * reach host, the URL is malformed, or if there is a certificate issue + * to name some examples. + * + * The document is considered successfully loaded when the + * DOMContentLoaded event on the frame element associated with the + * current window triggers and document.readyState is "complete". + * + * In chrome context it will change the current window's location to + * the supplied URL and wait until document.readyState equals "complete" + * or the page timeout duration has elapsed. + * + * @param {object} cmd + * @param {string} cmd.parameters.url + * URL to navigate to. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.navigateTo = async function (cmd) { + lazy.assert.content(this.context); + const browsingContext = lazy.assert.open( + this.getBrowsingContext({ top: true }) + ); + await this._handleUserPrompts(); + + let validURL; + try { + validURL = new URL(cmd.parameters.url); + } catch (e) { + throw new lazy.error.InvalidArgumentError(`Malformed URL: ${e.message}`); + } + + // Switch to the top-level browsing context before navigating + this.currentSession.contentBrowsingContext = browsingContext; + + const loadEventExpected = lazy.navigate.isLoadEventExpected( + this._getCurrentURL(), + { + future: validURL, + } + ); + + await lazy.navigate.waitForNavigationCompleted( + this, + () => { + lazy.navigate.navigateTo(browsingContext, validURL); + }, + { loadEventExpected } + ); + + this.curBrowser.contentBrowser.focus(); +}; + +/** + * Get a string representing the current URL. + * + * On Desktop this returns a string representation of the URL of the + * current top level browsing context. This is equivalent to + * document.location.href. + * + * When in the context of the chrome, this returns the canonical URL + * of the current resource. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getCurrentUrl = async function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + return this._getCurrentURL().href; +}; + +/** + * Gets the current title of the window. + * + * @returns {string} + * Document title of the top-level browsing context. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getTitle = async function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + return this.title; +}; + +/** + * Gets the current type of the window. + * + * @returns {string} + * Type of window + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.getWindowType = function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + + return this.windowType; +}; + +/** + * Gets the page source of the content document. + * + * @returns {string} + * String serialisation of the DOM of the current browsing context's + * active document. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getPageSource = async function () { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + return this.getActor().getPageSource(); +}; + +/** + * Cause the browser to traverse one step backward in the joint history + * of the current browsing context. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.goBack = async function () { + lazy.assert.content(this.context); + const browsingContext = lazy.assert.open( + this.getBrowsingContext({ top: true }) + ); + await this._handleUserPrompts(); + + // If there is no history, just return + if (!browsingContext.embedderElement?.canGoBack) { + return; + } + + await lazy.navigate.waitForNavigationCompleted(this, () => { + browsingContext.goBack(); + }); +}; + +/** + * Cause the browser to traverse one step forward in the joint history + * of the current browsing context. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.goForward = async function () { + lazy.assert.content(this.context); + const browsingContext = lazy.assert.open( + this.getBrowsingContext({ top: true }) + ); + await this._handleUserPrompts(); + + // If there is no history, just return + if (!browsingContext.embedderElement?.canGoForward) { + return; + } + + await lazy.navigate.waitForNavigationCompleted(this, () => { + browsingContext.goForward(); + }); +}; + +/** + * Causes the browser to reload the page in current top-level browsing + * context. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.refresh = async function () { + lazy.assert.content(this.context); + const browsingContext = lazy.assert.open( + this.getBrowsingContext({ top: true }) + ); + await this._handleUserPrompts(); + + // Switch to the top-level browsing context before navigating + this.currentSession.contentBrowsingContext = browsingContext; + + await lazy.navigate.waitForNavigationCompleted(this, () => { + lazy.navigate.refresh(browsingContext); + }); +}; + +/** + * Get the current window's handle. On desktop this typically corresponds + * to the currently selected tab. + * + * For chrome scope it returns the window identifier for the current chrome + * window for tests interested in managing the chrome window and tab separately. + * + * Return an opaque server-assigned identifier to this window that + * uniquely identifies it within this Marionette instance. This can + * be used to switch to this window at a later point. + * + * @returns {string} + * Unique window handle. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.getWindowHandle = function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + + if (this.context == lazy.Context.Chrome) { + return lazy.windowManager.getIdForWindow(this.curBrowser.window); + } + return lazy.TabManager.getIdForBrowser(this.curBrowser.contentBrowser); +}; + +/** + * Get a list of top-level browsing contexts. On desktop this typically + * corresponds to the set of open tabs for browser windows, or the window + * itself for non-browser chrome windows. + * + * For chrome scope it returns identifiers for each open chrome window for + * tests interested in managing a set of chrome windows and tabs separately. + * + * Each window handle is assigned by the server and is guaranteed unique, + * however the return array does not have a specified ordering. + * + * @returns {Array.<string>} + * Unique window handles. + */ +GeckoDriver.prototype.getWindowHandles = function () { + if (this.context == lazy.Context.Chrome) { + return lazy.windowManager.chromeWindowHandles.map(String); + } + return lazy.TabManager.allBrowserUniqueIds.map(String); +}; + +/** + * Get the current position and size of the browser window currently in focus. + * + * Will return the current browser window size in pixels. Refers to + * window outerWidth and outerHeight values, which include scroll bars, + * title bars, etc. + * + * @returns {Object<string, number>} + * Object with |x| and |y| coordinates, and |width| and |height| + * of browser window. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getWindowRect = async function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + return this.curBrowser.rect; +}; + +/** + * Set the window position and size of the browser on the operating + * system window manager. + * + * The supplied `width` and `height` values refer to the window `outerWidth` + * and `outerHeight` values, which include browser chrome and OS-level + * window borders. + * + * @param {object} cmd + * @param {number} cmd.parameters.x + * X coordinate of the top/left of the window that it will be + * moved to. + * @param {number} cmd.parameters.y + * Y coordinate of the top/left of the window that it will be + * moved to. + * @param {number} cmd.parameters.width + * Width to resize the window to. + * @param {number} cmd.parameters.height + * Height to resize the window to. + * + * @returns {Object<string, number>} + * Object with `x` and `y` coordinates and `width` and `height` + * dimensions. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not applicable to application. + */ +GeckoDriver.prototype.setWindowRect = async function (cmd) { + lazy.assert.desktop(); + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const { x = null, y = null, width = null, height = null } = cmd.parameters; + if (x !== null) { + lazy.assert.integer(x); + } + if (y !== null) { + lazy.assert.integer(y); + } + if (height !== null) { + lazy.assert.positiveInteger(height); + } + if (width !== null) { + lazy.assert.positiveInteger(width); + } + + const win = this.getCurrentWindow(); + switch (lazy.WindowState.from(win.windowState)) { + case lazy.WindowState.Fullscreen: + await exitFullscreen(win); + break; + + case lazy.WindowState.Maximized: + case lazy.WindowState.Minimized: + await restoreWindow(win); + break; + } + + function geometryMatches() { + if ( + width !== null && + height !== null && + (win.outerWidth !== width || win.outerHeight !== height) + ) { + return false; + } + if (x !== null && y !== null && (win.screenX !== x || win.screenY !== y)) { + return false; + } + lazy.logger.trace(`Requested window geometry matches`); + return true; + } + + if (!geometryMatches()) { + // There might be more than one resize or MozUpdateWindowPos event due + // to previous geometry changes, such as from restoreWindow(), so + // wait longer if window geometry does not match. + const options = { checkFn: geometryMatches, timeout: 500 }; + const promises = []; + if (width !== null && height !== null) { + promises.push(new lazy.EventPromise(win, "resize", options)); + win.resizeTo(width, height); + } + if (x !== null && y !== null) { + promises.push( + new lazy.EventPromise(win.windowRoot, "MozUpdateWindowPos", options) + ); + win.moveTo(x, y); + } + try { + await Promise.race(promises); + } catch (e) { + if (e instanceof lazy.error.TimeoutError) { + // The operating system might not honor the move or resize, in which + // case assume that geometry will have been adjusted "as close as + // possible" to that requested. There may be no event received if the + // geometry is already as close as possible. + } else { + throw e; + } + } + } + + return this.curBrowser.rect; +}; + +/** + * Switch current top-level browsing context by name or server-assigned + * ID. Searches for windows by name, then ID. Content windows take + * precedence. + * + * @param {object} cmd + * @param {string} cmd.parameters.handle + * Handle of the window to switch to. + * @param {boolean=} cmd.parameters.focus + * A boolean value which determines whether to focus + * the window. Defaults to true. + * + * @throws {InvalidArgumentError} + * If <var>handle</var> is not a string or <var>focus</var> not a boolean. + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.switchToWindow = async function (cmd) { + const { focus = true, handle } = cmd.parameters; + + lazy.assert.string( + handle, + lazy.pprint`Expected "handle" to be a string, got ${handle}` + ); + lazy.assert.boolean( + focus, + lazy.pprint`Expected "focus" to be a boolean, got ${focus}` + ); + + const found = lazy.windowManager.findWindowByHandle(handle); + + let selected = false; + if (found) { + try { + await this.setWindowHandle(found, focus); + selected = true; + } catch (e) { + lazy.logger.error(e); + } + } + + if (!selected) { + throw new lazy.error.NoSuchWindowError( + `Unable to locate window: ${handle}` + ); + } +}; + +/** + * Switch the marionette window to a given window. If the browser in + * the window is unregistered, register that browser and wait for + * the registration is complete. If |focus| is true then set the focus + * on the window. + * + * @param {object} winProperties + * Object containing window properties such as returned from + * :js:func:`GeckoDriver#getWindowProperties` + * @param {boolean=} focus + * A boolean value which determines whether to focus the window. + * Defaults to true. + */ +GeckoDriver.prototype.setWindowHandle = async function ( + winProperties, + focus = true +) { + if (!(winProperties.id in this.browsers)) { + // Initialise Marionette if the current chrome window has not been seen + // before. Also register the initial tab, if one exists. + this.addBrowser(winProperties.win); + this.mainFrame = winProperties.win; + + this.currentSession.chromeBrowsingContext = this.mainFrame.browsingContext; + + if (!winProperties.hasTabBrowser) { + this.currentSession.contentBrowsingContext = null; + } else { + const tabBrowser = lazy.TabManager.getTabBrowser(winProperties.win); + + // For chrome windows such as a reftest window, `getTabBrowser` is not + // a tabbrowser, it is the content browser which should be used here. + const contentBrowser = tabBrowser.tabs + ? tabBrowser.selectedBrowser + : tabBrowser; + + this.currentSession.contentBrowsingContext = + contentBrowser.browsingContext; + this.registerBrowser(contentBrowser); + } + } else { + // Otherwise switch to the known chrome window + this.curBrowser = this.browsers[winProperties.id]; + this.mainFrame = this.curBrowser.window; + + // Activate the tab if it's a content window. + let tab = null; + if (winProperties.hasTabBrowser) { + tab = await this.curBrowser.switchToTab( + winProperties.tabIndex, + winProperties.win, + focus + ); + } + + this.currentSession.chromeBrowsingContext = this.mainFrame.browsingContext; + this.currentSession.contentBrowsingContext = + tab?.linkedBrowser.browsingContext; + } + + // Check for an existing dialog for the new window + this.dialog = lazy.modal.findPrompt(this.curBrowser); + + // If there is an open window modal dialog the underlying chrome window + // cannot be focused. + if (focus && !this.dialog?.isWindowModal) { + await this.curBrowser.focusWindow(); + } +}; + +/** + * Set the current browsing context for future commands to the parent + * of the current browsing context. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.switchToParentFrame = async function () { + let browsingContext = this.getBrowsingContext(); + if (browsingContext && !browsingContext.parent) { + return; + } + + browsingContext = lazy.assert.open(browsingContext?.parent); + + this.currentSession.contentBrowsingContext = browsingContext; +}; + +/** + * Switch to a given frame within the current window. + * + * @param {object} cmd + * @param {(string | object)=} cmd.parameters.element + * A web element reference of the frame or its element id. + * @param {number=} cmd.parameters.id + * The index of the frame to switch to. + * If both element and id are not defined, switch to top-level frame. + * + * @throws {NoSuchElementError} + * If element represented by reference <var>element</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>element</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.switchToFrame = async function (cmd) { + const { element: el, id } = cmd.parameters; + + if (typeof id == "number") { + lazy.assert.unsignedShort( + id, + `Expected id to be unsigned short, got ${id}` + ); + } + + const top = id == null && el == null; + lazy.assert.open(this.getBrowsingContext({ top })); + await this._handleUserPrompts(); + + // Bug 1495063: Elements should be passed as WebReference reference + let byFrame; + if (typeof el == "string") { + byFrame = lazy.WebElement.fromUUID(el).toJSON(); + } else if (el) { + byFrame = el; + } + + const { browsingContext } = await this.getActor({ top }).switchToFrame( + byFrame || id + ); + + this.currentSession.contentBrowsingContext = browsingContext; +}; + +GeckoDriver.prototype.getTimeouts = function () { + return this.currentSession.timeouts; +}; + +/** + * Set timeout for page loading, searching, and scripts. + * + * @param {object} cmd + * @param {Object<string, number>} cmd.parameters + * Dictionary of timeout types and their new value, where all timeout + * types are optional. + * + * @throws {InvalidArgumentError} + * If timeout type key is unknown, or the value provided with it is + * not an integer. + */ +GeckoDriver.prototype.setTimeouts = function (cmd) { + // merge with existing timeouts + let merged = Object.assign( + this.currentSession.timeouts.toJSON(), + cmd.parameters + ); + + this.currentSession.timeouts = lazy.Timeouts.fromJSON(merged); +}; + +/** + * Perform a series of grouped actions at the specified points in time. + * + * @param {object} cmd + * @param {Array<?>} cmd.parameters.actions + * Array of objects that each represent an action sequence. + * + * @throws {NoSuchElementError} + * If an element that is used as part of the action chain is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If an element that is used as part of the action chain has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not yet available in current context. + */ +GeckoDriver.prototype.performActions = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const actions = cmd.parameters.actions; + await this.getActor().performActions(actions); +}; + +/** + * Release all the keys and pointer buttons that are currently depressed. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.releaseActions = async function () { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + await this.getActor().releaseActions(); +}; + +/** + * Find an element using the indicated search strategy. + * + * @param {object} cmd + * @param {string=} cmd.parameters.element + * Web element reference ID to the element that will be used as start node. + * @param {string} cmd.parameters.using + * Indicates which search method to use. + * @param {string} cmd.parameters.value + * Value the client is looking for. + * + * @returns {WebElement} + * Return the found element. + * + * @throws {NoSuchElementError} + * If element represented by reference <var>element</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>element</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.findElement = async function (cmd) { + const { element: el, using, value } = cmd.parameters; + + if (!lazy.supportedStrategies.has(using)) { + throw new lazy.error.InvalidSelectorError( + `Strategy not supported: ${using}` + ); + } + + lazy.assert.defined(value); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let startNode; + if (typeof el != "undefined") { + startNode = lazy.WebElement.fromUUID(el).toJSON(); + } + + let opts = { + startNode, + timeout: this.currentSession.timeouts.implicit, + all: false, + }; + + return this.getActor().findElement(using, value, opts); +}; + +/** + * Find an element within shadow root using the indicated search strategy. + * + * @param {object} cmd + * @param {string} cmd.parameters.shadowRoot + * Shadow root reference ID. + * @param {string} cmd.parameters.using + * Indicates which search method to use. + * @param {string} cmd.parameters.value + * Value the client is looking for. + * + * @returns {WebElement} + * Return the found element. + * + * @throws {DetachedShadowRootError} + * If shadow root represented by reference <var>id</var> is + * no longer attached to the DOM. + * @throws {NoSuchElementError} + * If the element which is looked for with <var>value</var> was + * not found. + * @throws {NoSuchShadowRoot} + * If shadow root represented by reference <var>shadowRoot</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.findElementFromShadowRoot = async function (cmd) { + const { shadowRoot, using, value } = cmd.parameters; + + if (!lazy.supportedStrategies.has(using)) { + throw new lazy.error.InvalidSelectorError( + `Strategy not supported: ${using}` + ); + } + + lazy.assert.defined(value); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const opts = { + all: false, + startNode: lazy.ShadowRoot.fromUUID(shadowRoot).toJSON(), + timeout: this.currentSession.timeouts.implicit, + }; + + return this.getActor().findElement(using, value, opts); +}; + +/** + * Find elements using the indicated search strategy. + * + * @param {object} cmd + * @param {string=} cmd.parameters.element + * Web element reference ID to the element that will be used as start node. + * @param {string} cmd.parameters.using + * Indicates which search method to use. + * @param {string} cmd.parameters.value + * Value the client is looking for. + * + * @returns {Array<WebElement>} + * Return the array of found elements. + * + * @throws {NoSuchElementError} + * If element represented by reference <var>element</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>element</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.findElements = async function (cmd) { + const { element: el, using, value } = cmd.parameters; + + if (!lazy.supportedStrategies.has(using)) { + throw new lazy.error.InvalidSelectorError( + `Strategy not supported: ${using}` + ); + } + + lazy.assert.defined(value); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let startNode; + if (typeof el != "undefined") { + startNode = lazy.WebElement.fromUUID(el).toJSON(); + } + + let opts = { + startNode, + timeout: this.currentSession.timeouts.implicit, + all: true, + }; + + return this.getActor().findElements(using, value, opts); +}; + +/** + * Find elements within shadow root using the indicated search strategy. + * + * @param {object} cmd + * @param {string} cmd.parameters.shadowRoot + * Shadow root reference ID. + * @param {string} cmd.parameters.using + * Indicates which search method to use. + * @param {string} cmd.parameters.value + * Value the client is looking for. + * + * @returns {Array<WebElement>} + * Return the array of found elements. + * + * @throws {DetachedShadowRootError} + * If shadow root represented by reference <var>id</var> is + * no longer attached to the DOM. + * @throws {NoSuchShadowRoot} + * If shadow root represented by reference <var>shadowRoot</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.findElementsFromShadowRoot = async function (cmd) { + const { shadowRoot, using, value } = cmd.parameters; + + if (!lazy.supportedStrategies.has(using)) { + throw new lazy.error.InvalidSelectorError( + `Strategy not supported: ${using}` + ); + } + + lazy.assert.defined(value); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const opts = { + all: true, + startNode: lazy.ShadowRoot.fromUUID(shadowRoot).toJSON(), + timeout: this.currentSession.timeouts.implicit, + }; + + return this.getActor().findElements(using, value, opts); +}; + +/** + * Return the shadow root of an element in the document. + * + * @param {object} cmd + * @param {id} cmd.parameters.id + * A web element id reference. + * @returns {ShadowRoot} + * ShadowRoot of the element. + * + * @throws {InvalidArgumentError} + * If element <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchShadowRoot} + * Element does not have a shadow root attached. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in chrome current context. + */ +GeckoDriver.prototype.getShadowRoot = async function (cmd) { + // Bug 1743541: Add support for chrome scope. + lazy.assert.content(this.context); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string( + cmd.parameters.id, + lazy.pprint`Expected "id" to be a string, got ${cmd.parameters.id}` + ); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getShadowRoot(webEl); +}; + +/** + * Return the active element in the document. + * + * @returns {WebReference} + * Active element of the current browsing context's document + * element, if the document element is non-null. + * + * @throws {NoSuchElementError} + * If the document does not have an active element, i.e. if + * its document element has been deleted. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in chrome context. + */ +GeckoDriver.prototype.getActiveElement = async function () { + lazy.assert.content(this.context); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + return this.getActor().getActiveElement(); +}; + +/** + * Send click event to element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be clicked. + * + * @throws {InvalidArgumentError} + * If element <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.clickElement = async function (cmd) { + const browsingContext = lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + const actor = this.getActor(); + + const loadEventExpected = lazy.navigate.isLoadEventExpected( + this._getCurrentURL(), + { + browsingContext, + target: await actor.getElementAttribute(webEl, "target"), + } + ); + + await lazy.navigate.waitForNavigationCompleted( + this, + () => actor.clickElement(webEl, this.currentSession.capabilities), + { + loadEventExpected, + // The click might trigger a navigation, so don't count on it. + requireBeforeUnload: false, + } + ); +}; + +/** + * Get a given attribute of an element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Web element reference ID to the element that will be inspected. + * @param {string} cmd.parameters.name + * Name of the attribute which value to retrieve. + * + * @returns {string} + * Value of the attribute. + * + * @throws {InvalidArgumentError} + * If <var>id</var> or <var>name</var> are not strings. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementAttribute = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const id = lazy.assert.string(cmd.parameters.id); + const name = lazy.assert.string(cmd.parameters.name); + const webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementAttribute(webEl, name); +}; + +/** + * Returns the value of a property associated with given element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Web element reference ID to the element that will be inspected. + * @param {string} cmd.parameters.name + * Name of the property which value to retrieve. + * + * @returns {string} + * Value of the property. + * + * @throws {InvalidArgumentError} + * If <var>id</var> or <var>name</var> are not strings. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementProperty = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const id = lazy.assert.string(cmd.parameters.id); + const name = lazy.assert.string(cmd.parameters.name); + const webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementProperty(webEl, name); +}; + +/** + * Get the text of an element, if any. Includes the text of all child + * elements. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be inspected. + * + * @returns {string} + * Element's text "as rendered". + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementText = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementText(webEl); +}; + +/** + * Get the tag name of the element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be inspected. + * + * @returns {string} + * Local tag name of element. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementTagName = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementTagName(webEl); +}; + +/** + * Check if element is displayed. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be inspected. + * + * @returns {boolean} + * True if displayed, false otherwise. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.isElementDisplayed = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().isElementDisplayed( + webEl, + this.currentSession.capabilities + ); +}; + +/** + * Return the property of the computed style of an element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be checked. + * @param {string} cmd.parameters.propertyName + * CSS rule that is being requested. + * + * @returns {string} + * Value of |propertyName|. + * + * @throws {InvalidArgumentError} + * If <var>id</var> or <var>propertyName</var> are not strings. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementValueOfCssProperty = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let prop = lazy.assert.string(cmd.parameters.propertyName); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementValueOfCssProperty(webEl, prop); +}; + +/** + * Check if element is enabled. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be checked. + * + * @returns {boolean} + * True if enabled, false if disabled. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.isElementEnabled = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().isElementEnabled( + webEl, + this.currentSession.capabilities + ); +}; + +/** + * Check if element is selected. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be checked. + * + * @returns {boolean} + * True if selected, false if unselected. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.isElementSelected = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().isElementSelected( + webEl, + this.currentSession.capabilities + ); +}; + +/** + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementRect = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementRect(webEl); +}; + +/** + * Send key presses to element after focusing on it. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be checked. + * @param {string} cmd.parameters.text + * Value to send to the element. + * + * @throws {InvalidArgumentError} + * If <var>id</var> or <var>text</var> are not strings. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.sendKeysToElement = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let text = lazy.assert.string(cmd.parameters.text); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().sendKeysToElement( + webEl, + text, + this.currentSession.capabilities + ); +}; + +/** + * Clear the text of an element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be cleared. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.clearElement = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + await this.getActor().clearElement(webEl); +}; + +/** + * Add a single cookie to the cookie store associated with the active + * document's address. + * + * @param {object} cmd + * @param {Map.<string, (string|number|boolean)>} cmd.parameters.cookie + * Cookie object. + * + * @throws {InvalidCookieDomainError} + * If <var>cookie</var> is for a different domain than the active + * document's host. + * @throws {NoSuchWindowError} + * Bbrowsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.addCookie = async function (cmd) { + lazy.assert.content(this.context); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let { protocol, hostname } = this._getCurrentURL(); + + const networkSchemes = ["http:", "https:"]; + if (!networkSchemes.includes(protocol)) { + throw new lazy.error.InvalidCookieDomainError("Document is cookie-averse"); + } + + let newCookie = lazy.cookie.fromJSON(cmd.parameters.cookie); + + lazy.cookie.add(newCookie, { restrictToHost: hostname, protocol }); +}; + +/** + * Get all the cookies for the current domain. + * + * This is the equivalent of calling <code>document.cookie</code> and + * parsing the result. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.getCookies = async function () { + lazy.assert.content(this.context); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let { hostname, pathname } = this._getCurrentURL(); + return [...lazy.cookie.iter(hostname, pathname)]; +}; + +/** + * Delete all cookies that are visible to a document. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.deleteAllCookies = async function () { + lazy.assert.content(this.context); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let { hostname, pathname } = this._getCurrentURL(); + for (let toDelete of lazy.cookie.iter(hostname, pathname)) { + lazy.cookie.remove(toDelete); + } +}; + +/** + * Delete a cookie by name. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.deleteCookie = async function (cmd) { + lazy.assert.content(this.context); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let { hostname, pathname } = this._getCurrentURL(); + let name = lazy.assert.string(cmd.parameters.name); + for (let c of lazy.cookie.iter(hostname, pathname)) { + if (c.name === name) { + lazy.cookie.remove(c); + } + } +}; + +/** + * Open a new top-level browsing context. + * + * @param {object} cmd + * @param {string=} cmd.parameters.type + * Optional type of the new top-level browsing context. Can be one of + * `tab` or `window`. Defaults to `tab`. + * @param {boolean=} cmd.parameters.focus + * Optional flag if the new top-level browsing context should be opened + * in foreground (focused) or background (not focused). Defaults to false. + * @param {boolean=} cmd.parameters.private + * Optional flag, which gets only evaluated for type `window`. True if the + * new top-level browsing context should be a private window. + * Defaults to false. + * + * @returns {Object<string, string>} + * Handle and type of the new browsing context. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.newWindow = async function (cmd) { + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + let focus = false; + if (typeof cmd.parameters.focus != "undefined") { + focus = lazy.assert.boolean( + cmd.parameters.focus, + lazy.pprint`Expected "focus" to be a boolean, got ${cmd.parameters.focus}` + ); + } + + let isPrivate = false; + if (typeof cmd.parameters.private != "undefined") { + isPrivate = lazy.assert.boolean( + cmd.parameters.private, + lazy.pprint`Expected "private" to be a boolean, got ${cmd.parameters.private}` + ); + } + + let type; + if (typeof cmd.parameters.type != "undefined") { + type = lazy.assert.string( + cmd.parameters.type, + lazy.pprint`Expected "type" to be a string, got ${cmd.parameters.type}` + ); + } + + // If an invalid or no type has been specified default to a tab. + // On Android always use a new tab instead because the application has a + // single window only. + if ( + typeof type == "undefined" || + !["tab", "window"].includes(type) || + lazy.AppInfo.isAndroid + ) { + type = "tab"; + } + + let contentBrowser; + + switch (type) { + case "window": + let win = await this.curBrowser.openBrowserWindow(focus, isPrivate); + contentBrowser = lazy.TabManager.getTabBrowser(win).selectedBrowser; + break; + + default: + // To not fail if a new type gets added in the future, make opening + // a new tab the default action. + let tab = await this.curBrowser.openTab(focus); + contentBrowser = lazy.TabManager.getBrowserForTab(tab); + } + + // Actors need the new window to be loaded to safely execute queries. + // Wait until the initial page load has been finished. + await lazy.waitForInitialNavigationCompleted( + contentBrowser.browsingContext.webProgress, + { + unloadTimeout: 5000, + } + ); + + const id = lazy.TabManager.getIdForBrowser(contentBrowser); + + return { handle: id.toString(), type }; +}; + +/** + * Close the currently selected tab/window. + * + * With multiple open tabs present the currently selected tab will + * be closed. Otherwise the window itself will be closed. If it is the + * last window currently open, the window will not be closed to prevent + * a shutdown of the application. Instead the returned list of window + * handles is empty. + * + * @returns {Array.<string>} + * Unique window handles of remaining windows. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.close = async function () { + lazy.assert.open( + this.getBrowsingContext({ context: lazy.Context.Content, top: true }) + ); + await this._handleUserPrompts(); + + // If there is only one window left, do not close unless windowless mode is + // enabled. Instead return a faked empty array of window handles. + // This will instruct geckodriver to terminate the application. + if ( + lazy.TabManager.getTabCount() === 1 && + !this.currentSession.capabilities.get("moz:windowless") + ) { + return []; + } + + await this.curBrowser.closeTab(); + this.currentSession.contentBrowsingContext = null; + + return lazy.TabManager.allBrowserUniqueIds.map(String); +}; + +/** + * Close the currently selected chrome window. + * + * If it is the last window currently open, the chrome window will not be + * closed to prevent a shutdown of the application. Instead the returned + * list of chrome window handles is empty. + * + * @returns {Array.<string>} + * Unique chrome window handles of remaining chrome windows. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.closeChromeWindow = async function () { + lazy.assert.desktop(); + lazy.assert.open( + this.getBrowsingContext({ context: lazy.Context.Chrome, top: true }) + ); + + let nwins = 0; + + // eslint-disable-next-line + for (let _ of lazy.windowManager.windows) { + nwins++; + } + + // If there is only one window left, do not close unless windowless mode is + // enabled. Instead return a faked empty array of window handles. + // This will instruct geckodriver to terminate the application. + if (nwins == 1 && !this.currentSession.capabilities.get("moz:windowless")) { + return []; + } + + await this.curBrowser.closeWindow(); + this.currentSession.chromeBrowsingContext = null; + this.currentSession.contentBrowsingContext = null; + + return lazy.windowManager.chromeWindowHandles.map(String); +}; + +/** Delete Marionette session. */ +GeckoDriver.prototype.deleteSession = function () { + if (!this.currentSession) { + return; + } + + for (let win of lazy.windowManager.windows) { + this.stopObservingWindow(win); + } + + // reset to the top-most frame + this.mainFrame = null; + + if (!this._isShuttingDown && this.promptListener) { + // Do not stop the prompt listener when quitting the browser to + // allow us to also accept beforeunload prompts during shutdown. + this.promptListener.stopListening(); + this.promptListener = null; + } + + try { + Services.obs.removeObserver(this, TOPIC_BROWSER_READY); + } catch (e) { + lazy.logger.debug(`Failed to remove observer "${TOPIC_BROWSER_READY}"`); + } + + // Always unregister actors after all other observers + // and listeners have been removed. + lazy.unregisterCommandsActor(); + // MarionetteEvents actors are only disabled to avoid IPC errors if there are + // in flight events being forwarded from the content process to the parent + // process. + lazy.disableEventsActor(); + + if (lazy.RemoteAgent.webDriverBiDi) { + lazy.RemoteAgent.webDriverBiDi.deleteSession(); + } else { + this.currentSession.destroy(); + this._currentSession = null; + } +}; + +/** + * Takes a screenshot of a web element, current frame, or viewport. + * + * The screen capture is returned as a lossless PNG image encoded as + * a base 64 string. + * + * If called in the content context, the |id| argument is not null and + * refers to a present and visible web element's ID, the capture area will + * be limited to the bounding box of that element. Otherwise, the capture + * area will be the bounding box of the current frame. + * + * If called in the chrome context, the screenshot will always represent + * the entire viewport. + * + * @param {object} cmd + * @param {string=} cmd.parameters.id + * Optional web element reference to take a screenshot of. + * If undefined, a screenshot will be taken of the document element. + * @param {boolean=} cmd.parameters.full + * True to take a screenshot of the entire document element. Is only + * considered if <var>id</var> is not defined. Defaults to true. + * @param {boolean=} cmd.parameters.hash + * True if the user requests a hash of the image data. Defaults to false. + * @param {boolean=} cmd.parameters.scroll + * Scroll to element if |id| is provided. Defaults to true. + * + * @returns {string} + * If <var>hash</var> is false, PNG image encoded as Base64 encoded + * string. If <var>hash</var> is true, hex digest of the SHA-256 + * hash of the Base64 encoded string. + * + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {StaleElementReferenceError} + * If element represented by reference <var>id</var> has gone stale. + */ +GeckoDriver.prototype.takeScreenshot = async function (cmd) { + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + let { id, full, hash, scroll } = cmd.parameters; + let format = hash ? lazy.capture.Format.Hash : lazy.capture.Format.Base64; + + full = typeof full == "undefined" ? true : full; + scroll = typeof scroll == "undefined" ? true : scroll; + + let webEl = id ? lazy.WebElement.fromUUID(id).toJSON() : null; + + // Only consider full screenshot if no element has been specified + full = webEl ? false : full; + + return this.getActor().takeScreenshot(webEl, format, full, scroll); +}; + +/** + * Get the current browser orientation. + * + * Will return one of the valid primary orientation values + * portrait-primary, landscape-primary, portrait-secondary, or + * landscape-secondary. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.getScreenOrientation = function () { + lazy.assert.mobile(); + lazy.assert.open(this.getBrowsingContext({ top: true })); + + const win = this.getCurrentWindow(); + + return win.screen.orientation.type; +}; + +/** + * Set the current browser orientation. + * + * The supplied orientation should be given as one of the valid + * orientation values. If the orientation is unknown, an error will + * be raised. + * + * Valid orientations are "portrait" and "landscape", which fall + * back to "portrait-primary" and "landscape-primary" respectively, + * and "portrait-secondary" as well as "landscape-secondary". + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.setScreenOrientation = async function (cmd) { + lazy.assert.mobile(); + lazy.assert.open(this.getBrowsingContext({ top: true })); + + const ors = [ + "portrait", + "landscape", + "portrait-primary", + "landscape-primary", + "portrait-secondary", + "landscape-secondary", + ]; + + let or = String(cmd.parameters.orientation); + lazy.assert.string(or); + let mozOr = or.toLowerCase(); + if (!ors.includes(mozOr)) { + throw new lazy.error.InvalidArgumentError( + `Unknown screen orientation: ${or}` + ); + } + + const win = this.getCurrentWindow(); + + try { + await win.screen.orientation.lock(mozOr); + } catch (e) { + throw new lazy.error.WebDriverError( + `Unable to set screen orientation: ${or}` + ); + } +}; + +/** + * Synchronously minimizes the user agent window as if the user pressed + * the minimize button. + * + * No action is taken if the window is already minimized. + * + * Not supported on Fennec. + * + * @returns {Object<string, number>} + * Window rect and window state. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available for current application. + */ +GeckoDriver.prototype.minimizeWindow = async function () { + lazy.assert.desktop(); + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const win = this.getCurrentWindow(); + switch (lazy.WindowState.from(win.windowState)) { + case lazy.WindowState.Fullscreen: + await exitFullscreen(win); + break; + + case lazy.WindowState.Maximized: + await restoreWindow(win); + break; + } + + if (lazy.WindowState.from(win.windowState) != lazy.WindowState.Minimized) { + let cb; + // Use a timed promise to abort if no window manager is present + await new lazy.TimedPromise( + resolve => { + cb = new lazy.DebounceCallback(resolve); + win.addEventListener("sizemodechange", cb); + win.minimize(); + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + win.removeEventListener("sizemodechange", cb); + await new lazy.IdlePromise(win); + } + + return this.curBrowser.rect; +}; + +/** + * Synchronously maximizes the user agent window as if the user pressed + * the maximize button. + * + * No action is taken if the window is already maximized. + * + * Not supported on Fennec. + * + * @returns {Object<string, number>} + * Window rect. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available for current application. + */ +GeckoDriver.prototype.maximizeWindow = async function () { + lazy.assert.desktop(); + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const win = this.getCurrentWindow(); + switch (lazy.WindowState.from(win.windowState)) { + case lazy.WindowState.Fullscreen: + await exitFullscreen(win); + break; + + case lazy.WindowState.Minimized: + await restoreWindow(win); + break; + } + + if (lazy.WindowState.from(win.windowState) != lazy.WindowState.Maximized) { + let cb; + // Use a timed promise to abort if no window manager is present + await new lazy.TimedPromise( + resolve => { + cb = new lazy.DebounceCallback(resolve); + win.addEventListener("sizemodechange", cb); + win.maximize(); + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + win.removeEventListener("sizemodechange", cb); + await new lazy.IdlePromise(win); + } + + return this.curBrowser.rect; +}; + +/** + * Synchronously sets the user agent window to full screen as if the user + * had done "View > Enter Full Screen". + * + * No action is taken if the window is already in full screen mode. + * + * Not supported on Fennec. + * + * @returns {Map.<string, number>} + * Window rect. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available for current application. + */ +GeckoDriver.prototype.fullscreenWindow = async function () { + lazy.assert.desktop(); + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const win = this.getCurrentWindow(); + switch (lazy.WindowState.from(win.windowState)) { + case lazy.WindowState.Maximized: + case lazy.WindowState.Minimized: + await restoreWindow(win); + break; + } + + if (lazy.WindowState.from(win.windowState) != lazy.WindowState.Fullscreen) { + let cb; + // Use a timed promise to abort if no window manager is present + await new lazy.TimedPromise( + resolve => { + cb = new lazy.DebounceCallback(resolve); + win.addEventListener("sizemodechange", cb); + win.fullScreen = true; + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + win.removeEventListener("sizemodechange", cb); + } + await new lazy.IdlePromise(win); + + return this.curBrowser.rect; +}; + +/** + * Dismisses a currently displayed modal dialogs, or returns no such alert if + * no modal is displayed. + * + * @throws {NoSuchAlertError} + * If there is no current user prompt. + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.dismissDialog = async function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + + const dialogClosed = this.promptListener.dialogClosed(); + this.dialog.dismiss(); + await dialogClosed; + + const win = this.getCurrentWindow(); + await new lazy.IdlePromise(win); +}; + +/** + * Accepts a currently displayed dialog modal, or returns no such alert if + * no modal is displayed. + * + * @throws {NoSuchAlertError} + * If there is no current user prompt. + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.acceptDialog = async function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + + const dialogClosed = this.promptListener.dialogClosed(); + this.dialog.accept(); + await dialogClosed; + + const win = this.getCurrentWindow(); + await new lazy.IdlePromise(win); +}; + +/** + * Returns the message shown in a currently displayed modal, or returns + * a no such alert error if no modal is currently displayed. + * + * @throws {NoSuchAlertError} + * If there is no current user prompt. + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.getTextFromDialog = async function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + const text = await this.dialog.getText(); + return text; +}; + +/** + * Set the user prompt's value field. + * + * Sends keys to the input field of a currently displayed modal, or + * returns a no such alert error if no modal is currently displayed. If + * a modal dialog is currently displayed but has no means for text input, + * an element not visible error is returned. + * + * @param {object} cmd + * @param {string} cmd.parameters.text + * Input to the user prompt's value field. + * + * @throws {ElementNotInteractableError} + * If the current user prompt is an alert or confirm. + * @throws {NoSuchAlertError} + * If there is no current user prompt. + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnsupportedOperationError} + * If the current user prompt is something other than an alert, + * confirm, or a prompt. + */ +GeckoDriver.prototype.sendKeysToDialog = async function (cmd) { + lazy.assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + + let text = lazy.assert.string(cmd.parameters.text); + let promptType = this.dialog.args.promptType; + + switch (promptType) { + case "alert": + case "confirm": + throw new lazy.error.ElementNotInteractableError( + `User prompt of type ${promptType} is not interactable` + ); + case "prompt": + break; + default: + await this.dismissDialog(); + throw new lazy.error.UnsupportedOperationError( + `User prompt of type ${promptType} is not supported` + ); + } + this.dialog.text = text; +}; + +GeckoDriver.prototype._checkIfAlertIsPresent = function () { + if (!this.dialog || !this.dialog.isOpen) { + throw new lazy.error.NoSuchAlertError(); + } +}; + +GeckoDriver.prototype._handleUserPrompts = async function () { + if (!this.dialog || !this.dialog.isOpen) { + return; + } + + if (this.dialog.promptType == "beforeunload") { + // Wait until the "beforeunload" prompt has been accepted. + await this.promptListener.dialogClosed(); + return; + } + + const textContent = await this.dialog.getText(); + + const behavior = this.currentSession.unhandledPromptBehavior; + switch (behavior) { + case lazy.UnhandledPromptBehavior.Accept: + await this.acceptDialog(); + break; + + case lazy.UnhandledPromptBehavior.AcceptAndNotify: + await this.acceptDialog(); + throw new lazy.error.UnexpectedAlertOpenError( + `Accepted user prompt dialog: ${textContent}` + ); + + case lazy.UnhandledPromptBehavior.Dismiss: + await this.dismissDialog(); + break; + + case lazy.UnhandledPromptBehavior.DismissAndNotify: + await this.dismissDialog(); + throw new lazy.error.UnexpectedAlertOpenError( + `Dismissed user prompt dialog: ${textContent}` + ); + + case lazy.UnhandledPromptBehavior.Ignore: + throw new lazy.error.UnexpectedAlertOpenError( + "Encountered unhandled user prompt dialog" + ); + + default: + throw new TypeError(`Unknown unhandledPromptBehavior "${behavior}"`); + } +}; + +/** + * Enables or disables accepting new socket connections. + * + * By calling this method with `false` the server will not accept any + * further connections, but existing connections will not be forcible + * closed. Use `true` to re-enable accepting connections. + * + * Please note that when closing the connection via the client you can + * end-up in a non-recoverable state if it hasn't been enabled before. + * + * This method is used for custom in application shutdowns via + * marionette.quit() or marionette.restart(), like File -> Quit. + * + * @param {object} cmd + * @param {boolean} cmd.parameters.value + * True if the server should accept new socket connections. + */ +GeckoDriver.prototype.acceptConnections = async function (cmd) { + lazy.assert.boolean(cmd.parameters.value); + await this._server.setAcceptConnections(cmd.parameters.value); +}; + +/** + * Quits the application with the provided flags. + * + * Marionette will stop accepting new connections before ending the + * current session, and finally attempting to quit the application. + * + * Optional {@link nsIAppStartup} flags may be provided as + * an array of masks, and these will be combined by ORing + * them with a bitmask. The available masks are defined in + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIAppStartup. + * + * Crucially, only one of the *Quit flags can be specified. The |eRestart| + * flag may be bit-wise combined with one of the *Quit flags to cause + * the application to restart after it quits. + * + * @param {object} cmd + * @param {Array.<string>=} cmd.parameters.flags + * Constant name of masks to pass to |Services.startup.quit|. + * If empty or undefined, |nsIAppStartup.eAttemptQuit| is used. + * @param {boolean=} cmd.parameters.safeMode + * Optional flag to indicate that the application has to + * be restarted in safe mode. + * + * @returns {Object<string,boolean>} + * Dictionary containing information that explains the shutdown reason. + * The value for `cause` contains the shutdown kind like "shutdown" or + * "restart", while `forced` will indicate if it was a normal or forced + * shutdown of the application. "in_app" is always set to indicate that + * it is a shutdown triggered from within the application. + * + * @throws {InvalidArgumentError} + * If <var>flags</var> contains unknown or incompatible flags, + * for example multiple Quit flags. + */ +GeckoDriver.prototype.quit = async function (cmd) { + const { flags = [], safeMode = false } = cmd.parameters; + + lazy.assert.array(flags, `Expected "flags" to be an array`); + lazy.assert.boolean(safeMode, `Expected "safeMode" to be a boolean`); + + if (safeMode && !flags.includes("eRestart")) { + throw new lazy.error.InvalidArgumentError( + `"safeMode" only works with restart flag` + ); + } + + // Register handler to run Marionette specific shutdown code. + Services.obs.addObserver(this, TOPIC_QUIT_APPLICATION_REQUESTED); + + let quitApplicationResponse; + try { + this._isShuttingDown = true; + quitApplicationResponse = await lazy.quit( + flags, + safeMode, + this.currentSession.capabilities.get("moz:windowless") + ); + } catch (e) { + this._isShuttingDown = false; + if (e instanceof TypeError) { + throw new lazy.error.InvalidArgumentError(e.message); + } + throw new lazy.error.UnsupportedOperationError(e.message); + } finally { + Services.obs.removeObserver(this, TOPIC_QUIT_APPLICATION_REQUESTED); + } + + return quitApplicationResponse; +}; + +GeckoDriver.prototype.installAddon = function (cmd) { + lazy.assert.desktop(); + + let path = cmd.parameters.path; + let temp = cmd.parameters.temporary || false; + if ( + typeof path == "undefined" || + typeof path != "string" || + typeof temp != "boolean" + ) { + throw new lazy.error.InvalidArgumentError(); + } + + return lazy.Addon.install(path, temp); +}; + +GeckoDriver.prototype.uninstallAddon = function (cmd) { + lazy.assert.desktop(); + + let id = cmd.parameters.id; + if (typeof id == "undefined" || typeof id != "string") { + throw new lazy.error.InvalidArgumentError(); + } + + return lazy.Addon.uninstall(id); +}; + +/** + * Retrieve the localized string for the specified entity id. + * + * Example: + * localizeEntity(["chrome://branding/locale/brand.dtd"], "brandShortName") + * + * @param {object} cmd + * @param {Array.<string>} cmd.parameters.urls + * Array of .dtd URLs. + * @param {string} cmd.parameters.id + * The ID of the entity to retrieve the localized string for. + * + * @returns {string} + * The localized string for the requested entity. + */ +GeckoDriver.prototype.localizeEntity = function (cmd) { + let { urls, id } = cmd.parameters; + + if (!Array.isArray(urls)) { + throw new lazy.error.InvalidArgumentError( + "Value of `urls` should be of type 'Array'" + ); + } + if (typeof id != "string") { + throw new lazy.error.InvalidArgumentError( + "Value of `id` should be of type 'string'" + ); + } + + return lazy.l10n.localizeEntity(urls, id); +}; + +/** + * Retrieve the localized string for the specified property id. + * + * Example: + * + * localizeProperty( + * ["chrome://global/locale/findbar.properties"], "FastFind"); + * + * @param {object} cmd + * @param {Array.<string>} cmd.parameters.urls + * Array of .properties URLs. + * @param {string} cmd.parameters.id + * The ID of the property to retrieve the localized string for. + * + * @returns {string} + * The localized string for the requested property. + */ +GeckoDriver.prototype.localizeProperty = function (cmd) { + let { urls, id } = cmd.parameters; + + if (!Array.isArray(urls)) { + throw new lazy.error.InvalidArgumentError( + "Value of `urls` should be of type 'Array'" + ); + } + if (typeof id != "string") { + throw new lazy.error.InvalidArgumentError( + "Value of `id` should be of type 'string'" + ); + } + + return lazy.l10n.localizeProperty(urls, id); +}; + +/** + * Initialize the reftest mode + */ +GeckoDriver.prototype.setupReftest = async function (cmd) { + if (this._reftest) { + throw new lazy.error.UnsupportedOperationError( + "Called reftest:setup with a reftest session already active" + ); + } + + let { + urlCount = {}, + screenshot = "unexpected", + isPrint = false, + } = cmd.parameters; + if (!["always", "fail", "unexpected"].includes(screenshot)) { + throw new lazy.error.InvalidArgumentError( + "Value of `screenshot` should be 'always', 'fail' or 'unexpected'" + ); + } + + this._reftest = new lazy.reftest.Runner(this); + this._reftest.setup(urlCount, screenshot, isPrint); +}; + +/** Run a reftest. */ +GeckoDriver.prototype.runReftest = function (cmd) { + let { test, references, expected, timeout, width, height, pageRanges } = + cmd.parameters; + + if (!this._reftest) { + throw new lazy.error.UnsupportedOperationError( + "Called reftest:run before reftest:start" + ); + } + + lazy.assert.string(test); + lazy.assert.string(expected); + lazy.assert.array(references); + + return this._reftest.run( + test, + references, + expected, + timeout, + pageRanges, + width, + height + ); +}; + +/** + * End a reftest run. + * + * Closes the reftest window (without changing the current window handle), + * and removes cached canvases. + */ +GeckoDriver.prototype.teardownReftest = function () { + if (!this._reftest) { + throw new lazy.error.UnsupportedOperationError( + "Called reftest:teardown before reftest:start" + ); + } + + this._reftest.teardown(); + this._reftest = null; +}; + +/** + * Print page as PDF. + * + * @param {object} cmd + * @param {boolean=} cmd.parameters.background + * Whether or not to print background colors and images. + * Defaults to false, which prints without background graphics. + * @param {number=} cmd.parameters.margin.bottom + * Bottom margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} cmd.parameters.margin.left + * Left margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} cmd.parameters.margin.right + * Right margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} cmd.parameters.margin.top + * Top margin in cm. Defaults to 1cm (~0.4 inches). + * @param {('landscape'|'portrait')=} cmd.parameters.options.orientation + * Paper orientation. Defaults to 'portrait'. + * @param {Array.<string|number>=} cmd.parameters.pageRanges + * Paper ranges to print, e.g., ['1-5', 8, '11-13']. + * Defaults to the empty array, which means print all pages. + * @param {number=} cmd.parameters.page.height + * Paper height in cm. Defaults to US letter height (27.94cm / 11 inches) + * @param {number=} cmd.parameters.page.width + * Paper width in cm. Defaults to US letter width (21.59cm / 8.5 inches) + * @param {number=} cmd.parameters.scale + * Scale of the webpage rendering. Defaults to 1.0. + * @param {boolean=} cmd.parameters.shrinkToFit + * Whether or not to override page size as defined by CSS. + * Defaults to true, in which case the content will be scaled + * to fit the paper size. + * + * @returns {string} + * Base64 encoded PDF representing printed document + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in chrome context. + */ +GeckoDriver.prototype.print = async function (cmd) { + lazy.assert.content(this.context); + lazy.assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const settings = lazy.print.addDefaultSettings(cmd.parameters); + for (const prop of ["top", "bottom", "left", "right"]) { + lazy.assert.positiveNumber( + settings.margin[prop], + lazy.pprint`margin.${prop} is not a positive number` + ); + } + for (const prop of ["width", "height"]) { + lazy.assert.positiveNumber( + settings.page[prop], + lazy.pprint`page.${prop} is not a positive number` + ); + } + lazy.assert.positiveNumber( + settings.scale, + `scale ${settings.scale} is not a positive number` + ); + lazy.assert.that( + s => + s >= lazy.print.minScaleValue && + settings.scale <= lazy.print.maxScaleValue, + `scale ${settings.scale} is outside the range ${lazy.print.minScaleValue}-${lazy.print.maxScaleValue}` + )(settings.scale); + lazy.assert.boolean(settings.shrinkToFit); + lazy.assert.that( + orientation => lazy.print.defaults.orientationValue.includes(orientation), + `orientation ${ + settings.orientation + } doesn't match allowed values "${lazy.print.defaults.orientationValue.join( + "/" + )}"` + )(settings.orientation); + lazy.assert.boolean(settings.background); + lazy.assert.array(settings.pageRanges); + + const browsingContext = this.curBrowser.tab.linkedBrowser.browsingContext; + const printSettings = await lazy.print.getPrintSettings(settings); + const binaryString = await lazy.print.printToBinaryString( + browsingContext, + printSettings + ); + + return btoa(binaryString); +}; + +GeckoDriver.prototype.addVirtualAuthenticator = function (cmd) { + const { + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified, + } = cmd.parameters; + + lazy.assert.string( + protocol, + "addVirtualAuthenticator: protocol must be a string" + ); + lazy.assert.string( + transport, + "addVirtualAuthenticator: transport must be a string" + ); + lazy.assert.boolean( + hasResidentKey, + "addVirtualAuthenticator: hasResidentKey must be a boolean" + ); + lazy.assert.boolean( + hasUserVerification, + "addVirtualAuthenticator: hasUserVerification must be a boolean" + ); + lazy.assert.boolean( + isUserConsenting, + "addVirtualAuthenticator: isUserConsenting must be a boolean" + ); + lazy.assert.boolean( + isUserVerified, + "addVirtualAuthenticator: isUserVerified must be a boolean" + ); + + return lazy.webauthn.addVirtualAuthenticator( + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified + ); +}; + +GeckoDriver.prototype.removeVirtualAuthenticator = function (cmd) { + const { authenticatorId } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "removeVirtualAuthenticator: authenticatorId must be a positiveInteger" + ); + + lazy.webauthn.removeVirtualAuthenticator(authenticatorId); +}; + +GeckoDriver.prototype.addCredential = function (cmd) { + const { + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount, + } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "addCredential: authenticatorId must be a positiveInteger" + ); + lazy.assert.string( + credentialId, + "addCredential: credentialId must be a string" + ); + lazy.assert.boolean( + isResidentCredential, + "addCredential: isResidentCredential must be a boolean" + ); + lazy.assert.string(rpId, "addCredential: rpId must be a string"); + lazy.assert.string(privateKey, "addCredential: privateKey must be a string"); + if (userHandle) { + lazy.assert.string( + userHandle, + "addCredential: userHandle must be a string if present" + ); + } + lazy.assert.number(signCount, "addCredential: signCount must be a number"); + + lazy.webauthn.addCredential( + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount + ); +}; + +GeckoDriver.prototype.getCredentials = function (cmd) { + const { authenticatorId } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "getCredentials: authenticatorId must be a positiveInteger" + ); + + return lazy.webauthn.getCredentials(authenticatorId); +}; + +GeckoDriver.prototype.removeCredential = function (cmd) { + const { authenticatorId, credentialId } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "removeCredential: authenticatorId must be a positiveInteger" + ); + lazy.assert.string( + credentialId, + "removeCredential: credentialId must be a string" + ); + + lazy.webauthn.removeCredential(authenticatorId, credentialId); +}; + +GeckoDriver.prototype.removeAllCredentials = function (cmd) { + const { authenticatorId } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "removeAllCredentials: authenticatorId must be a positiveInteger" + ); + + lazy.webauthn.removeAllCredentials(authenticatorId); +}; + +GeckoDriver.prototype.setUserVerified = function (cmd) { + const { authenticatorId, isUserVerified } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "setUserVerified: authenticatorId must be a positiveInteger" + ); + lazy.assert.boolean( + isUserVerified, + "setUserVerified: isUserVerified must be a boolean" + ); + + lazy.webauthn.setUserVerified(authenticatorId, isUserVerified); +}; + +GeckoDriver.prototype.setPermission = async function (cmd) { + const { descriptor, state, oneRealm = false } = cmd.parameters; + const browsingContext = lazy.assert.open(this.getBrowsingContext()); + + // XXX: WPT should not have these but currently they do and we pass testing pref to + // pass them, see bug 1875837. + if ( + ["clipboard-read", "clipboard-write"].includes(descriptor.name) && + state === "granted" + ) { + if ( + Services.prefs.getBoolPref("dom.events.testing.asyncClipboard", false) + ) { + // Okay, do nothing. The clipboard module will work without permission. + return; + } + throw new lazy.error.UnsupportedOperationError( + "setPermission: expected dom.events.testing.asyncClipboard to be set" + ); + } + + // XXX: We currently depend on camera/microphone tests throwing UnsupportedOperationError, + // the fix is ongoing in bug 1609427. + if (["camera", "microphone"].includes(descriptor.name)) { + throw new lazy.error.UnsupportedOperationError( + "setPermission: camera and microphone permissions are currently unsupported" + ); + } + + // XXX: Allowing this permission causes timing related Android crash, see also bug 1878741 + if (descriptor.name === "notifications") { + if (Services.prefs.getBoolPref("notification.prompt.testing", false)) { + // Okay, do nothing. The notifications module will work without permission. + return; + } + throw new lazy.error.UnsupportedOperationError( + "setPermission: expected notification.prompt.testing to be set" + ); + } + + let params; + try { + params = + await this.curBrowser.window.navigator.permissions.parseSetParameters({ + descriptor, + state, + }); + } catch (err) { + throw new lazy.error.InvalidArgumentError(`setPermission: ${err.message}`); + } + + lazy.assert.boolean(oneRealm); + + lazy.permissions.set(params.type, params.state, oneRealm, browsingContext); +}; + +/** + * Determines the Accessibility label for this element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Web element reference ID to the element for which the accessibility label + * will be returned. + * + * @returns {string} + * The Accessibility label for this element + */ +GeckoDriver.prototype.getComputedLabel = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getComputedLabel(webEl); +}; + +/** + * Determines the Accessibility role for this element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Web element reference ID to the element for which the accessibility role + * will be returned. + * + * @returns {string} + * The Accessibility role for this element + */ +GeckoDriver.prototype.getComputedRole = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + return this.getActor().getComputedRole(webEl); +}; + +GeckoDriver.prototype.commands = { + // Marionette service + "Marionette:AcceptConnections": GeckoDriver.prototype.acceptConnections, + "Marionette:GetContext": GeckoDriver.prototype.getContext, + "Marionette:GetScreenOrientation": GeckoDriver.prototype.getScreenOrientation, + "Marionette:GetWindowType": GeckoDriver.prototype.getWindowType, + "Marionette:Quit": GeckoDriver.prototype.quit, + "Marionette:SetContext": GeckoDriver.prototype.setContext, + "Marionette:SetScreenOrientation": GeckoDriver.prototype.setScreenOrientation, + + // Addon service + "Addon:Install": GeckoDriver.prototype.installAddon, + "Addon:Uninstall": GeckoDriver.prototype.uninstallAddon, + + // L10n service + "L10n:LocalizeEntity": GeckoDriver.prototype.localizeEntity, + "L10n:LocalizeProperty": GeckoDriver.prototype.localizeProperty, + + // Reftest service + "reftest:setup": GeckoDriver.prototype.setupReftest, + "reftest:run": GeckoDriver.prototype.runReftest, + "reftest:teardown": GeckoDriver.prototype.teardownReftest, + + // WebDriver service + "WebDriver:AcceptAlert": GeckoDriver.prototype.acceptDialog, + // deprecated, no longer used since the geckodriver 0.30.0 release + "WebDriver:AcceptDialog": GeckoDriver.prototype.acceptDialog, + "WebDriver:AddCookie": GeckoDriver.prototype.addCookie, + "WebDriver:Back": GeckoDriver.prototype.goBack, + "WebDriver:CloseChromeWindow": GeckoDriver.prototype.closeChromeWindow, + "WebDriver:CloseWindow": GeckoDriver.prototype.close, + "WebDriver:DeleteAllCookies": GeckoDriver.prototype.deleteAllCookies, + "WebDriver:DeleteCookie": GeckoDriver.prototype.deleteCookie, + "WebDriver:DeleteSession": GeckoDriver.prototype.deleteSession, + "WebDriver:DismissAlert": GeckoDriver.prototype.dismissDialog, + "WebDriver:ElementClear": GeckoDriver.prototype.clearElement, + "WebDriver:ElementClick": GeckoDriver.prototype.clickElement, + "WebDriver:ElementSendKeys": GeckoDriver.prototype.sendKeysToElement, + "WebDriver:ExecuteAsyncScript": GeckoDriver.prototype.executeAsyncScript, + "WebDriver:ExecuteScript": GeckoDriver.prototype.executeScript, + "WebDriver:FindElement": GeckoDriver.prototype.findElement, + "WebDriver:FindElementFromShadowRoot": + GeckoDriver.prototype.findElementFromShadowRoot, + "WebDriver:FindElements": GeckoDriver.prototype.findElements, + "WebDriver:FindElementsFromShadowRoot": + GeckoDriver.prototype.findElementsFromShadowRoot, + "WebDriver:Forward": GeckoDriver.prototype.goForward, + "WebDriver:FullscreenWindow": GeckoDriver.prototype.fullscreenWindow, + "WebDriver:GetActiveElement": GeckoDriver.prototype.getActiveElement, + "WebDriver:GetAlertText": GeckoDriver.prototype.getTextFromDialog, + "WebDriver:GetCapabilities": GeckoDriver.prototype.getSessionCapabilities, + "WebDriver:GetComputedLabel": GeckoDriver.prototype.getComputedLabel, + "WebDriver:GetComputedRole": GeckoDriver.prototype.getComputedRole, + "WebDriver:GetCookies": GeckoDriver.prototype.getCookies, + "WebDriver:GetCurrentURL": GeckoDriver.prototype.getCurrentUrl, + "WebDriver:GetElementAttribute": GeckoDriver.prototype.getElementAttribute, + "WebDriver:GetElementCSSValue": + GeckoDriver.prototype.getElementValueOfCssProperty, + "WebDriver:GetElementProperty": GeckoDriver.prototype.getElementProperty, + "WebDriver:GetElementRect": GeckoDriver.prototype.getElementRect, + "WebDriver:GetElementTagName": GeckoDriver.prototype.getElementTagName, + "WebDriver:GetElementText": GeckoDriver.prototype.getElementText, + "WebDriver:GetPageSource": GeckoDriver.prototype.getPageSource, + "WebDriver:GetShadowRoot": GeckoDriver.prototype.getShadowRoot, + "WebDriver:GetTimeouts": GeckoDriver.prototype.getTimeouts, + "WebDriver:GetTitle": GeckoDriver.prototype.getTitle, + "WebDriver:GetWindowHandle": GeckoDriver.prototype.getWindowHandle, + "WebDriver:GetWindowHandles": GeckoDriver.prototype.getWindowHandles, + "WebDriver:GetWindowRect": GeckoDriver.prototype.getWindowRect, + "WebDriver:IsElementDisplayed": GeckoDriver.prototype.isElementDisplayed, + "WebDriver:IsElementEnabled": GeckoDriver.prototype.isElementEnabled, + "WebDriver:IsElementSelected": GeckoDriver.prototype.isElementSelected, + "WebDriver:MinimizeWindow": GeckoDriver.prototype.minimizeWindow, + "WebDriver:MaximizeWindow": GeckoDriver.prototype.maximizeWindow, + "WebDriver:Navigate": GeckoDriver.prototype.navigateTo, + "WebDriver:NewSession": GeckoDriver.prototype.newSession, + "WebDriver:NewWindow": GeckoDriver.prototype.newWindow, + "WebDriver:PerformActions": GeckoDriver.prototype.performActions, + "WebDriver:Print": GeckoDriver.prototype.print, + "WebDriver:Refresh": GeckoDriver.prototype.refresh, + "WebDriver:ReleaseActions": GeckoDriver.prototype.releaseActions, + "WebDriver:SendAlertText": GeckoDriver.prototype.sendKeysToDialog, + "WebDriver:SetPermission": GeckoDriver.prototype.setPermission, + "WebDriver:SetTimeouts": GeckoDriver.prototype.setTimeouts, + "WebDriver:SetWindowRect": GeckoDriver.prototype.setWindowRect, + "WebDriver:SwitchToFrame": GeckoDriver.prototype.switchToFrame, + "WebDriver:SwitchToParentFrame": GeckoDriver.prototype.switchToParentFrame, + "WebDriver:SwitchToWindow": GeckoDriver.prototype.switchToWindow, + "WebDriver:TakeScreenshot": GeckoDriver.prototype.takeScreenshot, + + // WebAuthn + "WebAuthn:AddVirtualAuthenticator": + GeckoDriver.prototype.addVirtualAuthenticator, + "WebAuthn:RemoveVirtualAuthenticator": + GeckoDriver.prototype.removeVirtualAuthenticator, + "WebAuthn:AddCredential": GeckoDriver.prototype.addCredential, + "WebAuthn:GetCredentials": GeckoDriver.prototype.getCredentials, + "WebAuthn:RemoveCredential": GeckoDriver.prototype.removeCredential, + "WebAuthn:RemoveAllCredentials": GeckoDriver.prototype.removeAllCredentials, + "WebAuthn:SetUserVerified": GeckoDriver.prototype.setUserVerified, +}; + +async function exitFullscreen(win) { + let cb; + // Use a timed promise to abort if no window manager is present + await new lazy.TimedPromise( + resolve => { + cb = new lazy.DebounceCallback(resolve); + win.addEventListener("sizemodechange", cb); + win.fullScreen = false; + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + win.removeEventListener("sizemodechange", cb); + await new lazy.IdlePromise(win); +} + +async function restoreWindow(win) { + let cb; + if (lazy.WindowState.from(win.windowState) == lazy.WindowState.Normal) { + return; + } + // Use a timed promise to abort if no window manager is present + await new lazy.TimedPromise( + resolve => { + cb = new lazy.DebounceCallback(resolve); + win.addEventListener("sizemodechange", cb); + win.restore(); + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + win.removeEventListener("sizemodechange", cb); + await new lazy.IdlePromise(win); +} diff --git a/remote/marionette/evaluate.sys.mjs b/remote/marionette/evaluate.sys.mjs new file mode 100644 index 0000000000..bdcce779a1 --- /dev/null +++ b/remote/marionette/evaluate.sys.mjs @@ -0,0 +1,356 @@ +/* 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/. */ + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +const ARGUMENTS = "__webDriverArguments"; +const CALLBACK = "__webDriverCallback"; +const COMPLETE = "__webDriverComplete"; +const DEFAULT_TIMEOUT = 10000; // ms +const FINISH = "finish"; + +/** @namespace */ +export const evaluate = {}; + +/** + * Evaluate a script in given sandbox. + * + * The the provided `script` will be wrapped in an anonymous function + * with the `args` argument applied. + * + * The arguments provided by the `args<` argument are exposed + * through the `arguments` object available in the script context, + * and if the script is executed asynchronously with the `async` + * option, an additional last argument that is synonymous to the + * name `resolve` is appended, and can be accessed + * through `arguments[arguments.length - 1]`. + * + * The `timeout` option specifies the duration for how long the + * script should be allowed to run before it is interrupted and aborted. + * An interrupted script will cause a {@link ScriptTimeoutError} to occur. + * + * The `async` option indicates that the script will not return + * until the `resolve` callback is invoked, + * which is analogous to the last argument of the `arguments` object. + * + * The `file` option is used in error messages to provide information + * on the origin script file in the local end. + * + * The `line` option is used in error messages, along with `filename`, + * to provide the line number in the origin script file on the local end. + * + * @param {nsISandbox} sb + * Sandbox the script will be evaluted in. + * @param {string} script + * Script to evaluate. + * @param {Array.<?>=} args + * A sequence of arguments to call the script with. + * @param {object=} options + * @param {boolean=} options.async + * Indicates if the script should return immediately or wait for + * the callback to be invoked before returning. Defaults to false. + * @param {string=} options.file + * File location of the program in the client. Defaults to "dummy file". + * @param {number=} options.line + * Line number of the program in the client. Defaults to 0. + * @param {number=} options.timeout + * Duration in milliseconds before interrupting the script. Defaults to + * DEFAULT_TIMEOUT. + * + * @returns {Promise} + * A promise that when resolved will give you the return value from + * the script. Note that the return value requires serialisation before + * it can be sent to the client. + * + * @throws {JavaScriptError} + * If an {@link Error} was thrown whilst evaluating the script. + * @throws {ScriptTimeoutError} + * If the script was interrupted due to script timeout. + */ +evaluate.sandbox = function ( + sb, + script, + args = [], + { + async = false, + file = "dummy file", + line = 0, + timeout = DEFAULT_TIMEOUT, + } = {} +) { + let unloadHandler; + let marionetteSandbox = sandbox.create(sb.window); + + // timeout handler + let scriptTimeoutID, timeoutPromise; + if (timeout !== null) { + timeoutPromise = new Promise((resolve, reject) => { + scriptTimeoutID = setTimeout(() => { + reject( + new lazy.error.ScriptTimeoutError(`Timed out after ${timeout} ms`) + ); + }, timeout); + }); + } + + let promise = new Promise((resolve, reject) => { + let src = ""; + sb[COMPLETE] = resolve; + sb[ARGUMENTS] = sandbox.cloneInto(args, sb); + + // callback function made private + // so that introspection is possible + // on the arguments object + if (async) { + sb[CALLBACK] = sb[COMPLETE]; + src += `${ARGUMENTS}.push(rv => ${CALLBACK}(rv));`; + } + + src += `(function() { + ${script} + }).apply(null, ${ARGUMENTS})`; + + unloadHandler = sandbox.cloneInto( + () => reject(new lazy.error.JavaScriptError("Document was unloaded")), + marionetteSandbox + ); + marionetteSandbox.window.addEventListener("unload", unloadHandler); + + let promises = [ + Cu.evalInSandbox( + src, + sb, + "1.8", + file, + line, + /* enforceFilenameRestrictions */ false + ), + timeoutPromise, + ]; + + // Wait for the immediate result of calling evalInSandbox, or a timeout. + // Only resolve the promise if the scriptPromise was resolved and is not + // async, because the latter has to call resolve() itself. + Promise.race(promises).then( + value => { + if (!async) { + resolve(value); + } + }, + err => { + reject(err); + } + ); + }); + + // This block is mainly for async scripts, which escape the inner promise + // when calling resolve() on their own. The timeout promise will be re-used + // to break out after the initially setup timeout. + return Promise.race([promise, timeoutPromise]) + .catch(err => { + // Only raise valid errors for both the sync and async scripts. + if (err instanceof lazy.error.ScriptTimeoutError) { + throw err; + } + throw new lazy.error.JavaScriptError(err); + }) + .finally(() => { + clearTimeout(scriptTimeoutID); + marionetteSandbox.window.removeEventListener("unload", unloadHandler); + }); +}; + +/** + * `Cu.isDeadWrapper` does not return true for a dead sandbox that + * was assosciated with and extension popup. This provides a way to + * still test for a dead object. + * + * @param {object} obj + * A potentially dead object. + * @param {string} prop + * Name of a property on the object. + * + * @returns {boolean} + * True if <var>obj</var> is dead, false otherwise. + */ +evaluate.isDead = function (obj, prop) { + try { + obj[prop]; + } catch (e) { + if (e.message.includes("dead object")) { + return true; + } + throw e; + } + return false; +}; + +export const sandbox = {}; + +/** + * Provides a safe way to take an object defined in a privileged scope and + * create a structured clone of it in a less-privileged scope. It returns + * a reference to the clone. + * + * Unlike for {@link Components.utils.cloneInto}, `obj` may contain + * functions and DOM elements. + */ +sandbox.cloneInto = function (obj, sb) { + return Cu.cloneInto(obj, sb, { cloneFunctions: true, wrapReflectors: true }); +}; + +/** + * Augment given sandbox by an adapter that has an `exports` map + * property, or a normal map, of function names and function references. + * + * @param {Sandbox} sb + * The sandbox to augment. + * @param {object} adapter + * Object that holds an `exports` property, or a map, of function + * names and function references. + * + * @returns {Sandbox} + * The augmented sandbox. + */ +sandbox.augment = function (sb, adapter) { + function* entries(obj) { + for (let key of Object.keys(obj)) { + yield [key, obj[key]]; + } + } + + let funcs = adapter.exports || entries(adapter); + for (let [name, func] of funcs) { + sb[name] = func; + } + + return sb; +}; + +/** + * Creates a sandbox. + * + * @param {Window} win + * The DOM Window object. + * @param {nsIPrincipal=} principal + * An optional, custom principal to prefer over the Window. Useful if + * you need elevated security permissions. + * + * @returns {Sandbox} + * The created sandbox. + */ +sandbox.create = function (win, principal = null, opts = {}) { + let p = principal || win; + opts = Object.assign( + { + sameZoneAs: win, + sandboxPrototype: win, + wantComponents: true, + wantXrays: true, + wantGlobalProperties: ["ChromeUtils"], + }, + opts + ); + return new Cu.Sandbox(p, opts); +}; + +/** + * Creates a mutable sandbox, where changes to the global scope + * will have lasting side-effects. + * + * @param {Window} win + * The DOM Window object. + * + * @returns {Sandbox} + * The created sandbox. + */ +sandbox.createMutable = function (win) { + let opts = { + wantComponents: false, + wantXrays: false, + }; + // Note: We waive Xrays here to match potentially-accidental old behavior. + return Cu.waiveXrays(sandbox.create(win, null, opts)); +}; + +sandbox.createSystemPrincipal = function (win) { + let principal = Cc["@mozilla.org/systemprincipal;1"].createInstance( + Ci.nsIPrincipal + ); + return sandbox.create(win, principal); +}; + +sandbox.createSimpleTest = function (win, harness) { + let sb = sandbox.create(win); + sb = sandbox.augment(sb, harness); + sb[FINISH] = () => sb[COMPLETE](harness.generate_results()); + return sb; +}; + +/** + * Sandbox storage. When the user requests a sandbox by a specific name, + * if one exists in the storage this will be used as long as its window + * reference is still valid. + * + * @memberof evaluate + */ +export class Sandboxes { + /** + * @param {function(): Window} windowFn + * A function that returns the references to the current Window + * object. + */ + constructor(windowFn) { + this.windowFn_ = windowFn; + this.boxes_ = new Map(); + } + + get window_() { + return this.windowFn_(); + } + + /** + * Factory function for getting a sandbox by name, or failing that, + * creating a new one. + * + * If the sandbox' window does not match the provided window, a new one + * will be created. + * + * @param {string} name + * The name of the sandbox to get or create. + * @param {boolean=} [fresh=false] fresh + * Remove old sandbox by name first, if it exists. + * + * @returns {Sandbox} + * A used or fresh sandbox. + */ + get(name = "default", fresh = false) { + let sb = this.boxes_.get(name); + if (sb) { + if (fresh || evaluate.isDead(sb, "window") || sb.window != this.window_) { + this.boxes_.delete(name); + return this.get(name, false); + } + } else { + if (name == "system") { + sb = sandbox.createSystemPrincipal(this.window_); + } else { + sb = sandbox.create(this.window_); + } + this.boxes_.set(name, sb); + } + return sb; + } + + /** Clears cache of sandboxes. */ + clear() { + this.boxes_.clear(); + } +} diff --git a/remote/marionette/event.sys.mjs b/remote/marionette/event.sys.mjs new file mode 100644 index 0000000000..dbe6567e52 --- /dev/null +++ b/remote/marionette/event.sys.mjs @@ -0,0 +1,291 @@ +/* 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/. */ + +/* eslint-disable no-restricted-globals */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs", +}); + +/** Provides functionality for creating and sending DOM events. */ +export const event = {}; + +const _eventUtils = new WeakMap(); + +function _getEventUtils(win) { + if (!_eventUtils.has(win)) { + const eventUtilsObject = { + window: win, + parent: win, + _EU_Ci: Ci, + _EU_Cc: Cc, + }; + Services.scriptloader.loadSubScript( + "chrome://remote/content/external/EventUtils.js", + eventUtilsObject + ); + _eventUtils.set(win, eventUtilsObject); + } + return _eventUtils.get(win); +} + +event.MouseEvents = { + click: 0, + dblclick: 1, + mousedown: 2, + mouseup: 3, + mouseover: 4, + mouseout: 5, +}; + +event.Modifiers = { + shiftKey: 0, + ctrlKey: 1, + altKey: 2, + metaKey: 3, +}; + +event.MouseButton = { + isPrimary(button) { + return button === 0; + }, + isAuxiliary(button) { + return button === 1; + }, + isSecondary(button) { + return button === 2; + }, +}; + +/** + * Synthesise a mouse event at a point. + * + * If the type is specified in opts, an mouse event of that type is + * fired. Otherwise, a mousedown followed by a mouseup is performed. + * + * @param {number} left + * Offset from viewport left, in CSS pixels + * @param {number} top + * Offset from viewport top, in CSS pixels + * @param {object} opts + * Object which may contain the properties "shiftKey", "ctrlKey", + * "altKey", "metaKey", "accessKey", "clickCount", "button", and + * "type". + * @param {Window} win + * Window object. + * + * @returns {boolean} defaultPrevented + */ +event.synthesizeMouseAtPoint = function (left, top, opts, win) { + return _getEventUtils(win).synthesizeMouseAtPoint(left, top, opts, win); +}; + +/** + * Synthesise a touch event at a point. + * + * If the type is specified in opts, a touch event of that type is + * fired. Otherwise, a touchstart followed by a touchend is performed. + * + * @param {number} left + * Offset from viewport left, in CSS pixels + * @param {number} top + * Offset from viewport top, in CSS pixels + * @param {object} opts + * Object which may contain the properties "id", "rx", "ry", "angle", + * "force", "shiftKey", "ctrlKey", "altKey", "metaKey", "accessKey", + * "type". + * @param {Window} win + * Window object. + * + * @returns {boolean} defaultPrevented + */ +event.synthesizeTouchAtPoint = function (left, top, opts, win) { + return _getEventUtils(win).synthesizeTouchAtPoint(left, top, opts, win); +}; + +/** + * Synthesise a wheel scroll event at a point. + * + * @param {number} left + * Offset from viewport left, in CSS pixels + * @param {number} top + * Offset from viewport top, in CSS pixels + * @param {object} opts + * Object which may contain the properties "shiftKey", "ctrlKey", + * "altKey", "metaKey", "accessKey", "deltaX", "deltaY", "deltaZ", + * "deltaMode", "lineOrPageDeltaX", "lineOrPageDeltaY", "isMomentum", + * "isNoLineOrPageDelta", "isCustomizedByPrefs", "expectedOverflowDeltaX", + * "expectedOverflowDeltaY" + * @param {Window} win + * Window object. + */ +event.synthesizeWheelAtPoint = function (left, top, opts, win) { + const dpr = win.devicePixelRatio; + + // All delta properties expect the value in device pixels while the + // WebDriver specification uses CSS pixels. + if (typeof opts.deltaX !== "undefined") { + opts.deltaX *= dpr; + } + if (typeof opts.deltaY !== "undefined") { + opts.deltaY *= dpr; + } + if (typeof opts.deltaZ !== "undefined") { + opts.deltaZ *= dpr; + } + + return _getEventUtils(win).synthesizeWheelAtPoint(left, top, opts, win); +}; + +event.synthesizeMultiTouch = function (opts, win) { + const modifiers = _getEventUtils(win)._parseModifiers(opts); + win.windowUtils.sendTouchEvent( + opts.type, + opts.id, + opts.x, + opts.y, + opts.rx, + opts.ry, + opts.angle, + opts.force, + opts.tiltx, + opts.tilty, + opts.twist, + modifiers + ); +}; + +/** + * Synthesize a keydown event for a single key. + * + * @param {object} key + * Key data as returned by keyData.getData + * @param {Window} win + * Window object. + */ +event.sendKeyDown = function (key, win) { + event.sendSingleKey(key, win, "keydown"); +}; + +/** + * Synthesize a keyup event for a single key. + * + * @param {object} key + * Key data as returned by keyData.getData + * @param {Window} win + * Window object. + */ +event.sendKeyUp = function (key, win) { + event.sendSingleKey(key, win, "keyup"); +}; + +/** + * Synthesize a key event for a single key. + * + * @param {object} key + * Key data as returned by keyData.getData + * @param {Window} win + * Window object. + * @param {string=} type + * Event to emit. By default the full keydown/keypressed/keyup event + * sequence is emitted. + */ +event.sendSingleKey = function (key, win, type = null) { + let keyValue = key.key; + if (!key.printable) { + keyValue = `KEY_${keyValue}`; + } + const event = { + code: key.code, + location: key.location, + altKey: key.altKey ?? false, + shiftKey: key.shiftKey ?? false, + ctrlKey: key.ctrlKey ?? false, + metaKey: key.metaKey ?? false, + repeat: key.repeat ?? false, + }; + if (type) { + event.type = type; + } + _getEventUtils(win).synthesizeKey(keyValue, event, win); +}; + +/** + * Send a string as a series of keypresses. + * + * @param {string} keyString + * Sequence of characters to send as key presses + * @param {Window} win + * Window object + */ +event.sendKeys = function (keyString, win) { + const modifiers = {}; + for (let modifier in event.Modifiers) { + modifiers[modifier] = false; + } + + for (let keyValue of keyString) { + // keyValue will contain enough to represent the UTF-16 encoding of a single abstract character + // i.e. either a single scalar value, or a surrogate pair + if (modifiers.shiftKey) { + keyValue = lazy.keyData.getShiftedKey(keyValue); + } + const data = lazy.keyData.getData(keyValue); + const key = { ...data, ...modifiers }; + if (data.modifier) { + // Negating the state of the modifier here is not spec compliant but + // makes us compatible to Chrome's behavior for now. That's fine unless + // we know the correct behavior. + // + // @see: https://github.com/w3c/webdriver/issues/1734 + modifiers[data.modifier] = !modifiers[data.modifier]; + } + event.sendSingleKey(key, win); + } +}; + +event.sendEvent = function (eventType, el, modifiers = {}, opts = {}) { + opts.canBubble = opts.canBubble || true; + + let doc = el.ownerDocument || el.document; + let ev = doc.createEvent("Event"); + + ev.shiftKey = modifiers.shift; + ev.metaKey = modifiers.meta; + ev.altKey = modifiers.alt; + ev.ctrlKey = modifiers.ctrl; + + ev.initEvent(eventType, opts.canBubble, true); + el.dispatchEvent(ev); +}; + +event.mouseover = function (el, modifiers = {}, opts = {}) { + return event.sendEvent("mouseover", el, modifiers, opts); +}; + +event.mousemove = function (el, modifiers = {}, opts = {}) { + return event.sendEvent("mousemove", el, modifiers, opts); +}; + +event.mousedown = function (el, modifiers = {}, opts = {}) { + return event.sendEvent("mousedown", el, modifiers, opts); +}; + +event.mouseup = function (el, modifiers = {}, opts = {}) { + return event.sendEvent("mouseup", el, modifiers, opts); +}; + +event.click = function (el, modifiers = {}, opts = {}) { + return event.sendEvent("click", el, modifiers, opts); +}; + +event.change = function (el, modifiers = {}, opts = {}) { + return event.sendEvent("change", el, modifiers, opts); +}; + +event.input = function (el, modifiers = {}, opts = {}) { + return event.sendEvent("input", el, modifiers, opts); +}; diff --git a/remote/marionette/interaction.sys.mjs b/remote/marionette/interaction.sys.mjs new file mode 100644 index 0000000000..c71149a96a --- /dev/null +++ b/remote/marionette/interaction.sys.mjs @@ -0,0 +1,819 @@ +/* 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/. */ + +/* eslint-disable no-restricted-globals */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", + + accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs", + atom: "chrome://remote/content/marionette/atom.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + event: "chrome://remote/content/marionette/event.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// dragService may be null if it's in the headless mode (e.g., on Linux). +// It depends on the platform, though. +ChromeUtils.defineLazyGetter(lazy, "dragService", () => { + try { + return Cc["@mozilla.org/widget/dragservice;1"].getService( + Ci.nsIDragService + ); + } catch (e) { + // If we're in the headless mode, the drag service may be never + // instantiated. In this case, an exception is thrown. Let's ignore + // any exceptions since without the drag service, nobody can create a + // drag session. + return null; + } +}); + +/** XUL elements that support disabled attribute. */ +const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([ + "ARROWSCROLLBOX", + "BUTTON", + "CHECKBOX", + "COMMAND", + "DESCRIPTION", + "KEY", + "KEYSET", + "LABEL", + "MENU", + "MENUITEM", + "MENULIST", + "MENUSEPARATOR", + "RADIO", + "RADIOGROUP", + "RICHLISTBOX", + "RICHLISTITEM", + "TAB", + "TABS", + "TOOLBARBUTTON", + "TREE", +]); + +/** + * Common form controls that user can change the value property + * interactively. + */ +const COMMON_FORM_CONTROLS = new Set(["input", "textarea", "select"]); + +/** + * Input elements that do not fire <tt>input</tt> and <tt>change</tt> + * events when value property changes. + */ +const INPUT_TYPES_NO_EVENT = new Set([ + "checkbox", + "radio", + "file", + "hidden", + "image", + "reset", + "button", + "submit", +]); + +/** @namespace */ +export const interaction = {}; + +/** + * Interact with an element by clicking it. + * + * The element is scrolled into view before visibility- or interactability + * checks are performed. + * + * Selenium-style visibility checks will be performed + * if <var>specCompat</var> is false (default). Otherwise + * pointer-interactability checks will be performed. If either of these + * fail an {@link ElementNotInteractableError} is thrown. + * + * If <var>strict</var> is enabled (defaults to disabled), further + * accessibility checks will be performed, and these may result in an + * {@link ElementNotAccessibleError} being returned. + * + * When <var>el</var> is not enabled, an {@link InvalidElementStateError} + * is returned. + * + * @param {(DOMElement|XULElement)} el + * Element to click. + * @param {boolean=} [strict=false] strict + * Enforce strict accessibility tests. + * @param {boolean=} [specCompat=false] specCompat + * Use WebDriver specification compatible interactability definition. + * + * @throws {ElementNotInteractableError} + * If either Selenium-style visibility check or + * pointer-interactability check fails. + * @throws {ElementClickInterceptedError} + * If <var>el</var> is obscured by another element and a click would + * not hit, in <var>specCompat</var> mode. + * @throws {ElementNotAccessibleError} + * If <var>strict</var> is true and element is not accessible. + * @throws {InvalidElementStateError} + * If <var>el</var> is not enabled. + */ +interaction.clickElement = async function ( + el, + strict = false, + specCompat = false +) { + const a11y = lazy.accessibility.get(strict); + if (lazy.dom.isXULElement(el)) { + await chromeClick(el, a11y); + } else if (specCompat) { + await webdriverClickElement(el, a11y); + } else { + lazy.logger.trace(`Using non spec-compatible element click`); + await seleniumClickElement(el, a11y); + } +}; + +async function webdriverClickElement(el, a11y) { + const win = getWindow(el); + + // step 3 + if (el.localName == "input" && el.type == "file") { + throw new lazy.error.InvalidArgumentError( + "Cannot click <input type=file> elements" + ); + } + + let containerEl = lazy.dom.getContainer(el); + + // step 4 + if (!lazy.dom.isInView(containerEl)) { + lazy.dom.scrollIntoView(containerEl); + } + + // step 5 + // TODO(ato): wait for containerEl to be in view + + // step 6 + // if we cannot bring the container element into the viewport + // there is no point in checking if it is pointer-interactable + if (!lazy.dom.isInView(containerEl)) { + throw new lazy.error.ElementNotInteractableError( + lazy.pprint`Element ${el} could not be scrolled into view` + ); + } + + // step 7 + let rects = containerEl.getClientRects(); + let clickPoint = lazy.dom.getInViewCentrePoint(rects[0], win); + + if (lazy.dom.isObscured(containerEl)) { + throw new lazy.error.ElementClickInterceptedError( + null, + {}, + containerEl, + clickPoint + ); + } + + let acc = await a11y.assertAccessible(el, true); + a11y.assertVisible(acc, el, true); + a11y.assertEnabled(acc, el, true); + a11y.assertActionable(acc, el); + + // step 8 + if (el.localName == "option") { + interaction.selectOption(el); + } else { + // Synthesize a pointerMove action. + lazy.event.synthesizeMouseAtPoint( + clickPoint.x, + clickPoint.y, + { + type: "mousemove", + allowToHandleDragDrop: true, + }, + win + ); + + if (lazy.dragService?.getCurrentSession()) { + // Special handling is required if the mousemove started a drag session. + // In this case, mousedown event shouldn't be fired, and the mouseup should + // end the session. Therefore, we should synthesize only mouseup. + lazy.event.synthesizeMouseAtPoint( + clickPoint.x, + clickPoint.y, + { + type: "mouseup", + allowToHandleDragDrop: true, + }, + win + ); + } else { + // step 9 + let clicked = interaction.flushEventLoop(containerEl); + + // Synthesize a pointerDown + pointerUp action. + lazy.event.synthesizeMouseAtPoint( + clickPoint.x, + clickPoint.y, + { allowToHandleDragDrop: true }, + win + ); + + await clicked; + } + } + + // step 10 + // if the click causes navigation, the post-navigation checks are + // handled by navigate.js +} + +async function chromeClick(el, a11y) { + const win = getWindow(el); + + if (!(await lazy.atom.isElementEnabled(el, win))) { + throw new lazy.error.InvalidElementStateError("Element is not enabled"); + } + + let acc = await a11y.assertAccessible(el, true); + a11y.assertVisible(acc, el, true); + a11y.assertEnabled(acc, el, true); + a11y.assertActionable(acc, el); + + if (el.localName == "option") { + interaction.selectOption(el); + } else { + el.click(); + } +} + +async function seleniumClickElement(el, a11y) { + let win = getWindow(el); + + let visibilityCheckEl = el; + if (el.localName == "option") { + visibilityCheckEl = lazy.dom.getContainer(el); + } + + if (!(await lazy.dom.isVisible(visibilityCheckEl))) { + throw new lazy.error.ElementNotInteractableError(); + } + + if (!(await lazy.atom.isElementEnabled(el, win))) { + throw new lazy.error.InvalidElementStateError("Element is not enabled"); + } + + let acc = await a11y.assertAccessible(el, true); + a11y.assertVisible(acc, el, true); + a11y.assertEnabled(acc, el, true); + a11y.assertActionable(acc, el); + + if (el.localName == "option") { + interaction.selectOption(el); + } else { + let rects = el.getClientRects(); + let centre = lazy.dom.getInViewCentrePoint(rects[0], win); + let opts = {}; + lazy.event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win); + } +} + +/** + * Select <tt><option></tt> element in a <tt><select></tt> + * list. + * + * Because the dropdown list of select elements are implemented using + * native widget technology, our trusted synthesised events are not able + * to reach them. Dropdowns are instead handled mimicking DOM events, + * which for obvious reasons is not ideal, but at the current point in + * time considered to be good enough. + * + * @param {HTMLOptionElement} el + * Option element to select. + * + * @throws {TypeError} + * If <var>el</var> is a XUL element or not an <tt><option></tt> + * element. + * @throws {Error} + * If unable to find <var>el</var>'s parent <tt><select></tt> + * element. + */ +interaction.selectOption = function (el) { + if (lazy.dom.isXULElement(el)) { + throw new TypeError("XUL dropdowns not supported"); + } + if (el.localName != "option") { + throw new TypeError(lazy.pprint`Expected <option> element, got ${el}`); + } + + let containerEl = lazy.dom.getContainer(el); + + lazy.event.mouseover(containerEl); + lazy.event.mousemove(containerEl); + lazy.event.mousedown(containerEl); + containerEl.focus(); + + if (!el.disabled) { + // Clicking <option> in <select> should not be deselected if selected. + // However, clicking one in a <select multiple> should toggle + // selectedness the way holding down Control works. + if (containerEl.multiple) { + el.selected = !el.selected; + } else if (!el.selected) { + el.selected = true; + } + lazy.event.input(containerEl); + lazy.event.change(containerEl); + } + + lazy.event.mouseup(containerEl); + lazy.event.click(containerEl); + containerEl.blur(); +}; + +/** + * Clears the form control or the editable element, if required. + * + * Before clearing the element, it will attempt to scroll it into + * view if it is not already in the viewport. An error is raised + * if the element cannot be brought into view. + * + * If the element is a submittable form control and it is empty + * (it has no value or it has no files associated with it, in the + * case it is a <code><input type=file></code> element) or + * it is an editing host and its <code>innerHTML</code> content IDL + * attribute is empty, this function acts as a no-op. + * + * @param {Element} el + * Element to clear. + * + * @throws {InvalidElementStateError} + * If element is disabled, read-only, non-editable, not a submittable + * element or not an editing host, or cannot be scrolled into view. + */ +interaction.clearElement = function (el) { + if (lazy.dom.isDisabled(el)) { + throw new lazy.error.InvalidElementStateError( + lazy.pprint`Element is disabled: ${el}` + ); + } + if (lazy.dom.isReadOnly(el)) { + throw new lazy.error.InvalidElementStateError( + lazy.pprint`Element is read-only: ${el}` + ); + } + if (!lazy.dom.isEditable(el)) { + throw new lazy.error.InvalidElementStateError( + lazy.pprint`Unable to clear element that cannot be edited: ${el}` + ); + } + + if (!lazy.dom.isInView(el)) { + lazy.dom.scrollIntoView(el); + } + if (!lazy.dom.isInView(el)) { + throw new lazy.error.ElementNotInteractableError( + lazy.pprint`Element ${el} could not be scrolled into view` + ); + } + + if (lazy.dom.isEditingHost(el)) { + clearContentEditableElement(el); + } else { + clearResettableElement(el); + } +}; + +function clearContentEditableElement(el) { + if (el.innerHTML === "") { + return; + } + el.focus(); + el.innerHTML = ""; + el.blur(); +} + +function clearResettableElement(el) { + if (!lazy.dom.isMutableFormControl(el)) { + throw new lazy.error.InvalidElementStateError( + lazy.pprint`Not an editable form control: ${el}` + ); + } + + let isEmpty; + switch (el.type) { + case "file": + isEmpty = !el.files.length; + break; + + default: + isEmpty = el.value === ""; + break; + } + + if (el.validity.valid && isEmpty) { + return; + } + + el.focus(); + el.value = ""; + lazy.event.change(el); + el.blur(); +} + +/** + * Waits until the event loop has spun enough times to process the + * DOM events generated by clicking an element, or until the document + * is unloaded. + * + * @param {Element} el + * Element that is expected to receive the click. + * + * @returns {Promise} + * Promise is resolved once <var>el</var> has been clicked + * (its <code>click</code> event fires), the document is unloaded, + * or a 500 ms timeout is reached. + */ +interaction.flushEventLoop = async function (el) { + const win = el.ownerGlobal; + let unloadEv, clickEv; + + let spinEventLoop = resolve => { + unloadEv = resolve; + clickEv = event => { + lazy.logger.trace(`Received DOM event click for ${event.target}`); + if (win.closed) { + resolve(); + } else { + lazy.setTimeout(resolve, 0); + } + }; + + win.addEventListener("unload", unloadEv, { mozSystemGroup: true }); + el.addEventListener("click", clickEv, { mozSystemGroup: true }); + }; + let removeListeners = () => { + // only one event fires + win.removeEventListener("unload", unloadEv); + el.removeEventListener("click", clickEv); + }; + + return new lazy.TimedPromise(spinEventLoop, { + timeout: 500, + throws: null, + }).then(removeListeners); +}; + +/** + * If <var>el<var> is a textual form control, or is contenteditable, + * and no previous selection state exists, move the caret to the end + * of the form control. + * + * The element has to be a <code><input type=text></code> or + * <code><textarea></code> element, or have the contenteditable + * attribute set, for the cursor to be moved. + * + * @param {Element} el + * Element to potential move the caret in. + */ +interaction.moveCaretToEnd = function (el) { + if (!lazy.dom.isDOMElement(el)) { + return; + } + + let isTextarea = el.localName == "textarea"; + let isInputText = el.localName == "input" && el.type == "text"; + + if (isTextarea || isInputText) { + if (el.selectionEnd == 0) { + let len = el.value.length; + el.setSelectionRange(len, len); + } + } else if (el.isContentEditable) { + let selection = getWindow(el).getSelection(); + selection.setPosition(el, el.childNodes.length); + } +}; + +/** + * Performs checks if <var>el</var> is keyboard-interactable. + * + * To decide if an element is keyboard-interactable various properties, + * and computed CSS styles have to be evaluated. Whereby it has to be taken + * into account that the element can be part of a container (eg. option), + * and as such the container has to be checked instead. + * + * @param {Element} el + * Element to check. + * + * @returns {boolean} + * True if element is keyboard-interactable, false otherwise. + */ +interaction.isKeyboardInteractable = function (el) { + const win = getWindow(el); + + // body and document element are always keyboard-interactable + if (el.localName === "body" || el === win.document.documentElement) { + return true; + } + + // context menu popups do not take the focus from the document. + const menuPopup = el.closest("menupopup"); + if (menuPopup) { + if (menuPopup.state !== "open") { + // closed menupopups are not keyboard interactable. + return false; + } + + const menuItem = el.closest("menuitem"); + if (menuItem) { + // hidden or disabled menu items are not keyboard interactable. + return !menuItem.disabled && !menuItem.hidden; + } + + return true; + } + + return Services.focus.elementIsFocusable(el, 0); +}; + +/** + * Updates an `<input type=file>`'s file list with given `paths`. + * + * Hereby will the file list be appended with `paths` if the + * element allows multiple files. Otherwise the list will be + * replaced. + * + * @param {HTMLInputElement} el + * An `input type=file` element. + * @param {Array.<string>} paths + * List of full paths to any of the files to be uploaded. + * + * @throws {InvalidArgumentError} + * If `path` doesn't exist. + */ +interaction.uploadFiles = async function (el, paths) { + let files = []; + + if (el.hasAttribute("multiple")) { + // for multiple file uploads new files will be appended + files = Array.prototype.slice.call(el.files); + } else if (paths.length > 1) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Element ${el} doesn't accept multiple files` + ); + } + + for (let path of paths) { + let file; + + try { + file = await File.createFromFileName(path); + } catch (e) { + throw new lazy.error.InvalidArgumentError("File not found: " + path); + } + + files.push(file); + } + + el.mozSetFileArray(files); +}; + +/** + * Sets a form element's value. + * + * @param {DOMElement} el + * An form element, e.g. input, textarea, etc. + * @param {string} value + * The value to be set. + * + * @throws {TypeError} + * If <var>el</var> is not an supported form element. + */ +interaction.setFormControlValue = function (el, value) { + if (!COMMON_FORM_CONTROLS.has(el.localName)) { + throw new TypeError("This function is for form elements only"); + } + + el.value = value; + + if (INPUT_TYPES_NO_EVENT.has(el.type)) { + return; + } + + lazy.event.input(el); + lazy.event.change(el); +}; + +/** + * Send keys to element. + * + * @param {DOMElement|XULElement} el + * Element to send key events to. + * @param {Array.<string>} value + * Sequence of keystrokes to send to the element. + * @param {object=} options + * @param {boolean=} options.strictFileInteractability + * Run interactability checks on `<input type=file>` elements. + * @param {boolean=} options.accessibilityChecks + * Enforce strict accessibility tests. + * @param {boolean=} options.webdriverClick + * Use WebDriver specification compatible interactability definition. + */ +interaction.sendKeysToElement = async function ( + el, + value, + { + strictFileInteractability = false, + accessibilityChecks = false, + webdriverClick = false, + } = {} +) { + const a11y = lazy.accessibility.get(accessibilityChecks); + + if (webdriverClick) { + await webdriverSendKeysToElement( + el, + value, + a11y, + strictFileInteractability + ); + } else { + await legacySendKeysToElement(el, value, a11y); + } +}; + +async function webdriverSendKeysToElement( + el, + value, + a11y, + strictFileInteractability +) { + const win = getWindow(el); + + if (el.type !== "file" || strictFileInteractability) { + let containerEl = lazy.dom.getContainer(el); + + lazy.dom.scrollIntoView(containerEl); + + // TODO: Wait for element to be keyboard-interactible + if (!interaction.isKeyboardInteractable(containerEl)) { + throw new lazy.error.ElementNotInteractableError( + lazy.pprint`Element ${el} is not reachable by keyboard` + ); + } + + if (win.document.activeElement !== containerEl) { + containerEl.focus(); + // This validates the correct element types internally + interaction.moveCaretToEnd(containerEl); + } + } + + let acc = await a11y.assertAccessible(el, true); + a11y.assertActionable(acc, el); + + if (el.type == "file") { + let paths = value.split("\n"); + await interaction.uploadFiles(el, paths); + + lazy.event.input(el); + lazy.event.change(el); + } else if (el.type == "date" || el.type == "time") { + interaction.setFormControlValue(el, value); + } else { + lazy.event.sendKeys(value, win); + } +} + +async function legacySendKeysToElement(el, value, a11y) { + const win = getWindow(el); + + if (el.type == "file") { + el.focus(); + await interaction.uploadFiles(el, [value]); + + lazy.event.input(el); + lazy.event.change(el); + } else if (el.type == "date" || el.type == "time") { + interaction.setFormControlValue(el, value); + } else { + let visibilityCheckEl = el; + if (el.localName == "option") { + visibilityCheckEl = lazy.dom.getContainer(el); + } + + if (!(await lazy.dom.isVisible(visibilityCheckEl))) { + throw new lazy.error.ElementNotInteractableError( + "Element is not visible" + ); + } + + let acc = await a11y.assertAccessible(el, true); + a11y.assertActionable(acc, el); + + interaction.moveCaretToEnd(el); + el.focus(); + lazy.event.sendKeys(value, win); + } +} + +/** + * Determine the element displayedness of an element. + * + * @param {DOMElement|XULElement} el + * Element to determine displayedness of. + * @param {boolean=} [strict=false] strict + * Enforce strict accessibility tests. + * + * @returns {boolean} + * True if element is displayed, false otherwise. + */ +interaction.isElementDisplayed = async function (el, strict = false) { + let win = getWindow(el); + let displayed = await lazy.atom.isElementDisplayed(el, win); + + let a11y = lazy.accessibility.get(strict); + return a11y.assertAccessible(el).then(acc => { + a11y.assertVisible(acc, el, displayed); + return displayed; + }); +}; + +/** + * Check if element is enabled. + * + * @param {DOMElement|XULElement} el + * Element to test if is enabled. + * + * @returns {boolean} + * True if enabled, false otherwise. + */ +interaction.isElementEnabled = async function (el, strict = false) { + let enabled = true; + let win = getWindow(el); + + if (lazy.dom.isXULElement(el)) { + // check if XUL element supports disabled attribute + if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) { + if ( + el.hasAttribute("disabled") && + el.getAttribute("disabled") === "true" + ) { + enabled = false; + } + } + } else if ( + ["application/xml", "text/xml"].includes(win.document.contentType) + ) { + enabled = false; + } else { + enabled = await lazy.atom.isElementEnabled(el, win); + } + + let a11y = lazy.accessibility.get(strict); + return a11y.assertAccessible(el).then(acc => { + a11y.assertEnabled(acc, el, enabled); + return enabled; + }); +}; + +/** + * Determines if the referenced element is selected or not, with + * an additional accessibility check if <var>strict</var> is true. + * + * This operation only makes sense on input elements of the checkbox- + * and radio button states, and option elements. + * + * @param {(DOMElement|XULElement)} el + * Element to test if is selected. + * @param {boolean=} [strict=false] strict + * Enforce strict accessibility tests. + * + * @returns {boolean} + * True if element is selected, false otherwise. + * + * @throws {ElementNotAccessibleError} + * If <var>el</var> is not accessible when <var>strict</var> is true. + */ +interaction.isElementSelected = function (el, strict = false) { + let selected = lazy.dom.isSelected(el); + + let a11y = lazy.accessibility.get(strict); + return a11y.assertAccessible(el).then(acc => { + a11y.assertSelected(acc, el, selected); + return selected; + }); +}; + +function getWindow(el) { + // eslint-disable-next-line mozilla/use-ownerGlobal + return el.ownerDocument.defaultView; +} diff --git a/remote/marionette/jar.mn b/remote/marionette/jar.mn new file mode 100644 index 0000000000..b206dc2487 --- /dev/null +++ b/remote/marionette/jar.mn @@ -0,0 +1,51 @@ +# 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/. + +remote.jar: +% content remote %content/ + content/marionette/accessibility.sys.mjs (accessibility.sys.mjs) + content/marionette/actors/MarionetteCommandsChild.sys.mjs (actors/MarionetteCommandsChild.sys.mjs) + content/marionette/actors/MarionetteCommandsParent.sys.mjs (actors/MarionetteCommandsParent.sys.mjs) + content/marionette/actors/MarionetteEventsChild.sys.mjs (actors/MarionetteEventsChild.sys.mjs) + content/marionette/actors/MarionetteEventsParent.sys.mjs (actors/MarionetteEventsParent.sys.mjs) + content/marionette/actors/MarionetteReftestChild.sys.mjs (actors/MarionetteReftestChild.sys.mjs) + content/marionette/actors/MarionetteReftestParent.sys.mjs (actors/MarionetteReftestParent.sys.mjs) + content/marionette/addon.sys.mjs (addon.sys.mjs) + content/marionette/atom.sys.mjs (atom.sys.mjs) + content/marionette/browser.sys.mjs (browser.sys.mjs) + content/marionette/cert.sys.mjs (cert.sys.mjs) + content/marionette/cookie.sys.mjs (cookie.sys.mjs) + content/marionette/driver.sys.mjs (driver.sys.mjs) + content/marionette/evaluate.sys.mjs (evaluate.sys.mjs) + content/marionette/event.sys.mjs (event.sys.mjs) + content/marionette/interaction.sys.mjs (interaction.sys.mjs) + content/marionette/json.sys.mjs (json.sys.mjs) + content/marionette/l10n.sys.mjs (l10n.sys.mjs) + content/marionette/message.sys.mjs (message.sys.mjs) + content/marionette/navigate.sys.mjs (navigate.sys.mjs) + content/marionette/packets.sys.mjs (packets.sys.mjs) + content/marionette/permissions.sys.mjs (permissions.sys.mjs) + content/marionette/prefs.sys.mjs (prefs.sys.mjs) + content/marionette/reftest.sys.mjs (reftest.sys.mjs) + content/marionette/reftest.xhtml (chrome/reftest.xhtml) + content/marionette/reftest-content.js (reftest-content.js) + content/marionette/server.sys.mjs (server.sys.mjs) + content/marionette/stream-utils.sys.mjs (stream-utils.sys.mjs) + content/marionette/sync.sys.mjs (sync.sys.mjs) + content/marionette/transport.sys.mjs (transport.sys.mjs) + content/marionette/web-reference.sys.mjs (web-reference.sys.mjs) + content/marionette/webauthn.sys.mjs (webauthn.sys.mjs) +#ifdef ENABLE_TESTS + content/marionette/test_dialog.dtd (chrome/test_dialog.dtd) + content/marionette/test_dialog.properties (chrome/test_dialog.properties) + content/marionette/test_dialog.xhtml (chrome/test_dialog.xhtml) + content/marionette/test_menupopup.xhtml (chrome/test_menupopup.xhtml) + content/marionette/test_nested_iframe.xhtml (chrome/test_nested_iframe.xhtml) + content/marionette/test_no_xul.xhtml (chrome/test_no_xul.xhtml) + content/marionette/test.xhtml (chrome/test.xhtml) + content/marionette/test2.xhtml (chrome/test2.xhtml) +#ifdef MOZ_CODE_COVERAGE + content/marionette/PerTestCoverageUtils.sys.mjs (../../tools/code-coverage/PerTestCoverageUtils.sys.mjs) +#endif +#endif diff --git a/remote/marionette/json.sys.mjs b/remote/marionette/json.sys.mjs new file mode 100644 index 0000000000..bae1b99cdd --- /dev/null +++ b/remote/marionette/json.sys.mjs @@ -0,0 +1,491 @@ +/* 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/. */ + +import { WebFrame, WebWindow } from "./web-reference.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + ShadowRoot: "chrome://remote/content/marionette/web-reference.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + WebElement: "chrome://remote/content/marionette/web-reference.sys.mjs", + WebFrame: "chrome://remote/content/marionette/web-reference.sys.mjs", + WebReference: "chrome://remote/content/marionette/web-reference.sys.mjs", + WebWindow: "chrome://remote/content/marionette/web-reference.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +/** @namespace */ +export const json = {}; + +/** + * Clone an object including collections. + * + * @param {object} value + * Object to be cloned. + * @param {Set} seen + * List of objects already processed. + * @param {Function} cloneAlgorithm + * The clone algorithm to invoke for individual list entries or object + * properties. + * + * @returns {object} + * The cloned object. + */ +function cloneObject(value, seen, cloneAlgorithm) { + // Only proceed with cloning an object if it hasn't been seen yet. + if (seen.has(value)) { + throw new lazy.error.JavaScriptError("Cyclic object value"); + } + seen.add(value); + + let result; + + if (lazy.dom.isCollection(value)) { + result = [...value].map(entry => cloneAlgorithm(entry, seen)); + } else { + // arbitrary objects + result = {}; + for (let prop in value) { + try { + result[prop] = cloneAlgorithm(value[prop], seen); + } catch (e) { + if (e.result == Cr.NS_ERROR_NOT_IMPLEMENTED) { + lazy.logger.debug(`Skipping ${prop}: ${e.message}`); + } else { + throw e; + } + } + } + } + + seen.delete(value); + + return result; +} + +/** + * Clone arbitrary objects to JSON-safe primitives that can be + * transported across processes and over the Marionette protocol. + * + * The marshaling rules are as follows: + * + * - Primitives are returned as is. + * + * - Collections, such as `Array`, `NodeList`, `HTMLCollection` + * et al. are transformed to arrays and then recursed. + * + * - Elements and ShadowRoots that are not known WebReference's are added to + * the `NodeCache`. For both the associated unique web reference identifier + * is returned. + * + * - Objects with custom JSON representations, i.e. if they have + * a callable `toJSON` function, are returned verbatim. This means + * their internal integrity _are not_ checked. Be careful. + * + * - If a cyclic references is detected a JavaScriptError is thrown. + * + * @param {object} value + * Object to be cloned. + * @param {NodeCache} nodeCache + * Node cache that holds already seen WebElement and ShadowRoot references. + * + * @returns {Object<Map<BrowsingContext, Array<string>, object>>} + * Object that contains a list of browsing contexts each with a list of + * shared ids for collected elements and shadow root nodes, and second the + * same object as provided by `value` with the WebDriver classic supported + * DOM nodes replaced by WebReference's. + * + * @throws {JavaScriptError} + * If an object contains cyclic references. + * @throws {StaleElementReferenceError} + * If the element has gone stale, indicating it is no longer + * attached to the DOM. + */ +json.clone = function (value, nodeCache) { + const seenNodeIds = new Map(); + let hasSerializedWindows = false; + + function cloneJSON(value, seen) { + if (seen === undefined) { + seen = new Set(); + } + + if ([undefined, null].includes(value)) { + return null; + } + + const type = typeof value; + + if (["boolean", "number", "string"].includes(type)) { + // Primitive values + return value; + } + + // Evaluation of code might take place in mutable sandboxes, which are + // created to waive XRays by default. As such DOM nodes and windows + // have to be unwaived before accessing properties like "ownerGlobal" + // is possible. + // + // Until bug 1743788 is fixed there might be the possibility that more + // objects might need to be unwaived as well. + const isNode = Node.isInstance(value); + const isWindow = Window.isInstance(value); + if (isNode || isWindow) { + value = Cu.unwaiveXrays(value); + } + + if (isNode && lazy.dom.isElement(value)) { + // Convert DOM elements to WebReference instances. + + if (lazy.dom.isStale(value)) { + // Don't create a reference for stale elements. + throw new lazy.error.StaleElementReferenceError( + lazy.pprint`The element ${value} is no longer attached to the DOM` + ); + } + + const nodeRef = nodeCache.getOrCreateNodeReference(value, seenNodeIds); + + return lazy.WebReference.from(value, nodeRef).toJSON(); + } + + if (isNode && lazy.dom.isShadowRoot(value)) { + // Convert ShadowRoot instances to WebReference references. + + if (lazy.dom.isDetached(value)) { + // Don't create a reference for detached shadow roots. + throw new lazy.error.DetachedShadowRootError( + lazy.pprint`The ShadowRoot ${value} is no longer attached to the DOM` + ); + } + + const nodeRef = nodeCache.getOrCreateNodeReference(value, seenNodeIds); + + return lazy.WebReference.from(value, nodeRef).toJSON(); + } + + if (isWindow) { + // Convert window instances to WebReference references. + let reference; + + if (value.browsingContext.parent == null) { + reference = new WebWindow(value.browsingContext.browserId.toString()); + hasSerializedWindows = true; + } else { + reference = new WebFrame(value.browsingContext.id.toString()); + } + + return reference.toJSON(); + } + + if (typeof value.toJSON == "function") { + // custom JSON representation + let unsafeJSON; + try { + unsafeJSON = value.toJSON(); + } catch (e) { + throw new lazy.error.JavaScriptError(`toJSON() failed with: ${e}`); + } + + return cloneJSON(unsafeJSON, seen); + } + + // Collections and arbitrary objects + return cloneObject(value, seen, cloneJSON); + } + + return { + seenNodeIds, + serializedValue: cloneJSON(value, new Set()), + hasSerializedWindows, + }; +}; + +/** + * Deserialize an arbitrary object. + * + * @param {object} value + * Arbitrary object. + * @param {NodeCache} nodeCache + * Node cache that holds already seen WebElement and ShadowRoot references. + * @param {BrowsingContext} browsingContext + * The browsing context to check. + * + * @returns {object} + * Same object as provided by `value` with the WebDriver specific + * references replaced with real JavaScript objects. + * + * @throws {NoSuchElementError} + * If the WebElement reference has not been seen before. + * @throws {StaleElementReferenceError} + * If the element is stale, indicating it is no longer attached to the DOM. + */ +json.deserialize = function (value, nodeCache, browsingContext) { + function deserializeJSON(value, seen) { + if (seen === undefined) { + seen = new Set(); + } + + if (value === undefined || value === null) { + return value; + } + + switch (typeof value) { + case "boolean": + case "number": + case "string": + default: + return value; + + case "object": + if (lazy.WebReference.isReference(value)) { + // Create a WebReference based on the WebElement identifier. + const webRef = lazy.WebReference.fromJSON(value); + + if (webRef instanceof lazy.ShadowRoot) { + return getKnownShadowRoot(browsingContext, webRef.uuid, nodeCache); + } + + if (webRef instanceof lazy.WebElement) { + return getKnownElement(browsingContext, webRef.uuid, nodeCache); + } + + if (webRef instanceof lazy.WebFrame) { + const browsingContext = BrowsingContext.get(webRef.uuid); + + if (browsingContext === null || browsingContext.parent === null) { + throw new lazy.error.NoSuchWindowError( + `Unable to locate frame with id: ${webRef.uuid}` + ); + } + + return browsingContext.window; + } + + if (webRef instanceof lazy.WebWindow) { + const browsingContext = BrowsingContext.getCurrentTopByBrowserId( + webRef.uuid + ); + + if (browsingContext === null) { + throw new lazy.error.NoSuchWindowError( + `Unable to locate window with id: ${webRef.uuid}` + ); + } + + return browsingContext.window; + } + } + + return cloneObject(value, seen, deserializeJSON); + } + } + + return deserializeJSON(value, new Set()); +}; + +/** + * Convert unique navigable ids to internal browser ids. + * + * @param {object} serializedData + * The data to process. + * + * @returns {object} + * The processed data. + */ +json.mapFromNavigableIds = function (serializedData) { + function _processData(data) { + if (lazy.WebReference.isReference(data)) { + const webRef = lazy.WebReference.fromJSON(data); + + if (webRef instanceof lazy.WebWindow) { + const browser = lazy.TabManager.getBrowserById(webRef.uuid); + if (browser) { + webRef.uuid = browser?.browserId.toString(); + data = webRef.toJSON(); + } + } + } else if (typeof data === "object") { + for (const entry in data) { + data[entry] = _processData(data[entry]); + } + } + + return data; + } + + return _processData(serializedData); +}; + +/** + * Convert browser ids to unique navigable ids. + * + * @param {object} serializedData + * The data to process. + * + * @returns {object} + * The processed data. + */ +json.mapToNavigableIds = function (serializedData) { + function _processData(data) { + if (lazy.WebReference.isReference(data)) { + const webRef = lazy.WebReference.fromJSON(data); + if (webRef instanceof lazy.WebWindow) { + const browsingContext = BrowsingContext.getCurrentTopByBrowserId( + webRef.uuid + ); + + webRef.uuid = lazy.TabManager.getIdForBrowsingContext(browsingContext); + data = webRef.toJSON(); + } + } else if (typeof data == "object") { + for (const entry in data) { + data[entry] = _processData(data[entry]); + } + } + + return data; + } + + return _processData(serializedData); +}; + +/** + * Resolve element from specified web reference identifier. + * + * @param {BrowsingContext} browsingContext + * The browsing context to retrieve the element from. + * @param {string} nodeId + * The WebReference uuid for a DOM element. + * @param {NodeCache} nodeCache + * Node cache that holds already seen WebElement and ShadowRoot references. + * + * @returns {Element} + * The DOM element that the identifier was generated for. + * + * @throws {NoSuchElementError} + * If the element doesn't exist in the current browsing context. + * @throws {StaleElementReferenceError} + * If the element has gone stale, indicating its node document is no + * longer the active document or it is no longer attached to the DOM. + */ +export function getKnownElement(browsingContext, nodeId, nodeCache) { + if (!isNodeReferenceKnown(browsingContext, nodeId, nodeCache)) { + throw new lazy.error.NoSuchElementError( + `The element with the reference ${nodeId} is not known in the current browsing context`, + { elementId: nodeId } + ); + } + + const node = nodeCache.getNode(browsingContext, nodeId); + + // Ensure the node is of the correct Node type. + if (node !== null && !lazy.dom.isElement(node)) { + throw new lazy.error.NoSuchElementError( + `The element with the reference ${nodeId} is not of type HTMLElement` + ); + } + + // If null, which may be the case if the element has been unwrapped from a + // weak reference, it is always considered stale. + if (node === null || lazy.dom.isStale(node)) { + throw new lazy.error.StaleElementReferenceError( + `The element with the reference ${nodeId} ` + + "is stale; either its node document is not the active document, " + + "or it is no longer connected to the DOM" + ); + } + + return node; +} + +/** + * Resolve ShadowRoot from specified web reference identifier. + * + * @param {BrowsingContext} browsingContext + * The browsing context to retrieve the shadow root from. + * @param {string} nodeId + * The WebReference uuid for a ShadowRoot. + * @param {NodeCache} nodeCache + * Node cache that holds already seen WebElement and ShadowRoot references. + * + * @returns {ShadowRoot} + * The ShadowRoot that the identifier was generated for. + * + * @throws {NoSuchShadowRootError} + * If the ShadowRoot doesn't exist in the current browsing context. + * @throws {DetachedShadowRootError} + * If the ShadowRoot is detached, indicating its node document is no + * longer the active document or it is no longer attached to the DOM. + */ +export function getKnownShadowRoot(browsingContext, nodeId, nodeCache) { + if (!isNodeReferenceKnown(browsingContext, nodeId, nodeCache)) { + throw new lazy.error.NoSuchShadowRootError( + `The shadow root with the reference ${nodeId} is not known in the current browsing context`, + { shadowId: nodeId } + ); + } + + const node = nodeCache.getNode(browsingContext, nodeId); + + // Ensure the node is of the correct Node type. + if (node !== null && !lazy.dom.isShadowRoot(node)) { + throw new lazy.error.NoSuchShadowRootError( + `The shadow root with the reference ${nodeId} is not of type ShadowRoot` + ); + } + + // If null, which may be the case if the element has been unwrapped from a + // weak reference, it is always considered stale. + if (node === null || lazy.dom.isDetached(node)) { + throw new lazy.error.DetachedShadowRootError( + `The shadow root with the reference ${nodeId} ` + + "is detached; either its node document is not the active document, " + + "or it is no longer connected to the DOM" + ); + } + + return node; +} + +/** + * Determines if the node reference is known for the given browsing context. + * + * For WebDriver classic only nodes from the same browsing context are + * allowed to be accessed. + * + * @param {BrowsingContext} browsingContext + * The browsing context the element has to be part of. + * @param {ElementIdentifier} nodeId + * The WebElement reference identifier for a DOM element. + * @param {NodeCache} nodeCache + * Node cache that holds already seen node references. + * + * @returns {boolean} + * True if the element is known in the given browsing context. + */ +function isNodeReferenceKnown(browsingContext, nodeId, nodeCache) { + const nodeDetails = nodeCache.getReferenceDetails(nodeId); + if (nodeDetails === null) { + return false; + } + + if (nodeDetails.isTopBrowsingContext) { + // As long as Navigables are not available any cross-group navigation will + // cause a swap of the current top-level browsing context. The only unique + // identifier in such a case is the browser id the top-level browsing + // context actually lives in. + return nodeDetails.browserId === browsingContext.browserId; + } + + return nodeDetails.browsingContextId === browsingContext.id; +} diff --git a/remote/marionette/l10n.sys.mjs b/remote/marionette/l10n.sys.mjs new file mode 100644 index 0000000000..ed9f307463 --- /dev/null +++ b/remote/marionette/l10n.sys.mjs @@ -0,0 +1,101 @@ +/* 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/. */ + +/** + * An API which allows Marionette to handle localized content. + * + * The localization (https://mzl.la/2eUMjyF) of UI elements in Gecko + * based applications is done via entities and properties. For static + * values entities are used, which are located in .dtd files. Whereby for + * dynamically updated content the values come from .property files. Both + * types of elements can be identifed via a unique id, and the translated + * content retrieved. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "domParser", () => { + const parser = new DOMParser(); + parser.forceEnableDTD(); + return parser; +}); + +/** @namespace */ +export const l10n = {}; + +/** + * Retrieve the localized string for the specified entity id. + * + * Example: + * localizeEntity(["chrome://branding/locale/brand.dtd"], "brandShortName") + * + * @param {Array.<string>} urls + * Array of .dtd URLs. + * @param {string} id + * The ID of the entity to retrieve the localized string for. + * + * @returns {string} + * The localized string for the requested entity. + */ +l10n.localizeEntity = function (urls, id) { + // Build a string which contains all possible entity locations + let locations = []; + urls.forEach((url, index) => { + locations.push(`<!ENTITY % dtd_${index} SYSTEM "${url}">%dtd_${index};`); + }); + + // Use the DOM parser to resolve the entity and extract its real value + let header = `<?xml version="1.0"?><!DOCTYPE elem [${locations.join("")}]>`; + let elem = `<elem id="elementID">&${id};</elem>`; + let doc = lazy.domParser.parseFromString(header + elem, "text/xml"); + let element = doc.querySelector("elem[id='elementID']"); + + if (element === null) { + throw new lazy.error.NoSuchElementError( + `Entity with id='${id}' hasn't been found` + ); + } + + return element.textContent; +}; + +/** + * Retrieve the localized string for the specified property id. + * + * Example: + * + * localizeProperty( + * ["chrome://global/locale/findbar.properties"], "FastFind"); + * + * @param {Array.<string>} urls + * Array of .properties URLs. + * @param {string} id + * The ID of the property to retrieve the localized string for. + * + * @returns {string} + * The localized string for the requested property. + */ +l10n.localizeProperty = function (urls, id) { + let property = null; + + for (let url of urls) { + let bundle = Services.strings.createBundle(url); + try { + property = bundle.GetStringFromName(id); + break; + } catch (e) {} + } + + if (property === null) { + throw new lazy.error.NoSuchElementError( + `Property with ID '${id}' hasn't been found` + ); + } + + return property; +}; diff --git a/remote/marionette/message.sys.mjs b/remote/marionette/message.sys.mjs new file mode 100644 index 0000000000..d8b5dd60f9 --- /dev/null +++ b/remote/marionette/message.sys.mjs @@ -0,0 +1,329 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", +}); + +/** Representation of the packets transproted over the wire. */ +export class Message { + /** + * @param {number} messageID + * Message ID unique identifying this message. + */ + constructor(messageID) { + this.id = lazy.assert.integer(messageID); + } + + toString() { + function replacer(key, value) { + if (typeof value === "string") { + return lazy.truncate`${value}`; + } + return value; + } + + return JSON.stringify(this.toPacket(), replacer); + } + + /** + * Converts a data packet into a {@link Command} or {@link Response}. + * + * @param {Array.<number, number, ?, ?>} data + * A four element array where the elements, in sequence, signifies + * message type, message ID, method name or error, and parameters + * or result. + * + * @returns {Message} + * Based on the message type, a {@link Command} or {@link Response} + * instance. + * + * @throws {TypeError} + * If the message type is not recognised. + */ + static fromPacket(data) { + const [type] = data; + + switch (type) { + case Command.Type: + return Command.fromPacket(data); + + case Response.Type: + return Response.fromPacket(data); + + default: + throw new TypeError( + "Unrecognised message type in packet: " + JSON.stringify(data) + ); + } + } +} + +/** + * Messages may originate from either the server or the client. + * Because the remote protocol is full duplex, both endpoints may be + * the origin of both commands and responses. + * + * @enum + * @see {@link Message} + */ +Message.Origin = { + /** Indicates that the message originates from the client. */ + Client: 0, + /** Indicates that the message originates from the server. */ + Server: 1, +}; + +/** + * A command is a request from the client to run a series of remote end + * steps and return a fitting response. + * + * The command can be synthesised from the message passed over the + * Marionette socket using the {@link fromPacket} function. The format of + * a message is: + * + * <pre> + * [<var>type</var>, <var>id</var>, <var>name</var>, <var>params</var>] + * </pre> + * + * where + * + * <dl> + * <dt><var>type</var> (integer) + * <dd> + * Must be zero (integer). Zero means that this message is + * a command. + * + * <dt><var>id</var> (integer) + * <dd> + * Integer used as a sequence number. The server replies with + * the same ID for the response. + * + * <dt><var>name</var> (string) + * <dd> + * String representing the command name with an associated set + * of remote end steps. + * + * <dt><var>params</var> (JSON Object or null) + * <dd> + * Object of command function arguments. The keys of this object + * must be strings, but the values can be arbitrary values. + * </dl> + * + * A command has an associated message <var>id</var> that prevents + * the dispatcher from sending responses in the wrong order. + * + * The command may also have optional error- and result handlers that + * are called when the client returns with a response. These are + * <code>function onerror({Object})</code>, + * <code>function onresult({Object})</code>, and + * <code>function onresult({Response})</code>: + * + * @param {number} messageID + * Message ID unique identifying this message. + * @param {string} name + * Command name. + * @param {Object<string, ?>} params + * Command parameters. + */ +export class Command extends Message { + constructor(messageID, name, params = {}) { + super(messageID); + + this.name = lazy.assert.string(name); + this.parameters = lazy.assert.object(params); + + this.onerror = null; + this.onresult = null; + + this.origin = Message.Origin.Client; + this.sent = false; + } + + /** + * Calls the error- or result handler associated with this command. + * This function can be replaced with a custom response handler. + * + * @param {Response} resp + * The response to pass on to the result or error to the + * <code>onerror</code> or <code>onresult</code> handlers to. + */ + onresponse(resp) { + if (this.onerror && resp.error) { + this.onerror(resp.error); + } else if (this.onresult && resp.body) { + this.onresult(resp.body); + } + } + + /** + * Encodes the command to a packet. + * + * @returns {Array} + * Packet. + */ + toPacket() { + return [Command.Type, this.id, this.name, this.parameters]; + } + + /** + * Converts a data packet into {@link Command}. + * + * @param {Array.<number, number, *, *>} payload + * A four element array where the elements, in sequence, signifies + * message type, message ID, command name, and parameters. + * + * @returns {Command} + * Representation of packet. + * + * @throws {TypeError} + * If the message type is not recognised. + */ + static fromPacket(payload) { + let [type, msgID, name, params] = payload; + lazy.assert.that(n => n === Command.Type)(type); + + // if parameters are given but null, treat them as undefined + if (params === null) { + params = undefined; + } + + return new Command(msgID, name, params); + } +} + +Command.Type = 0; + +/** + * @callback ResponseCallback + * + * @param {Response} resp + * Response to handle. + */ + +/** + * Represents the response returned from the remote end after execution + * of its corresponding command. + * + * The response is a mutable object passed to each command for + * modification through the available setters. To send data in a response, + * you modify the body property on the response. The body property can + * also be replaced completely. + * + * The response is sent implicitly by + * {@link server.TCPConnection#execute when a command has finished + * executing, and any modifications made subsequent to that will have + * no effect. + * + * @param {number} messageID + * Message ID tied to the corresponding command request this is + * a response for. + * @param {ResponseHandler} respHandler + * Function callback called on sending the response. + */ +export class Response extends Message { + constructor(messageID, respHandler = () => {}) { + super(messageID); + + this.respHandler_ = lazy.assert.callable(respHandler); + + this.error = null; + this.body = { value: null }; + + this.origin = Message.Origin.Server; + this.sent = false; + } + + /** + * Sends response conditionally, given a predicate. + * + * @param {function(Response): boolean} predicate + * A predicate taking a Response object and returning a boolean. + */ + sendConditionally(predicate) { + if (predicate(this)) { + this.send(); + } + } + + /** + * Sends response using the response handler provided on + * construction. + * + * @throws {RangeError} + * If the response has already been sent. + */ + send() { + if (this.sent) { + throw new RangeError("Response has already been sent: " + this); + } + this.respHandler_(this); + this.sent = true; + } + + /** + * Send error to client. + * + * Turns the response into an error response, clears any previously + * set body data, and sends it using the response handler provided + * on construction. + * + * @param {Error} err + * The Error instance to send. + * + * @throws {Error} + * If <var>err</var> is not a {@link WebDriverError}, the error + * is propagated, i.e. rethrown. + */ + sendError(err) { + this.error = lazy.error.wrap(err).toJSON(); + this.body = null; + this.send(); + + // propagate errors which are implementation problems + if (!lazy.error.isWebDriverError(err)) { + throw err; + } + } + + /** + * Encodes the response to a packet. + * + * @returns {Array} + * Packet. + */ + toPacket() { + return [Response.Type, this.id, this.error, this.body]; + } + + /** + * Converts a data packet into {@link Response}. + * + * @param {Array.<number, number, ?, ?>} payload + * A four element array where the elements, in sequence, signifies + * message type, message ID, error, and result. + * + * @returns {Response} + * Representation of packet. + * + * @throws {TypeError} + * If the message type is not recognised. + */ + static fromPacket(payload) { + let [type, msgID, err, body] = payload; + lazy.assert.that(n => n === Response.Type)(type); + + let resp = new Response(msgID); + resp.error = lazy.assert.string(err); + + resp.body = body; + return resp; + } +} + +Response.Type = 1; diff --git a/remote/marionette/moz.build b/remote/marionette/moz.build new file mode 100644 index 0000000000..52237f8719 --- /dev/null +++ b/remote/marionette/moz.build @@ -0,0 +1,10 @@ +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] + +with Files("**"): + BUG_COMPONENT = ("Remote Protocol", "Marionette") diff --git a/remote/marionette/navigate.sys.mjs b/remote/marionette/navigate.sys.mjs new file mode 100644 index 0000000000..993ca75cf8 --- /dev/null +++ b/remote/marionette/navigate.sys.mjs @@ -0,0 +1,429 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + EventDispatcher: + "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + PageLoadStrategy: + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", + ProgressListener: "chrome://remote/content/shared/Navigate.sys.mjs", + TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// Timeouts used to check if a new navigation has been initiated. +const TIMEOUT_BEFOREUNLOAD_EVENT = 200; +const TIMEOUT_UNLOAD_EVENT = 5000; + +/** @namespace */ +export const navigate = {}; + +/** + * Checks the value of readyState for the current page + * load activity, and resolves the command if the load + * has been finished. It also takes care of the selected + * page load strategy. + * + * @param {PageLoadStrategy} pageLoadStrategy + * Strategy when navigation is considered as finished. + * @param {object} eventData + * @param {string} eventData.documentURI + * Current document URI of the document. + * @param {string} eventData.readyState + * Current ready state of the document. + * + * @returns {boolean} + * True if the page load has been finished. + */ +function checkReadyState(pageLoadStrategy, eventData = {}) { + const { documentURI, readyState } = eventData; + + const result = { error: null, finished: false }; + + switch (readyState) { + case "interactive": + if (documentURI.startsWith("about:certerror")) { + result.error = new lazy.error.InsecureCertificateError(); + result.finished = true; + } else if (/about:.*(error)\?/.exec(documentURI)) { + result.error = new lazy.error.UnknownError( + `Reached error page: ${documentURI}` + ); + result.finished = true; + + // Return early with a page load strategy of eager, and also + // special-case about:blocked pages which should be treated as + // non-error pages but do not raise a pageshow event. about:blank + // is also treaded specifically here, because it gets temporary + // loaded for new content processes, and we only want to rely on + // complete loads for it. + } else if ( + (pageLoadStrategy === lazy.PageLoadStrategy.Eager && + documentURI != "about:blank") || + /about:blocked\?/.exec(documentURI) + ) { + result.finished = true; + } + break; + + case "complete": + result.finished = true; + break; + } + + return result; +} + +/** + * Determines if we expect to get a DOM load event (DOMContentLoaded) + * on navigating to the <code>future</code> URL. + * + * @param {URL} current + * URL the browser is currently visiting. + * @param {object} options + * @param {BrowsingContext=} options.browsingContext + * The current browsing context. Needed for targets of _parent and _top. + * @param {URL=} options.future + * Destination URL, if known. + * @param {target=} options.target + * Link target, if known. + * + * @returns {boolean} + * Full page load would be expected if future is followed. + * + * @throws TypeError + * If <code>current</code> is not defined, or any of + * <code>current</code> or <code>future</code> are invalid URLs. + */ +navigate.isLoadEventExpected = function (current, options = {}) { + const { browsingContext, future, target } = options; + + if (typeof current == "undefined") { + throw new TypeError("Expected at least one URL"); + } + + if (["_parent", "_top"].includes(target) && !browsingContext) { + throw new TypeError( + "Expected browsingContext when target is _parent or _top" + ); + } + + // Don't wait if the navigation happens in a different browsing context + if ( + target === "_blank" || + (target === "_parent" && browsingContext.parent) || + (target === "_top" && browsingContext.top != browsingContext) + ) { + return false; + } + + // Assume we will go somewhere exciting + if (typeof future == "undefined") { + return true; + } + + // Assume javascript:<whatever> will modify the current document + // but this is not an entirely safe assumption to make, + // considering it could be used to set window.location + if (future.protocol == "javascript:") { + return false; + } + + // If hashes are present and identical + if ( + current.href.includes("#") && + future.href.includes("#") && + current.hash === future.hash + ) { + return false; + } + + return true; +}; + +/** + * Load the given URL in the specified browsing context. + * + * @param {CanonicalBrowsingContext} browsingContext + * Browsing context to load the URL into. + * @param {string} url + * URL to navigate to. + */ +navigate.navigateTo = async function (browsingContext, url) { + const opts = { + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + // Fake user activation. + hasValidUserGestureActivation: true, + }; + browsingContext.fixupAndLoadURIString(url, opts); +}; + +/** + * Reload the page. + * + * @param {CanonicalBrowsingContext} browsingContext + * Browsing context to refresh. + */ +navigate.refresh = async function (browsingContext) { + const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + browsingContext.reload(flags); +}; + +/** + * Execute a callback and wait for a possible navigation to complete + * + * @param {GeckoDriver} driver + * Reference to driver instance. + * @param {Function} callback + * Callback to execute that might trigger a navigation. + * @param {object} options + * @param {BrowsingContext=} options.browsingContext + * Browsing context to observe. Defaults to the current browsing context. + * @param {boolean=} options.loadEventExpected + * If false, return immediately and don't wait for + * the navigation to be completed. Defaults to true. + * @param {boolean=} options.requireBeforeUnload + * If false and no beforeunload event is fired, abort waiting + * for the navigation. Defaults to true. + */ +navigate.waitForNavigationCompleted = async function waitForNavigationCompleted( + driver, + callback, + options = {} +) { + const { + browsingContextFn = driver.getBrowsingContext.bind(driver), + loadEventExpected = true, + requireBeforeUnload = true, + } = options; + + const browsingContext = browsingContextFn(); + const chromeWindow = browsingContext.topChromeWindow; + const pageLoadStrategy = driver.currentSession.pageLoadStrategy; + + // Return immediately if no load event is expected + if (!loadEventExpected) { + await callback(); + return Promise.resolve(); + } + + // When not waiting for page load events, do not return until the navigation has actually started. + if (pageLoadStrategy === lazy.PageLoadStrategy.None) { + const listener = new lazy.ProgressListener(browsingContext.webProgress, { + resolveWhenStarted: true, + waitForExplicitStart: true, + }); + const navigated = listener.start(); + navigated.finally(() => { + if (listener.isStarted) { + listener.stop(); + } + }); + + await callback(); + await navigated; + + return Promise.resolve(); + } + + let rejectNavigation; + let resolveNavigation; + + let browsingContextChanged = false; + let seenBeforeUnload = false; + let seenUnload = false; + + let unloadTimer; + + const checkDone = ({ finished, error }) => { + if (finished) { + if (error) { + rejectNavigation(error); + } else { + resolveNavigation(); + } + } + }; + + const onPromptOpened = (_, data) => { + if (data.prompt.promptType === "beforeunload") { + // Ignore beforeunload prompts which are handled by the driver class. + return; + } + + lazy.logger.trace("Canceled page load listener because a dialog opened"); + checkDone({ finished: true }); + }; + + const onTimer = timer => { + // For the command "Element Click" we want to detect a potential navigation + // as early as possible. The `beforeunload` event is an indication for that + // but could still cause the navigation to get aborted by the user. As such + // wait a bit longer for the `unload` event to happen, which usually will + // occur pretty soon after `beforeunload`. + // + // Note that with WebDriver BiDi enabled the `beforeunload` prompts might + // not get implicitly accepted, so lets keep the timer around until we know + // that it is really not required. + if (seenBeforeUnload) { + seenBeforeUnload = false; + unloadTimer.initWithCallback( + onTimer, + TIMEOUT_UNLOAD_EVENT, + Ci.nsITimer.TYPE_ONE_SHOT + ); + + // If no page unload has been detected, ensure to properly stop + // the load listener, and return from the currently active command. + } else if (!seenUnload) { + lazy.logger.trace( + "Canceled page load listener because no navigation " + + "has been detected" + ); + checkDone({ finished: true }); + } + }; + + const onNavigation = (eventName, data) => { + const browsingContext = browsingContextFn(); + + // Ignore events from other browsing contexts than the selected one. + if (data.browsingContext != browsingContext) { + return; + } + + lazy.logger.trace( + lazy.truncate`[${data.browsingContext.id}] Received event ${data.type} for ${data.documentURI}` + ); + + switch (data.type) { + case "beforeunload": + seenBeforeUnload = true; + break; + + case "pagehide": + seenUnload = true; + break; + + case "hashchange": + case "popstate": + checkDone({ finished: true }); + break; + + case "DOMContentLoaded": + case "pageshow": + // Don't require an unload event when a top-level browsing context + // change occurred. + if (!seenUnload && !browsingContextChanged) { + return; + } + const result = checkReadyState(pageLoadStrategy, data); + checkDone(result); + break; + } + }; + + // In the case when the currently selected frame is closed, + // there will be no further load events. Stop listening immediately. + const onBrowsingContextDiscarded = (subject, topic, why) => { + // If the BrowsingContext is being discarded to be replaced by another + // context, we don't want to stop waiting for the pageload to complete, as + // we will continue listening to the newly created context. + if (subject == browsingContextFn() && why != "replace") { + lazy.logger.trace( + "Canceled page load listener " + + `because browsing context with id ${subject.id} has been removed` + ); + checkDone({ finished: true }); + } + }; + + // Detect changes to the top-level browsing context to not + // necessarily require an unload event. + const onBrowsingContextChanged = event => { + if (event.target === driver.curBrowser.contentBrowser) { + browsingContextChanged = true; + } + }; + + const onUnload = event => { + lazy.logger.trace( + "Canceled page load listener " + + "because the top-browsing context has been closed" + ); + checkDone({ finished: true }); + }; + + chromeWindow.addEventListener("TabClose", onUnload); + chromeWindow.addEventListener("unload", onUnload); + driver.curBrowser.tabBrowser?.addEventListener( + "XULFrameLoaderCreated", + onBrowsingContextChanged + ); + driver.promptListener.on("opened", onPromptOpened); + Services.obs.addObserver( + onBrowsingContextDiscarded, + "browsing-context-discarded" + ); + + lazy.EventDispatcher.on("page-load", onNavigation); + + return new lazy.TimedPromise( + async (resolve, reject) => { + rejectNavigation = reject; + resolveNavigation = resolve; + + try { + await callback(); + + // Certain commands like clickElement can cause a navigation. Setup a timer + // to check if a "beforeunload" event has been emitted within the given + // time frame. If not resolve the Promise. + if (!requireBeforeUnload) { + unloadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + unloadTimer.initWithCallback( + onTimer, + TIMEOUT_BEFOREUNLOAD_EVENT, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + } catch (e) { + // Executing the callback above could destroy the actor pair before the + // command returns. Such an error has to be ignored. + if (e.name !== "AbortError") { + checkDone({ finished: true, error: e }); + } + } + }, + { + errorMessage: "Navigation timed out", + timeout: driver.currentSession.timeouts.pageLoad, + } + ).finally(() => { + // Clean-up all registered listeners and timers + Services.obs.removeObserver( + onBrowsingContextDiscarded, + "browsing-context-discarded" + ); + chromeWindow.removeEventListener("TabClose", onUnload); + chromeWindow.removeEventListener("unload", onUnload); + driver.curBrowser.tabBrowser?.removeEventListener( + "XULFrameLoaderCreated", + onBrowsingContextChanged + ); + driver.promptListener?.off("opened", onPromptOpened); + unloadTimer?.cancel(); + + lazy.EventDispatcher.off("page-load", onNavigation); + }); +}; diff --git a/remote/marionette/packets.sys.mjs b/remote/marionette/packets.sys.mjs new file mode 100644 index 0000000000..5acb455726 --- /dev/null +++ b/remote/marionette/packets.sys.mjs @@ -0,0 +1,424 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + StreamUtils: "chrome://remote/content/marionette/stream-utils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "unicodeConverter", () => { + const unicodeConverter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + unicodeConverter.charset = "UTF-8"; + + return unicodeConverter; +}); + +/** + * Packets contain read / write functionality for the different packet types + * supported by the debugging protocol, so that a transport can focus on + * delivery and queue management without worrying too much about the specific + * packet types. + * + * They are intended to be "one use only", so a new packet should be + * instantiated for each incoming or outgoing packet. + * + * A complete Packet type should expose at least the following: + * read(stream, scriptableStream) + * Called when the input stream has data to read + * write(stream) + * Called when the output stream is ready to write + * get done() + * Returns true once the packet is done being read / written + * destroy() + * Called to clean up at the end of use + */ + +const defer = function () { + let deferred = { + promise: new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }), + }; + return deferred; +}; + +// The transport's previous check ensured the header length did not +// exceed 20 characters. Here, we opt for the somewhat smaller, but still +// large limit of 1 TiB. +const PACKET_LENGTH_MAX = Math.pow(2, 40); + +/** + * A generic Packet processing object (extended by two subtypes below). + * + * @class + */ +export function Packet(transport) { + this._transport = transport; + this._length = 0; +} + +/** + * Attempt to initialize a new Packet based on the incoming packet header + * we've received so far. We try each of the types in succession, trying + * JSON packets first since they are much more common. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @returns {Packet} + * Parsed packet of the matching type, or null if no types matched. + */ +Packet.fromHeader = function (header, transport) { + return ( + JSONPacket.fromHeader(header, transport) || + BulkPacket.fromHeader(header, transport) + ); +}; + +Packet.prototype = { + get length() { + return this._length; + }, + + set length(length) { + if (length > PACKET_LENGTH_MAX) { + throw new Error( + "Packet length " + + length + + " exceeds the max length of " + + PACKET_LENGTH_MAX + ); + } + this._length = length; + }, + + destroy() { + this._transport = null; + }, +}; + +/** + * With a JSON packet (the typical packet type sent via the transport), + * data is transferred as a JSON packet serialized into a string, + * with the string length prepended to the packet, followed by a colon + * ([length]:[packet]). The contents of the JSON packet are specified in + * the Remote Debugging Protocol specification. + * + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + */ +export function JSONPacket(transport) { + Packet.call(this, transport); + this._data = ""; + this._done = false; +} + +/** + * Attempt to initialize a new JSONPacket based on the incoming packet + * header we've received so far. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @returns {JSONPacket} + * Parsed packet, or null if it's not a match. + */ +JSONPacket.fromHeader = function (header, transport) { + let match = this.HEADER_PATTERN.exec(header); + + if (!match) { + return null; + } + + let packet = new JSONPacket(transport); + packet.length = +match[1]; + return packet; +}; + +JSONPacket.HEADER_PATTERN = /^(\d+):$/; + +JSONPacket.prototype = Object.create(Packet.prototype); + +Object.defineProperty(JSONPacket.prototype, "object", { + /** + * Gets the object (not the serialized string) being read or written. + */ + get() { + return this._object; + }, + + /** + * Sets the object to be sent when write() is called. + */ + set(object) { + this._object = object; + let data = JSON.stringify(object); + this._data = lazy.unicodeConverter.ConvertFromUnicode(data); + this.length = this._data.length; + }, +}); + +JSONPacket.prototype.read = function (stream, scriptableStream) { + // Read in more packet data. + this._readData(stream, scriptableStream); + + if (!this.done) { + // Don't have a complete packet yet. + return; + } + + let json = this._data; + try { + json = lazy.unicodeConverter.ConvertToUnicode(json); + this._object = JSON.parse(json); + } catch (e) { + let msg = + "Error parsing incoming packet: " + + json + + " (" + + e + + " - " + + e.stack + + ")"; + console.error(msg); + dump(msg + "\n"); + return; + } + + this._transport._onJSONObjectReady(this._object); +}; + +JSONPacket.prototype._readData = function (stream, scriptableStream) { + let bytesToRead = Math.min( + this.length - this._data.length, + stream.available() + ); + this._data += scriptableStream.readBytes(bytesToRead); + this._done = this._data.length === this.length; +}; + +JSONPacket.prototype.write = function (stream) { + if (this._outgoing === undefined) { + // Format the serialized packet to a buffer + this._outgoing = this.length + ":" + this._data; + } + + let written = stream.write(this._outgoing, this._outgoing.length); + this._outgoing = this._outgoing.slice(written); + this._done = !this._outgoing.length; +}; + +Object.defineProperty(JSONPacket.prototype, "done", { + get() { + return this._done; + }, +}); + +JSONPacket.prototype.toString = function () { + return JSON.stringify(this._object, null, 2); +}; + +/** + * With a bulk packet, data is transferred by temporarily handing over + * the transport's input or output stream to the application layer for + * writing data directly. This can be much faster for large data sets, + * and avoids various stages of copies and data duplication inherent in + * the JSON packet type. The bulk packet looks like: + * + * bulk [actor] [type] [length]:[data] + * + * The interpretation of the data portion depends on the kind of actor and + * the packet's type. See the Remote Debugging Protocol Stream Transport + * spec for more details. + * + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + */ +export function BulkPacket(transport) { + Packet.call(this, transport); + this._done = false; + this._readyForWriting = defer(); +} + +/** + * Attempt to initialize a new BulkPacket based on the incoming packet + * header we've received so far. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @returns {BulkPacket} + * Parsed packet, or null if it's not a match. + */ +BulkPacket.fromHeader = function (header, transport) { + let match = this.HEADER_PATTERN.exec(header); + + if (!match) { + return null; + } + + let packet = new BulkPacket(transport); + packet.header = { + actor: match[1], + type: match[2], + length: +match[3], + }; + return packet; +}; + +BulkPacket.HEADER_PATTERN = /^bulk ([^: ]+) ([^: ]+) (\d+):$/; + +BulkPacket.prototype = Object.create(Packet.prototype); + +BulkPacket.prototype.read = function (stream) { + // Temporarily pause monitoring of the input stream + this._transport.pauseIncoming(); + + let deferred = defer(); + + this._transport._onBulkReadReady({ + actor: this.actor, + type: this.type, + length: this.length, + copyTo: output => { + let copying = lazy.StreamUtils.copyStream(stream, output, this.length); + deferred.resolve(copying); + return copying; + }, + stream, + done: deferred, + }); + + // Await the result of reading from the stream + deferred.promise.then(() => { + this._done = true; + this._transport.resumeIncoming(); + }, this._transport.close); + + // Ensure this is only done once + this.read = () => { + throw new Error("Tried to read() a BulkPacket's stream multiple times."); + }; +}; + +BulkPacket.prototype.write = function (stream) { + if (this._outgoingHeader === undefined) { + // Format the serialized packet header to a buffer + this._outgoingHeader = + "bulk " + this.actor + " " + this.type + " " + this.length + ":"; + } + + // Write the header, or whatever's left of it to write. + if (this._outgoingHeader.length) { + let written = stream.write( + this._outgoingHeader, + this._outgoingHeader.length + ); + this._outgoingHeader = this._outgoingHeader.slice(written); + return; + } + + // Temporarily pause the monitoring of the output stream + this._transport.pauseOutgoing(); + + let deferred = defer(); + + this._readyForWriting.resolve({ + copyFrom: input => { + let copying = lazy.StreamUtils.copyStream(input, stream, this.length); + deferred.resolve(copying); + return copying; + }, + stream, + done: deferred, + }); + + // Await the result of writing to the stream + deferred.promise.then(() => { + this._done = true; + this._transport.resumeOutgoing(); + }, this._transport.close); + + // Ensure this is only done once + this.write = () => { + throw new Error("Tried to write() a BulkPacket's stream multiple times."); + }; +}; + +Object.defineProperty(BulkPacket.prototype, "streamReadyForWriting", { + get() { + return this._readyForWriting.promise; + }, +}); + +Object.defineProperty(BulkPacket.prototype, "header", { + get() { + return { + actor: this.actor, + type: this.type, + length: this.length, + }; + }, + + set(header) { + this.actor = header.actor; + this.type = header.type; + this.length = header.length; + }, +}); + +Object.defineProperty(BulkPacket.prototype, "done", { + get() { + return this._done; + }, +}); + +BulkPacket.prototype.toString = function () { + return "Bulk: " + JSON.stringify(this.header, null, 2); +}; + +/** + * RawPacket is used to test the transport's error handling of malformed + * packets, by writing data directly onto the stream. + * + * @param {DebuggerTransport} transport + * The transport instance that will own the packet. + * @param {string} data + * The raw string to send out onto the stream. + */ +export function RawPacket(transport, data) { + Packet.call(this, transport); + this._data = data; + this.length = data.length; + this._done = false; +} + +RawPacket.prototype = Object.create(Packet.prototype); + +RawPacket.prototype.read = function () { + // this has not yet been needed for testing + throw new Error("Not implemented"); +}; + +RawPacket.prototype.write = function (stream) { + let written = stream.write(this._data, this._data.length); + this._data = this._data.slice(written); + this._done = !this._data.length; +}; + +Object.defineProperty(RawPacket.prototype, "done", { + get() { + return this._done; + }, +}); diff --git a/remote/marionette/permissions.sys.mjs b/remote/marionette/permissions.sys.mjs new file mode 100644 index 0000000000..5238bf8347 --- /dev/null +++ b/remote/marionette/permissions.sys.mjs @@ -0,0 +1,95 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs", +}); + +/** @namespace */ +export const permissions = {}; + +function mapToInternalPermissionParameters(browsingContext, permissionType) { + const currentURI = browsingContext.currentWindowGlobal.documentURI; + + // storage-access is quite special... + if (permissionType === "storage-access") { + const thirdPartyPrincipalSite = Services.eTLD.getSite(currentURI); + + const topLevelURI = browsingContext.top.currentWindowGlobal.documentURI; + const topLevelPrincipal = + Services.scriptSecurityManager.createContentPrincipal(topLevelURI, {}); + + return { + name: "3rdPartyFrameStorage^" + thirdPartyPrincipalSite, + principal: topLevelPrincipal, + }; + } + + const currentPrincipal = + Services.scriptSecurityManager.createContentPrincipal(currentURI, {}); + + return { + name: permissionType, + principal: currentPrincipal, + }; +} + +/** + * Set a permission's state. + * Note: Currently just a shim to support testdriver's set_permission. + * + * @param {object} permissionType + * The Gecko internal permission type + * @param {string} state + * State of the permission. It can be `granted`, `denied` or `prompt`. + * @param {boolean} oneRealm + * Currently ignored + * @param {browsingContext=} browsingContext + * Current browsing context object + * @throws {UnsupportedOperationError} + * If `marionette.setpermission.enabled` is not set or + * an unsupported permission is used. + */ +permissions.set = function (permissionType, state, oneRealm, browsingContext) { + if (!lazy.MarionettePrefs.setPermissionEnabled) { + throw new lazy.error.UnsupportedOperationError( + "'Set Permission' is not available" + ); + } + + const { name, principal } = mapToInternalPermissionParameters( + browsingContext, + permissionType + ); + + switch (state) { + case "granted": { + Services.perms.addFromPrincipal( + principal, + name, + Services.perms.ALLOW_ACTION + ); + return; + } + case "denied": { + Services.perms.addFromPrincipal( + principal, + name, + Services.perms.DENY_ACTION + ); + return; + } + case "prompt": { + Services.perms.removeFromPrincipal(principal, name); + return; + } + default: + throw new lazy.error.UnsupportedOperationError( + "Unrecognized permission keyword for 'Set Permission' operation" + ); + } +}; diff --git a/remote/marionette/prefs.sys.mjs b/remote/marionette/prefs.sys.mjs new file mode 100644 index 0000000000..17df13d0fd --- /dev/null +++ b/remote/marionette/prefs.sys.mjs @@ -0,0 +1,175 @@ +/* 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/. */ + +const { PREF_BOOL, PREF_INT, PREF_INVALID, PREF_STRING } = Ci.nsIPrefBranch; + +export class Branch { + /** + * @param {string=} branch + * Preference subtree. Uses root tree given `null`. + */ + constructor(branch) { + this._branch = Services.prefs.getBranch(branch); + } + + /** + * Gets value of `pref` in its known type. + * + * @param {string} pref + * Preference name. + * @param {*=} fallback + * Fallback value to return if `pref` does not exist. + * + * @returns {(string|boolean|number)} + * Value of `pref`, or the `fallback` value if `pref` does + * not exist. + * + * @throws {TypeError} + * If `pref` is not a recognised preference and no `fallback` + * value has been provided. + */ + get(pref, fallback = null) { + switch (this._branch.getPrefType(pref)) { + case PREF_STRING: + return this._branch.getStringPref(pref); + + case PREF_BOOL: + return this._branch.getBoolPref(pref); + + case PREF_INT: + return this._branch.getIntPref(pref); + + case PREF_INVALID: + default: + if (fallback != null) { + return fallback; + } + throw new TypeError(`Unrecognised preference: ${pref}`); + } + } + + /** + * Sets the value of `pref`. + * + * @param {string} pref + * Preference name. + * @param {(string|boolean|number)} value + * `pref`'s new value. + * + * @throws {TypeError} + * If `value` is not the correct type for `pref`. + */ + set(pref, value) { + let typ; + if (typeof value != "undefined" && value != null) { + typ = value.constructor.name; + } + + switch (typ) { + case "String": + // Unicode compliant + return this._branch.setStringPref(pref, value); + + case "Boolean": + return this._branch.setBoolPref(pref, value); + + case "Number": + return this._branch.setIntPref(pref, value); + + default: + throw new TypeError(`Illegal preference type value: ${typ}`); + } + } +} + +/** + * Provides shortcuts for lazily getting and setting typed Marionette + * preferences. + * + * Some of Marionette's preferences are stored using primitive values + * that internally are represented by complex types. + * + * Because we cannot trust the input of many of these preferences, + * this class provides abstraction that lets us safely deal with + * potentially malformed input. + * + * A further complication is that we cannot rely on `Preferences.sys.mjs` + * in Marionette. See https://bugzilla.mozilla.org/show_bug.cgi?id=1357517 + * for further details. + */ +class MarionetteBranch extends Branch { + constructor(branch = "marionette.") { + super(branch); + } + + /** + * The `marionette.debugging.clicktostart` preference delays + * server startup until a modal dialogue has been clicked to allow + * time for user to set breakpoints in the Browser Toolbox. + * + * @returns {boolean} + */ + get clickToStart() { + return this.get("debugging.clicktostart", false); + } + + /** + * The `marionette.port` preference, detailing which port + * the TCP server should listen on. + * + * @returns {number} + */ + get port() { + return this.get("port", 2828); + } + + set port(newPort) { + this.set("port", newPort); + } + + /** + * Gets the `marionette.setpermission.enabled` preference, should + * only be used for testdriver's set_permission API. + * + * @returns {boolean} + */ + get setPermissionEnabled() { + return this.get("setpermission.enabled", false); + } +} + +/** Reads a JSON serialised blob stored in the environment. */ +export class EnvironmentPrefs { + /** + * Reads the environment variable `key` and tries to parse it as + * JSON Object, then provides an iterator over its keys and values. + * + * If the environment variable is not set, this function returns empty. + * + * @param {string} key + * Environment variable. + * + * @returns {Iterable.<string, (string|boolean|number)>} + */ + static *from(key) { + if (!Services.env.exists(key)) { + return; + } + + let prefs; + try { + prefs = JSON.parse(Services.env.get(key)); + } catch (e) { + throw new TypeError(`Unable to parse prefs from ${key}`, e); + } + + for (let prefName of Object.keys(prefs)) { + yield [prefName, prefs[prefName]]; + } + } +} + +// There is a future potential of exposing this as Marionette.prefs.port +// if we introduce a Marionette.jsm module. +export const MarionettePrefs = new MarionetteBranch(); diff --git a/remote/marionette/reftest-content.js b/remote/marionette/reftest-content.js new file mode 100644 index 0000000000..3c0712f232 --- /dev/null +++ b/remote/marionette/reftest-content.js @@ -0,0 +1,65 @@ +/* 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/. */ + +/* eslint-env mozilla/frame-script */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PrintUtils", + "chrome://global/content/printUtils.js" +); + +// This is an implementation of nsIBrowserDOMWindow that handles only opening +// print browsers, because the "open a new window fallback" is just too slow +// in some cases and causes timeouts. +function BrowserDOMWindow() {} +BrowserDOMWindow.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIBrowserDOMWindow"]), + + _maybeOpen(aOpenWindowInfo, aWhere) { + if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) { + return PrintUtils.handleStaticCloneCreatedForPrint(aOpenWindowInfo); + } + return null; + }, + + createContentWindow( + aURI, + aOpenWindowInfo, + aWhere, + aFlags, + aTriggeringPrincipal, + aCsp + ) { + return this._maybeOpen(aOpenWindowInfo, aWhere)?.browsingContext; + }, + + openURI(aURI, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) { + return this._maybeOpen(aOpenWindowInfo, aWhere)?.browsingContext; + }, + + createContentWindowInFrame(aURI, aParams, aWhere, aFlags, aName) { + return this._maybeOpen(aParams.openWindowInfo, aWhere); + }, + + openURIInFrame(aURI, aParams, aWhere, aFlags, aName) { + return this._maybeOpen(aParams.openWindowInfo, aWhere); + }, + + canClose() { + return true; + }, + + get tabCount() { + return 1; + }, +}; + +window.browserDOMWindow = new BrowserDOMWindow(); diff --git a/remote/marionette/reftest.sys.mjs b/remote/marionette/reftest.sys.mjs new file mode 100644 index 0000000000..23140fd49f --- /dev/null +++ b/remote/marionette/reftest.sys.mjs @@ -0,0 +1,921 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + capture: "chrome://remote/content/shared/Capture.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + navigate: "chrome://remote/content/marionette/navigate.sys.mjs", + print: "chrome://remote/content/shared/PDF.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +const SCREENSHOT_MODE = { + unexpected: 0, + fail: 1, + always: 2, +}; + +const STATUS = { + PASS: "PASS", + FAIL: "FAIL", + ERROR: "ERROR", + TIMEOUT: "TIMEOUT", +}; + +const DEFAULT_REFTEST_WIDTH = 600; +const DEFAULT_REFTEST_HEIGHT = 600; + +// reftest-print page dimensions in cm +const CM_PER_INCH = 2.54; +const DEFAULT_PAGE_WIDTH = 5 * CM_PER_INCH; +const DEFAULT_PAGE_HEIGHT = 3 * CM_PER_INCH; +const DEFAULT_PAGE_MARGIN = 0.5 * CM_PER_INCH; + +// CSS 96 pixels per inch, compared to pdf.js default 72 pixels per inch +const DEFAULT_PDF_RESOLUTION = 96 / 72; + +/** + * Implements an fast runner for web-platform-tests format reftests + * c.f. http://web-platform-tests.org/writing-tests/reftests.html. + * + * @namespace + */ +export const reftest = {}; + +/** + * @memberof reftest + * @class Runner + */ +reftest.Runner = class { + constructor(driver) { + this.driver = driver; + this.canvasCache = new DefaultMap(undefined, () => new Map([[null, []]])); + this.isPrint = null; + this.windowUtils = null; + this.lastURL = null; + this.useRemoteTabs = lazy.AppInfo.browserTabsRemoteAutostart; + this.useRemoteSubframes = lazy.AppInfo.fissionAutostart; + } + + /** + * Setup the required environment for running reftests. + * + * This will open a non-browser window in which the tests will + * be loaded, and set up various caches for the reftest run. + * + * @param {Object<number>} urlCount + * Object holding a map of URL: number of times the URL + * will be opened during the reftest run, where that's + * greater than 1. + * @param {string} screenshotMode + * String enum representing when screenshots should be taken + */ + setup(urlCount, screenshotMode, isPrint = false) { + this.isPrint = isPrint; + + lazy.assert.open(this.driver.getBrowsingContext({ top: true })); + this.parentWindow = this.driver.getCurrentWindow(); + + this.screenshotMode = + SCREENSHOT_MODE[screenshotMode] || SCREENSHOT_MODE.unexpected; + + this.urlCount = Object.keys(urlCount || {}).reduce( + (map, key) => map.set(key, urlCount[key]), + new Map() + ); + + if (isPrint) { + this.loadPdfJs(); + } + + ChromeUtils.registerWindowActor("MarionetteReftest", { + kind: "JSWindowActor", + parent: { + esModuleURI: + "chrome://remote/content/marionette/actors/MarionetteReftestParent.sys.mjs", + }, + child: { + esModuleURI: + "chrome://remote/content/marionette/actors/MarionetteReftestChild.sys.mjs", + events: { + load: { mozSystemGroup: true, capture: true }, + }, + }, + allFrames: true, + }); + } + + /** + * Cleanup the environment once the reftest is finished. + */ + teardown() { + // Abort the current test if any. + this.abort(); + + // Unregister the JSWindowActors. + ChromeUtils.unregisterWindowActor("MarionetteReftest"); + } + + async ensureWindow(timeout, width, height) { + lazy.logger.debug(`ensuring we have a window ${width}x${height}`); + + if (this.reftestWin && !this.reftestWin.closed) { + let browserRect = this.reftestWin.gBrowser.getBoundingClientRect(); + if (browserRect.width === width && browserRect.height === height) { + return this.reftestWin; + } + lazy.logger.debug(`current: ${browserRect.width}x${browserRect.height}`); + } + + let reftestWin; + if (lazy.AppInfo.isAndroid) { + lazy.logger.debug("Using current window"); + reftestWin = this.parentWindow; + await lazy.navigate.waitForNavigationCompleted(this.driver, () => { + const browsingContext = this.driver.getBrowsingContext(); + lazy.navigate.navigateTo(browsingContext, "about:blank"); + }); + } else { + lazy.logger.debug("Using separate window"); + if (this.reftestWin && !this.reftestWin.closed) { + this.reftestWin.close(); + } + reftestWin = await this.openWindow(width, height); + } + + this.setupWindow(reftestWin, width, height); + this.windowUtils = reftestWin.windowUtils; + this.reftestWin = reftestWin; + + let windowHandle = lazy.windowManager.getWindowProperties(reftestWin); + await this.driver.setWindowHandle(windowHandle, true); + + const url = await this.driver._getCurrentURL(); + this.lastURL = url.href; + lazy.logger.debug(`loaded initial URL: ${this.lastURL}`); + + let browserRect = reftestWin.gBrowser.getBoundingClientRect(); + lazy.logger.debug(`new: ${browserRect.width}x${browserRect.height}`); + + return reftestWin; + } + + async openWindow(width, height) { + lazy.assert.positiveInteger(width); + lazy.assert.positiveInteger(height); + + let reftestWin = this.parentWindow.open( + "chrome://remote/content/marionette/reftest.xhtml", + "reftest", + `chrome,height=${height},width=${width}` + ); + + await new Promise(resolve => { + reftestWin.addEventListener("load", resolve, { once: true }); + }); + return reftestWin; + } + + setupWindow(reftestWin, width, height) { + let browser; + if (lazy.AppInfo.isAndroid) { + browser = reftestWin.document.getElementsByTagName("browser")[0]; + browser.setAttribute("remote", "false"); + } else { + browser = reftestWin.document.createElementNS(XUL_NS, "xul:browser"); + browser.permanentKey = {}; + browser.setAttribute("id", "browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("primary", "true"); + browser.setAttribute("remote", this.useRemoteTabs ? "true" : "false"); + } + // Make sure the browser element is exactly the right size, no matter + // what size our window is + const windowStyle = ` + padding: 0px; + margin: 0px; + border:none; + min-width: ${width}px; min-height: ${height}px; + max-width: ${width}px; max-height: ${height}px; + color-scheme: env(-moz-content-preferred-color-scheme); + `; + browser.setAttribute("style", windowStyle); + + if (!lazy.AppInfo.isAndroid) { + let doc = reftestWin.document.documentElement; + while (doc.firstChild) { + doc.firstChild.remove(); + } + doc.appendChild(browser); + } + if (reftestWin.BrowserApp) { + reftestWin.BrowserApp = browser; + } + reftestWin.gBrowser = browser; + return reftestWin; + } + + async abort() { + if (this.reftestWin && this.reftestWin != this.parentWindow) { + await this.driver.closeChromeWindow(); + let parentHandle = lazy.windowManager.getWindowProperties( + this.parentWindow + ); + await this.driver.setWindowHandle(parentHandle); + } + this.reftestWin = null; + } + + /** + * Run a specific reftest. + * + * The assumed semantics are those of web-platform-tests where + * references form a tree and each test must meet all the conditions + * to reach one leaf node of the tree in order for the overall test + * to pass. + * + * @param {string} testUrl + * URL of the test itself. + * @param {Array.<Array>} references + * Array representing a tree of references to try. + * + * Each item in the array represents a single reference node and + * has the form <code>[referenceUrl, references, relation]</code>, + * where <var>referenceUrl</var> is a string to the URL, relation + * is either <code>==</code> or <code>!=</code> depending on the + * type of reftest, and references is another array containing + * items of the same form, representing further comparisons treated + * as AND with the current item. Sibling entries are treated as OR. + * + * For example with testUrl of T: + * + * <pre><code> + * references = [[A, [[B, [], ==]], ==]] + * Must have T == A AND A == B to pass + * + * references = [[A, [], ==], [B, [], !=] + * Must have T == A OR T != B + * + * references = [[A, [[B, [], ==], [C, [], ==]], ==], [D, [], ]] + * Must have (T == A AND A == B) OR (T == A AND A == C) OR (T == D) + * </code></pre> + * + * @param {string} expected + * Expected test outcome (e.g. <tt>PASS</tt>, <tt>FAIL</tt>). + * @param {number} timeout + * Test timeout in milliseconds. + * + * @returns {object} + * Result object with fields status, message and extra. + */ + async run( + testUrl, + references, + expected, + timeout, + pageRanges = {}, + width = DEFAULT_REFTEST_WIDTH, + height = DEFAULT_REFTEST_HEIGHT + ) { + let timerId; + + let timeoutPromise = new Promise(resolve => { + timerId = lazy.setTimeout(() => { + resolve({ status: STATUS.TIMEOUT, message: null, extra: {} }); + }, timeout); + }); + + let testRunner = (async () => { + let result; + try { + result = await this.runTest( + testUrl, + references, + expected, + timeout, + pageRanges, + width, + height + ); + } catch (e) { + result = { + status: STATUS.ERROR, + message: String(e), + stack: e.stack, + extra: {}, + }; + } + return result; + })(); + + let result = await Promise.race([testRunner, timeoutPromise]); + lazy.clearTimeout(timerId); + if (result.status === STATUS.TIMEOUT) { + await this.abort(); + } + + return result; + } + + async runTest( + testUrl, + references, + expected, + timeout, + pageRanges, + width, + height + ) { + let win = await this.ensureWindow(timeout, width, height); + + function toBase64(screenshot) { + let dataURL = screenshot.canvas.toDataURL(); + return dataURL.split(",")[1]; + } + + let result = { + status: STATUS.FAIL, + message: "", + stack: null, + extra: {}, + }; + + let screenshotData = []; + + let stack = []; + for (let i = references.length - 1; i >= 0; i--) { + let item = references[i]; + stack.push([testUrl, ...item]); + } + + let done = false; + + while (stack.length && !done) { + let [lhsUrl, rhsUrl, references, relation, extras = {}] = stack.pop(); + result.message += `Testing ${lhsUrl} ${relation} ${rhsUrl}\n`; + + let comparison; + try { + comparison = await this.compareUrls( + win, + lhsUrl, + rhsUrl, + relation, + timeout, + pageRanges, + extras + ); + } catch (e) { + comparison = { + lhs: null, + rhs: null, + passed: false, + error: e, + msg: null, + }; + } + if (comparison.msg) { + result.message += `${comparison.msg}\n`; + } + if (comparison.error !== null) { + result.status = STATUS.ERROR; + result.message += String(comparison.error); + result.stack = comparison.error.stack; + } + + function recordScreenshot() { + let encodedLHS = comparison.lhs ? toBase64(comparison.lhs) : ""; + let encodedRHS = comparison.rhs ? toBase64(comparison.rhs) : ""; + screenshotData.push([ + { url: lhsUrl, screenshot: encodedLHS }, + relation, + { url: rhsUrl, screenshot: encodedRHS }, + ]); + } + + if (this.screenshotMode === SCREENSHOT_MODE.always) { + recordScreenshot(); + } + + if (comparison.passed) { + if (references.length) { + for (let i = references.length - 1; i >= 0; i--) { + let item = references[i]; + stack.push([rhsUrl, ...item]); + } + } else { + // Reached a leaf node so all of one reference chain passed + result.status = STATUS.PASS; + if ( + this.screenshotMode <= SCREENSHOT_MODE.fail && + expected != result.status + ) { + recordScreenshot(); + } + done = true; + } + } else if (!stack.length || result.status == STATUS.ERROR) { + // If we don't have any alternatives to try then this will be + // the last iteration, so save the failing screenshots if required. + let isFail = this.screenshotMode === SCREENSHOT_MODE.fail; + let isUnexpected = this.screenshotMode === SCREENSHOT_MODE.unexpected; + if (isFail || (isUnexpected && expected != result.status)) { + recordScreenshot(); + } + } + + // Return any reusable canvases to the pool + let cacheKey = width + "x" + height; + let canvasPool = this.canvasCache.get(cacheKey).get(null); + [comparison.lhs, comparison.rhs].map(screenshot => { + if (screenshot !== null && screenshot.reuseCanvas) { + canvasPool.push(screenshot.canvas); + } + }); + lazy.logger.debug( + `Canvas pool (${cacheKey}) is of length ${canvasPool.length}` + ); + } + + if (screenshotData.length) { + // For now the tbpl formatter only accepts one screenshot, so just + // return the last one we took. + let lastScreenshot = screenshotData[screenshotData.length - 1]; + // eslint-disable-next-line camelcase + result.extra.reftest_screenshots = lastScreenshot; + } + + return result; + } + + async compareUrls( + win, + lhsUrl, + rhsUrl, + relation, + timeout, + pageRanges, + extras + ) { + lazy.logger.info(`Testing ${lhsUrl} ${relation} ${rhsUrl}`); + + if (relation !== "==" && relation != "!=") { + throw new error.InvalidArgumentError( + "Reftest operator should be '==' or '!='" + ); + } + + let lhsIter, lhsCount, rhsIter, rhsCount; + if (!this.isPrint) { + // Take the reference screenshot first so that if we pause + // we see the test rendering + rhsIter = [await this.screenshot(win, rhsUrl, timeout)].values(); + lhsIter = [await this.screenshot(win, lhsUrl, timeout)].values(); + lhsCount = rhsCount = 1; + } else { + [rhsIter, rhsCount] = await this.screenshotPaginated( + win, + rhsUrl, + timeout, + pageRanges + ); + [lhsIter, lhsCount] = await this.screenshotPaginated( + win, + lhsUrl, + timeout, + pageRanges + ); + } + + let passed = null; + let error = null; + let pixelsDifferent = null; + let maxDifferences = {}; + let msg = null; + + if (lhsCount != rhsCount) { + passed = relation == "!="; + if (!passed) { + msg = `Got different numbers of pages; test has ${lhsCount}, ref has ${rhsCount}`; + } + } + + let lhs = null; + let rhs = null; + lazy.logger.debug(`Comparing ${lhsCount} pages`); + if (passed === null) { + for (let i = 0; i < lhsCount; i++) { + lhs = (await lhsIter.next()).value; + rhs = (await rhsIter.next()).value; + lazy.logger.debug( + `lhs canvas size ${lhs.canvas.width}x${lhs.canvas.height}` + ); + lazy.logger.debug( + `rhs canvas size ${rhs.canvas.width}x${rhs.canvas.height}` + ); + if ( + lhs.canvas.width != rhs.canvas.width || + lhs.canvas.height != rhs.canvas.height + ) { + msg = + `Got different page sizes; test is ` + + `${lhs.canvas.width}x${lhs.canvas.height}px, ref is ` + + `${rhs.canvas.width}x${rhs.canvas.height}px`; + passed = false; + break; + } + try { + pixelsDifferent = this.windowUtils.compareCanvases( + lhs.canvas, + rhs.canvas, + maxDifferences + ); + } catch (e) { + error = e; + passed = false; + break; + } + + let areEqual = this.isAcceptableDifference( + maxDifferences.value, + pixelsDifferent, + extras.fuzzy + ); + lazy.logger.debug( + `Page ${i + 1} maxDifferences: ${maxDifferences.value} ` + + `pixelsDifferent: ${pixelsDifferent}` + ); + lazy.logger.debug( + `Page ${i + 1} ${areEqual ? "compare equal" : "compare unequal"}` + ); + if (!areEqual) { + if (relation == "==") { + passed = false; + msg = + `Found ${pixelsDifferent} pixels different, ` + + `maximum difference per channel ${maxDifferences.value}`; + if (this.isPrint) { + msg += ` on page ${i + 1}`; + } + } else { + passed = true; + } + break; + } + } + } + + // If passed isn't set we got to the end without finding differences + if (passed === null) { + if (relation == "==") { + passed = true; + } else { + msg = `mismatch reftest has no differences`; + passed = false; + } + } + return { lhs, rhs, passed, error, msg }; + } + + isAcceptableDifference(maxDifference, pixelsDifferent, allowed) { + if (!allowed) { + lazy.logger.info(`No differences allowed`); + return pixelsDifferent === 0; + } + let [allowedDiff, allowedPixels] = allowed; + lazy.logger.info( + `Allowed ${allowedPixels.join("-")} pixels different, ` + + `maximum difference per channel ${allowedDiff.join("-")}` + ); + return ( + (pixelsDifferent === 0 && allowedPixels[0] == 0) || + (maxDifference === 0 && allowedDiff[0] == 0) || + (maxDifference >= allowedDiff[0] && + maxDifference <= allowedDiff[1] && + (pixelsDifferent >= allowedPixels[0] || + pixelsDifferent <= allowedPixels[1])) + ); + } + + ensureFocus(win) { + const focusManager = Services.focus; + if (focusManager.activeWindow != win) { + win.focus(); + } + this.driver.curBrowser.contentBrowser.focus(); + } + + updateBrowserRemotenessByURL(browser, url) { + // We don't use remote tabs on Android. + if (lazy.AppInfo.isAndroid) { + return; + } + let oa = lazy.E10SUtils.predictOriginAttributes({ browser }); + let remoteType = lazy.E10SUtils.getRemoteTypeForURI( + url, + this.useRemoteTabs, + this.useRemoteSubframes, + lazy.E10SUtils.DEFAULT_REMOTE_TYPE, + null, + oa + ); + + // Only re-construct the browser if its remote type needs to change. + if (browser.remoteType !== remoteType) { + if (remoteType === lazy.E10SUtils.NOT_REMOTE) { + browser.removeAttribute("remote"); + browser.removeAttribute("remoteType"); + } else { + browser.setAttribute("remote", "true"); + browser.setAttribute("remoteType", remoteType); + } + + browser.changeRemoteness({ remoteType }); + browser.construct(); + } + } + + async loadTestUrl(win, url, timeout, warnOnOverflow = true) { + const browsingContext = this.driver.getBrowsingContext({ top: true }); + const webProgress = browsingContext.webProgress; + + lazy.logger.debug(`Starting load of ${url}`); + if (this.lastURL === url) { + lazy.logger.debug(`Refreshing page`); + await lazy.navigate.waitForNavigationCompleted(this.driver, () => { + lazy.navigate.refresh(browsingContext); + }); + } else { + // HACK: DocumentLoadListener currently doesn't know how to + // process-switch loads in a non-tabbed <browser>. We need to manually + // set the browser's remote type in order to ensure that the load + // happens in the correct process. + // + // See bug 1636169. + this.updateBrowserRemotenessByURL(win.gBrowser, url); + lazy.navigate.navigateTo(browsingContext, url); + + this.lastURL = url; + } + + this.ensureFocus(win); + + // TODO: Move all the wait logic into the parent process (bug 1669787) + let isReftestReady = false; + while (!isReftestReady) { + // Note: We cannot compare the URL here. Before the navigation is complete + // currentWindowGlobal.documentURI.spec will still point to the old URL. + const actor = + webProgress.browsingContext.currentWindowGlobal.getActor( + "MarionetteReftest" + ); + isReftestReady = await actor.reftestWait( + url, + this.useRemoteTabs, + warnOnOverflow + ); + } + } + + async screenshot(win, url, timeout) { + // On windows the above doesn't *actually* set the window to be the + // reftest size; but *does* set the content area to be the right size; + // the window is given some extra borders that aren't explicable from CSS + let browserRect = win.gBrowser.getBoundingClientRect(); + let canvas = null; + let remainingCount = this.urlCount.get(url) || 1; + let cache = remainingCount > 1; + let cacheKey = browserRect.width + "x" + browserRect.height; + lazy.logger.debug( + `screenshot ${url} remainingCount: ` + + `${remainingCount} cache: ${cache} cacheKey: ${cacheKey}` + ); + let reuseCanvas = false; + let sizedCache = this.canvasCache.get(cacheKey); + if (sizedCache.has(url)) { + lazy.logger.debug(`screenshot ${url} taken from cache`); + canvas = sizedCache.get(url); + if (!cache) { + sizedCache.delete(url); + } + } else { + let canvasPool = sizedCache.get(null); + if (canvasPool.length) { + lazy.logger.debug("reusing canvas from canvas pool"); + canvas = canvasPool.pop(); + } else { + lazy.logger.debug("using new canvas"); + canvas = null; + } + reuseCanvas = !cache; + + let ctxInterface = win.CanvasRenderingContext2D; + let flags = + ctxInterface.DRAWWINDOW_DRAW_CARET | + ctxInterface.DRAWWINDOW_DRAW_VIEW | + ctxInterface.DRAWWINDOW_USE_WIDGET_LAYERS; + + if ( + !( + 0 <= browserRect.left && + 0 <= browserRect.top && + win.innerWidth >= browserRect.width && + win.innerHeight >= browserRect.height + ) + ) { + lazy.logger.error(`Invalid window dimensions: +browserRect.left: ${browserRect.left} +browserRect.top: ${browserRect.top} +win.innerWidth: ${win.innerWidth} +browserRect.width: ${browserRect.width} +win.innerHeight: ${win.innerHeight} +browserRect.height: ${browserRect.height}`); + throw new Error("Window has incorrect dimensions"); + } + + url = new URL(url).href; // normalize the URL + + await this.loadTestUrl(win, url, timeout); + + canvas = await lazy.capture.canvas( + win, + win.docShell.browsingContext, + 0, // left + 0, // top + browserRect.width, + browserRect.height, + { canvas, flags, readback: true } + ); + } + if ( + canvas.width !== browserRect.width || + canvas.height !== browserRect.height + ) { + lazy.logger.warn( + `Canvas dimensions changed to ${canvas.width}x${canvas.height}` + ); + reuseCanvas = false; + cache = false; + } + if (cache) { + sizedCache.set(url, canvas); + } + this.urlCount.set(url, remainingCount - 1); + return { canvas, reuseCanvas }; + } + + async screenshotPaginated(win, url, timeout, pageRanges) { + url = new URL(url).href; // normalize the URL + await this.loadTestUrl(win, url, timeout, false); + + const [width, height] = [DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT]; + const margin = DEFAULT_PAGE_MARGIN; + const settings = lazy.print.addDefaultSettings({ + page: { + width, + height, + }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: margin, + }, + shrinkToFit: false, + background: true, + }); + const printSettings = lazy.print.getPrintSettings(settings); + + const binaryString = await lazy.print.printToBinaryString( + win.gBrowser.browsingContext, + printSettings + ); + + try { + const pdf = await this.loadPdf(binaryString); + let pages = this.getPages(pageRanges, url, pdf.numPages); + return [this.renderPages(pdf, pages), pages.size]; + } catch (e) { + lazy.logger.warn(`Loading of pdf failed`); + throw e; + } + } + + async loadPdfJs() { + // Ensure pdf.js is loaded in the opener window + await new Promise((resolve, reject) => { + const doc = this.parentWindow.document; + const script = doc.createElement("script"); + script.type = "module"; + script.src = "resource://pdf.js/build/pdf.mjs"; + script.onload = resolve; + script.onerror = () => reject(new Error("pdfjs load failed")); + doc.documentElement.appendChild(script); + }); + this.parentWindow.pdfjsLib.GlobalWorkerOptions.workerSrc = + "resource://pdf.js/build/pdf.worker.mjs"; + } + + async loadPdf(data) { + return this.parentWindow.pdfjsLib.getDocument({ data }).promise; + } + + async *renderPages(pdf, pages) { + let canvas = null; + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) { + if (!pages.has(pageNumber)) { + lazy.logger.info(`Skipping page ${pageNumber}/${pdf.numPages}`); + continue; + } + lazy.logger.info(`Rendering page ${pageNumber}/${pdf.numPages}`); + let page = await pdf.getPage(pageNumber); + let viewport = page.getViewport({ scale: DEFAULT_PDF_RESOLUTION }); + // Prepare canvas using PDF page dimensions + if (canvas === null) { + canvas = this.parentWindow.document.createElementNS(XHTML_NS, "canvas"); + canvas.height = viewport.height; + canvas.width = viewport.width; + } + + // Render PDF page into canvas context + let context = canvas.getContext("2d"); + let renderContext = { + canvasContext: context, + viewport, + }; + await page.render(renderContext).promise; + yield { canvas, reuseCanvas: false }; + } + } + + getPages(pageRanges, url, totalPages) { + // Extract test id from URL without parsing + let afterHost = url.slice(url.indexOf(":") + 3); + afterHost = afterHost.slice(afterHost.indexOf("/")); + const ranges = pageRanges[afterHost]; + let rv = new Set(); + + if (!ranges) { + for (let i = 1; i <= totalPages; i++) { + rv.add(i); + } + return rv; + } + + for (let rangePart of ranges) { + if (rangePart.length === 1) { + rv.add(rangePart[0]); + } else { + if (rangePart.length !== 2) { + throw new Error( + `Page ranges must be <int> or <int> '-' <int>, got ${rangePart}` + ); + } + let [lower, upper] = rangePart; + if (lower === null) { + lower = 1; + } + if (upper === null) { + upper = totalPages; + } + for (let i = lower; i <= upper; i++) { + rv.add(i); + } + } + } + return rv; + } +}; + +class DefaultMap extends Map { + constructor(iterable, defaultFactory) { + super(iterable); + this.defaultFactory = defaultFactory; + } + + get(key) { + if (this.has(key)) { + return super.get(key); + } + + let v = this.defaultFactory(); + this.set(key, v); + return v; + } +} diff --git a/remote/marionette/server.sys.mjs b/remote/marionette/server.sys.mjs new file mode 100644 index 0000000000..36e7a9d639 --- /dev/null +++ b/remote/marionette/server.sys.mjs @@ -0,0 +1,460 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + Command: "chrome://remote/content/marionette/message.sys.mjs", + DebuggerTransport: "chrome://remote/content/marionette/transport.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + GeckoDriver: "chrome://remote/content/marionette/driver.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs", + Message: "chrome://remote/content/marionette/message.sys.mjs", + PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", + Response: "chrome://remote/content/marionette/message.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); +ChromeUtils.defineLazyGetter(lazy, "ServerSocket", () => { + return Components.Constructor( + "@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "initSpecialConnection" + ); +}); + +const { KeepWhenOffline, LoopbackOnly } = Ci.nsIServerSocket; + +const PROTOCOL_VERSION = 3; + +/** + * Bootstraps Marionette and handles incoming client connections. + * + * Starting the Marionette server will open a TCP socket sporting the + * debugger transport interface on the provided `port`. For every + * new connection, a {@link TCPConnection} is created. + */ +export class TCPListener { + /** + * @param {number} port + * Port for server to listen to. + */ + constructor(port) { + this.port = port; + this.socket = null; + this.conns = new Set(); + this.nextConnID = 0; + this.alive = false; + } + + /** + * Function produces a {@link GeckoDriver}. + * + * Determines the application to initialise the driver with. + * + * @returns {GeckoDriver} + * A driver instance. + */ + driverFactory() { + return new lazy.GeckoDriver(this); + } + + async setAcceptConnections(value) { + if (value) { + if (!this.socket) { + await lazy.PollPromise( + (resolve, reject) => { + try { + const flags = KeepWhenOffline | LoopbackOnly; + const backlog = 1; + this.socket = new lazy.ServerSocket(this.port, flags, backlog); + resolve(); + } catch (e) { + lazy.logger.debug( + `Could not bind to port ${this.port} (${e.name})` + ); + reject(); + } + }, + { interval: 250, timeout: 5000 } + ); + + // Since PollPromise doesn't throw when timeout expires, + // we can end up in the situation when the socket is undefined. + if (!this.socket) { + throw new Error(`Could not bind to port ${this.port}`); + } + + this.port = this.socket.port; + + this.socket.asyncListen(this); + lazy.logger.info(`Listening on port ${this.port}`); + } + } else if (this.socket) { + // Note that closing the server socket will not close currently active + // connections. + this.socket.close(); + this.socket = null; + lazy.logger.info(`Stopped listening on port ${this.port}`); + } + } + + /** + * Bind this listener to {@link #port} and start accepting incoming + * socket connections on {@link #onSocketAccepted}. + * + * The marionette.port preference will be populated with the value + * of {@link #port}. + */ + async start() { + if (this.alive) { + return; + } + + // Start socket server and listening for connection attempts + await this.setAcceptConnections(true); + lazy.MarionettePrefs.port = this.port; + this.alive = true; + } + + async stop() { + if (!this.alive) { + return; + } + + // Shutdown server socket, and no longer listen for new connections + await this.setAcceptConnections(false); + this.alive = false; + } + + onSocketAccepted(serverSocket, clientSocket) { + let input = clientSocket.openInputStream(0, 0, 0); + let output = clientSocket.openOutputStream(0, 0, 0); + let transport = new lazy.DebuggerTransport(input, output); + + // Only allow a single active WebDriver session at a time + const hasActiveSession = [...this.conns].find( + conn => !!conn.driver.currentSession + ); + if (hasActiveSession) { + lazy.logger.warn( + "Connection attempt denied because an active session has been found" + ); + + // Ideally we should stop the server to listen for new connection + // attempts, but the current architecture doesn't allow us to do that. + // As such just close the transport if no further connections are allowed. + transport.close(); + return; + } + + let conn = new TCPConnection( + this.nextConnID++, + transport, + this.driverFactory.bind(this) + ); + conn.onclose = this.onConnectionClosed.bind(this); + this.conns.add(conn); + + lazy.logger.debug( + `Accepted connection ${conn.id} ` + + `from ${clientSocket.host}:${clientSocket.port}` + ); + conn.sayHello(); + transport.ready(); + } + + onConnectionClosed(conn) { + lazy.logger.debug(`Closed connection ${conn.id}`); + this.conns.delete(conn); + } +} + +/** + * Marionette client connection. + * + * Dispatches packets received to their correct service destinations + * and sends back the service endpoint's return values. + * + * @param {number} connID + * Unique identifier of the connection this dispatcher should handle. + * @param {DebuggerTransport} transport + * Debugger transport connection to the client. + * @param {function(): GeckoDriver} driverFactory + * Factory function that produces a {@link GeckoDriver}. + */ +export class TCPConnection { + constructor(connID, transport, driverFactory) { + this.id = connID; + this.conn = transport; + + // transport hooks are TCPConnection#onPacket + // and TCPConnection#onClosed + this.conn.hooks = this; + + // callback for when connection is closed + this.onclose = null; + + // last received/sent message ID + this.lastID = 0; + + this.driver = driverFactory(); + } + + #log(msg) { + let dir = msg.origin == lazy.Message.Origin.Client ? "->" : "<-"; + lazy.logger.debug(`${this.id} ${dir} ${msg.toString()}`); + } + + /** + * Debugger transport callback that cleans up + * after a connection is closed. + */ + onClosed() { + this.driver.deleteSession(); + if (this.onclose) { + this.onclose(this); + } + } + + /** + * Callback that receives data packets from the client. + * + * If the message is a Response, we look up the command previously + * issued to the client and run its callback, if any. In case of + * a Command, the corresponding is executed. + * + * @param {Array.<number, number, ?, ?>} data + * A four element array where the elements, in sequence, signifies + * message type, message ID, method name or error, and parameters + * or result. + */ + onPacket(data) { + // unable to determine how to respond + if (!Array.isArray(data)) { + let e = new TypeError( + "Unable to unmarshal packet data: " + JSON.stringify(data) + ); + lazy.error.report(e); + return; + } + + // return immediately with any error trying to unmarshal message + let msg; + try { + msg = lazy.Message.fromPacket(data); + msg.origin = lazy.Message.Origin.Client; + this.#log(msg); + } catch (e) { + let resp = this.createResponse(data[1]); + resp.sendError(e); + return; + } + + // execute new command + if (msg instanceof lazy.Command) { + (async () => { + await this.execute(msg); + })(); + } else { + lazy.logger.fatal("Cannot process messages other than Command"); + } + } + + /** + * Executes a Marionette command and sends back a response when it + * has finished executing. + * + * If the command implementation sends the response itself by calling + * <code>resp.send()</code>, the response is guaranteed to not be + * sent twice. + * + * Errors thrown in commands are marshaled and sent back, and if they + * are not {@link WebDriverError} instances, they are additionally + * propagated and reported to {@link Components.utils.reportError}. + * + * @param {Command} cmd + * Command to execute. + */ + async execute(cmd) { + let resp = this.createResponse(cmd.id); + let sendResponse = () => resp.sendConditionally(resp => !resp.sent); + let sendError = resp.sendError.bind(resp); + + await this.despatch(cmd, resp) + .then(sendResponse, sendError) + .catch(lazy.error.report); + } + + /** + * Despatches command to appropriate Marionette service. + * + * @param {Command} cmd + * Command to run. + * @param {Response} resp + * Mutable response where the command's return value will be + * assigned. + * + * @throws {Error} + * A command's implementation may throw at any time. + */ + async despatch(cmd, resp) { + const startTime = Cu.now(); + + let fn = this.driver.commands[cmd.name]; + if (typeof fn == "undefined") { + throw new lazy.error.UnknownCommandError(cmd.name); + } + + if (cmd.name != "WebDriver:NewSession") { + lazy.assert.session(this.driver.currentSession); + } + + let rv = await fn.bind(this.driver)(cmd); + + // Bug 1819029: Some older commands cannot return a response wrapped within + // a value field because it would break compatibility with geckodriver and + // Marionette client. It's unlikely that we are going to fix that. + // + // Warning: No more commands should be added to this list! + const commandsNoValueResponse = [ + "Marionette:Quit", + "WebDriver:FindElements", + "WebDriver:FindElementsFromShadowRoot", + "WebDriver:CloseChromeWindow", + "WebDriver:CloseWindow", + "WebDriver:FullscreenWindow", + "WebDriver:GetCookies", + "WebDriver:GetElementRect", + "WebDriver:GetTimeouts", + "WebDriver:GetWindowHandles", + "WebDriver:GetWindowRect", + "WebDriver:MaximizeWindow", + "WebDriver:MinimizeWindow", + "WebDriver:NewSession", + "WebDriver:NewWindow", + "WebDriver:SetWindowRect", + ]; + + if (rv != null) { + // By default the Response' constructor sets the body to `{ value: null }`. + // As such we only want to override the value if it's neither `null` nor + // `undefined`. + if (commandsNoValueResponse.includes(cmd.name)) { + resp.body = rv; + } else { + resp.body.value = rv; + } + } + + if (Services.profiler?.IsActive()) { + ChromeUtils.addProfilerMarker( + "Marionette: Command", + { startTime, category: "Remote-Protocol" }, + `${cmd.name} (${cmd.id})` + ); + } + } + + /** + * Fail-safe creation of a new instance of {@link Response}. + * + * @param {number} msgID + * Message ID to respond to. If it is not a number, -1 is used. + * + * @returns {Response} + * Response to the message with `msgID`. + */ + createResponse(msgID) { + if (typeof msgID != "number") { + msgID = -1; + } + return new lazy.Response(msgID, this.send.bind(this)); + } + + sendError(err, cmdID) { + let resp = new lazy.Response(cmdID, this.send.bind(this)); + resp.sendError(err); + } + + /** + * When a client connects we send across a JSON Object defining the + * protocol level. + * + * This is the only message sent by Marionette that does not follow + * the regular message format. + */ + sayHello() { + let whatHo = { + applicationType: "gecko", + marionetteProtocol: PROTOCOL_VERSION, + }; + this.sendRaw(whatHo); + } + + /** + * Delegates message to client based on the provided {@code cmdID}. + * The message is sent over the debugger transport socket. + * + * The command ID is a unique identifier assigned to the client's request + * that is used to distinguish the asynchronous responses. + * + * Whilst responses to commands are synchronous and must be sent in the + * correct order. + * + * @param {Message} msg + * The command or response to send. + */ + send(msg) { + msg.origin = lazy.Message.Origin.Server; + if (msg instanceof lazy.Response) { + this.sendToClient(msg); + } else { + lazy.logger.fatal("Cannot send messages other than Response"); + } + } + + // Low-level methods: + + /** + * Send given response to the client over the debugger transport socket. + * + * @param {Response} resp + * The response to send back to the client. + */ + sendToClient(resp) { + this.sendMessage(resp); + } + + /** + * Marshal message to the Marionette message format and send it. + * + * @param {Message} msg + * The message to send. + */ + sendMessage(msg) { + this.#log(msg); + let payload = msg.toPacket(); + this.sendRaw(payload); + } + + /** + * Send the given payload over the debugger transport socket to the + * connected client. + * + * @param {Object<string, ?>} payload + * The payload to ship. + */ + sendRaw(payload) { + this.conn.send(payload); + } + + toString() { + return `[object TCPConnection ${this.id}]`; + } +} diff --git a/remote/marionette/stream-utils.sys.mjs b/remote/marionette/stream-utils.sys.mjs new file mode 100644 index 0000000000..5979280660 --- /dev/null +++ b/remote/marionette/stream-utils.sys.mjs @@ -0,0 +1,256 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "IOUtil", + "@mozilla.org/io-util;1", + "nsIIOUtil" +); + +ChromeUtils.defineLazyGetter(lazy, "ScriptableInputStream", () => { + return Components.Constructor( + "@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init" + ); +}); + +const BUFFER_SIZE = 0x8000; + +/** + * This helper function (and its companion object) are used by bulk + * senders and receivers to read and write data in and out of other streams. + * Functions that make use of this tool are passed to callers when it is + * time to read or write bulk data. It is highly recommended to use these + * copier functions instead of the stream directly because the copier + * enforces the agreed upon length. Since bulk mode reuses an existing + * stream, the sender and receiver must write and read exactly the agreed + * upon amount of data, or else the entire transport will be left in a + * invalid state. Additionally, other methods of stream copying (such as + * NetUtil.asyncCopy) close the streams involved, which would terminate + * the debugging transport, and so it is avoided here. + * + * Overall, this *works*, but clearly the optimal solution would be + * able to just use the streams directly. If it were possible to fully + * implement nsIInputStream/nsIOutputStream in JS, wrapper streams could + * be created to enforce the length and avoid closing, and consumers could + * use familiar stream utilities like NetUtil.asyncCopy. + * + * The function takes two async streams and copies a precise number + * of bytes from one to the other. Copying begins immediately, but may + * complete at some future time depending on data size. Use the returned + * promise to know when it's complete. + * + * @param {nsIAsyncInputStream} input + * Stream to copy from. + * @param {nsIAsyncOutputStream} output + * Stream to copy to. + * @param {number} length + * Amount of data that needs to be copied. + * + * @returns {Promise} + * Promise is resolved when copying completes or rejected if any + * (unexpected) errors occur. + */ +function copyStream(input, output, length) { + let copier = new StreamCopier(input, output, length); + return copier.copy(); +} + +/** @class */ +function StreamCopier(input, output, length) { + lazy.EventEmitter.decorate(this); + this._id = StreamCopier._nextId++; + this.input = input; + // Save off the base output stream, since we know it's async as we've + // required + this.baseAsyncOutput = output; + if (lazy.IOUtil.outputStreamIsBuffered(output)) { + this.output = output; + } else { + this.output = Cc[ + "@mozilla.org/network/buffered-output-stream;1" + ].createInstance(Ci.nsIBufferedOutputStream); + this.output.init(output, BUFFER_SIZE); + } + this._length = length; + this._amountLeft = length; + this._deferred = { + promise: new Promise((resolve, reject) => { + this._deferred.resolve = resolve; + this._deferred.reject = reject; + }), + }; + + this._copy = this._copy.bind(this); + this._flush = this._flush.bind(this); + this._destroy = this._destroy.bind(this); + + // Copy promise's then method up to this object. + // + // Allows the copier to offer a promise interface for the simple succeed + // or fail scenarios, but also emit events (due to the EventEmitter) + // for other states, like progress. + this.then = this._deferred.promise.then.bind(this._deferred.promise); + this.then(this._destroy, this._destroy); + + // Stream ready callback starts as |_copy|, but may switch to |_flush| + // at end if flushing would block the output stream. + this._streamReadyCallback = this._copy; +} +StreamCopier._nextId = 0; + +StreamCopier.prototype = { + copy() { + // Dispatch to the next tick so that it's possible to attach a progress + // event listener, even for extremely fast copies (like when testing). + Services.tm.currentThread.dispatch(() => { + try { + this._copy(); + } catch (e) { + this._deferred.reject(e); + } + }, 0); + return this; + }, + + _copy() { + let bytesAvailable = this.input.available(); + let amountToCopy = Math.min(bytesAvailable, this._amountLeft); + this._debug("Trying to copy: " + amountToCopy); + + let bytesCopied; + try { + bytesCopied = this.output.writeFrom(this.input, amountToCopy); + } catch (e) { + if (e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this._debug("Base stream would block, will retry"); + this._debug("Waiting for output stream"); + this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread); + return; + } + throw e; + } + + this._amountLeft -= bytesCopied; + this._debug("Copied: " + bytesCopied + ", Left: " + this._amountLeft); + this._emitProgress(); + + if (this._amountLeft === 0) { + this._debug("Copy done!"); + this._flush(); + return; + } + + this._debug("Waiting for input stream"); + this.input.asyncWait(this, 0, 0, Services.tm.currentThread); + }, + + _emitProgress() { + this.emit("progress", { + bytesSent: this._length - this._amountLeft, + totalBytes: this._length, + }); + }, + + _flush() { + try { + this.output.flush(); + } catch (e) { + if ( + e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK || + e.result == Cr.NS_ERROR_FAILURE + ) { + this._debug("Flush would block, will retry"); + this._streamReadyCallback = this._flush; + this._debug("Waiting for output stream"); + this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread); + return; + } + throw e; + } + this._deferred.resolve(); + }, + + _destroy() { + this._destroy = null; + this._copy = null; + this._flush = null; + this.input = null; + this.output = null; + }, + + // nsIInputStreamCallback + onInputStreamReady() { + this._streamReadyCallback(); + }, + + // nsIOutputStreamCallback + onOutputStreamReady() { + this._streamReadyCallback(); + }, + + _debug() {}, +}; + +/** + * Read from a stream, one byte at a time, up to the next + * <var>delimiter</var> character, but stopping if we've read |count| + * without finding it. Reading also terminates early if there are less + * than <var>count</var> bytes available on the stream. In that case, + * we only read as many bytes as the stream currently has to offer. + * + * @param {nsIInputStream} stream + * Input stream to read from. + * @param {string} delimiter + * Character we're trying to find. + * @param {number} count + * Max number of characters to read while searching. + * + * @returns {string} + * Collected data. If the delimiter was found, this string will + * end with it. + */ +// TODO: This implementation could be removed if bug 984651 is fixed, +// which provides a native version of the same idea. +function delimitedRead(stream, delimiter, count) { + let scriptableStream; + if (stream instanceof Ci.nsIScriptableInputStream) { + scriptableStream = stream; + } else { + scriptableStream = new lazy.ScriptableInputStream(stream); + } + + let data = ""; + + // Don't exceed what's available on the stream + count = Math.min(count, stream.available()); + + if (count <= 0) { + return data; + } + + let char; + while (char !== delimiter && count > 0) { + char = scriptableStream.readBytes(1); + count--; + data += char; + } + + return data; +} + +export const StreamUtils = { + copyStream, + delimitedRead, +}; diff --git a/remote/marionette/sync.sys.mjs b/remote/marionette/sync.sys.mjs new file mode 100644 index 0000000000..284f5ce729 --- /dev/null +++ b/remote/marionette/sync.sys.mjs @@ -0,0 +1,538 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer; + +const PROMISE_TIMEOUT = AppConstants.DEBUG ? 4500 : 1500; + +/** + * Dispatch a function to be executed on the main thread. + * + * @param {Function} func + * Function to be executed. + */ +export function executeSoon(func) { + if (typeof func != "function") { + throw new TypeError(); + } + + Services.tm.dispatchToMainThread(func); +} + +/** + * Runs a Promise-like function off the main thread until it is resolved + * through ``resolve`` or ``rejected`` callbacks. The function is + * guaranteed to be run at least once, irregardless of the timeout. + * + * The ``func`` is evaluated every ``interval`` for as long as its + * runtime duration does not exceed ``interval``. Evaluations occur + * sequentially, meaning that evaluations of ``func`` are queued if + * the runtime evaluation duration of ``func`` is greater than ``interval``. + * + * ``func`` is given two arguments, ``resolve`` and ``reject``, + * of which one must be called for the evaluation to complete. + * Calling ``resolve`` with an argument indicates that the expected + * wait condition was met and will return the passed value to the + * caller. Conversely, calling ``reject`` will evaluate ``func`` + * again until the ``timeout`` duration has elapsed or ``func`` throws. + * The passed value to ``reject`` will also be returned to the caller + * once the wait has expired. + * + * Usage:: + * + * let els = new PollPromise((resolve, reject) => { + * let res = document.querySelectorAll("p"); + * if (res.length > 0) { + * resolve(Array.from(res)); + * } else { + * reject([]); + * } + * }, {timeout: 1000}); + * + * @param {Condition} func + * Function to run off the main thread. + * @param {object=} options + * @param {number=} options.timeout + * Desired timeout if wanted. If 0 or less than the runtime evaluation + * time of ``func``, ``func`` is guaranteed to run at least once. + * Defaults to using no timeout. + * @param {number=} options.interval + * Duration between each poll of ``func`` in milliseconds. + * Defaults to 10 milliseconds. + * + * @returns {Promise.<*>} + * Yields the value passed to ``func``'s + * ``resolve`` or ``reject`` callbacks. + * + * @throws {*} + * If ``func`` throws, its error is propagated. + * @throws {TypeError} + * If `timeout` or `interval`` are not numbers. + * @throws {RangeError} + * If `timeout` or `interval` are not unsigned integers. + */ +export function PollPromise(func, { timeout = null, interval = 10 } = {}) { + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + + if (typeof func != "function") { + throw new TypeError(); + } + if (timeout != null && typeof timeout != "number") { + throw new TypeError(); + } + if (typeof interval != "number") { + throw new TypeError(); + } + if ( + (timeout && (!Number.isInteger(timeout) || timeout < 0)) || + !Number.isInteger(interval) || + interval < 0 + ) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let start, end; + + if (Number.isInteger(timeout)) { + start = new Date().getTime(); + end = start + timeout; + } + + let evalFn = () => { + new Promise(func) + .then(resolve, rejected => { + if (lazy.error.isError(rejected)) { + throw rejected; + } + + // return if there is a timeout and set to 0, + // allowing |func| to be evaluated at least once + if ( + typeof end != "undefined" && + (start == end || new Date().getTime() >= end) + ) { + resolve(rejected); + } + }) + .catch(reject); + }; + + // the repeating slack timer waits |interval| + // before invoking |evalFn| + evalFn(); + + timer.init(evalFn, interval, TYPE_REPEATING_SLACK); + }).then( + res => { + timer.cancel(); + return res; + }, + err => { + timer.cancel(); + throw err; + } + ); +} + +/** + * Represents the timed, eventual completion (or failure) of an + * asynchronous operation, and its resulting value. + * + * In contrast to a regular Promise, it times out after ``timeout``. + * + * @param {Function} fn + * Function to run, which will have its ``reject`` + * callback invoked after the ``timeout`` duration is reached. + * It is given two callbacks: ``resolve(value)`` and + * ``reject(error)``. + * @param {object=} options + * @param {string} options.errorMessage + * Message to use for the thrown error. + * @param {number=} options.timeout + * ``condition``'s ``reject`` callback will be called + * after this timeout, given in milliseconds. + * By default 1500 ms in an optimised build and 4500 ms in + * debug builds. + * @param {Error=} options.throws + * When the ``timeout`` is hit, this error class will be + * thrown. If it is null, no error is thrown and the promise is + * instead resolved on timeout with a TimeoutError. + * + * @returns {Promise.<*>} + * Timed promise. + * + * @throws {TypeError} + * If `timeout` is not a number. + * @throws {RangeError} + * If `timeout` is not an unsigned integer. + */ +export function TimedPromise(fn, options = {}) { + const { + errorMessage = "TimedPromise timed out", + timeout = PROMISE_TIMEOUT, + throws = lazy.error.TimeoutError, + } = options; + + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + + if (typeof fn != "function") { + throw new TypeError(); + } + if (typeof timeout != "number") { + throw new TypeError(); + } + if (!Number.isInteger(timeout) || timeout < 0) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let trace; + + // Reject only if |throws| is given. Otherwise it is assumed that + // the user is OK with the promise timing out. + let bail = () => { + const message = `${errorMessage} after ${timeout} ms`; + if (throws !== null) { + let err = new throws(message); + reject(err); + } else { + lazy.logger.warn(message, trace); + resolve(); + } + }; + + trace = lazy.error.stack(); + timer.initWithCallback({ notify: bail }, timeout, TYPE_ONE_SHOT); + + try { + fn(resolve, reject); + } catch (e) { + reject(e); + } + }).then( + res => { + timer.cancel(); + return res; + }, + err => { + timer.cancel(); + throw err; + } + ); +} + +/** + * Pauses for the given duration. + * + * @param {number} timeout + * Duration to wait before fulfilling promise in milliseconds. + * + * @returns {Promise} + * Promise that fulfills when the `timeout` is elapsed. + * + * @throws {TypeError} + * If `timeout` is not a number. + * @throws {RangeError} + * If `timeout` is not an unsigned integer. + */ +export function Sleep(timeout) { + if (typeof timeout != "number") { + throw new TypeError(); + } + if (!Number.isInteger(timeout) || timeout < 0) { + throw new RangeError(); + } + + return new Promise(resolve => { + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + // Bug 1663880 - Explicitely cancel the timer for now to prevent a hang + timer.cancel(); + resolve(); + }, + timeout, + TYPE_ONE_SHOT + ); + }); +} + +/** + * Detects when the specified message manager has been destroyed. + * + * One can observe the removal and detachment of a content browser + * (`<xul:browser>`) or a chrome window by its message manager + * disconnecting. + * + * When a browser is associated with a tab, this is safer than only + * relying on the event `TabClose` which signalises the _intent to_ + * remove a tab and consequently would lead to the destruction of + * the content browser and its browser message manager. + * + * When closing a chrome window it is safer than only relying on + * the event 'unload' which signalises the _intent to_ close the + * chrome window and consequently would lead to the destruction of + * the window and its window message manager. + * + * @param {MessageListenerManager} messageManager + * The message manager to observe for its disconnect state. + * Use the browser message manager when closing a content browser, + * and the window message manager when closing a chrome window. + * + * @returns {Promise} + * A promise that resolves when the message manager has been destroyed. + */ +export function MessageManagerDestroyedPromise(messageManager) { + return new Promise(resolve => { + function observe(subject, topic) { + lazy.logger.trace(`Received observer notification ${topic}`); + + if (subject == messageManager) { + Services.obs.removeObserver(this, "message-manager-disconnect"); + resolve(); + } + } + + Services.obs.addObserver(observe, "message-manager-disconnect"); + }); +} + +/** + * Throttle until the main thread is idle and `window` has performed + * an animation frame (in that order). + * + * @param {ChromeWindow} win + * Window to request the animation frame from. + * + * @returns {Promise} + */ +export function IdlePromise(win) { + const animationFramePromise = new Promise(resolve => { + executeSoon(() => { + win.requestAnimationFrame(resolve); + }); + }); + + // Abort if the underlying window gets closed + const windowClosedPromise = new PollPromise(resolve => { + if (win.closed) { + resolve(); + } + }); + + return Promise.race([animationFramePromise, windowClosedPromise]); +} + +/** + * Wraps a callback function, that, as long as it continues to be + * invoked, will not be triggered. The given function will be + * called after the timeout duration is reached, after no more + * events fire. + * + * This class implements the {@link EventListener} interface, + * which means it can be used interchangably with `addEventHandler`. + * + * Debouncing events can be useful when dealing with e.g. DOM events + * that fire at a high rate. It is generally advisable to avoid + * computationally expensive operations such as DOM modifications + * under these circumstances. + * + * One such high frequenecy event is `resize` that can fire multiple + * times before the window reaches its final dimensions. In order + * to delay an operation until the window has completed resizing, + * it is possible to use this technique to only invoke the callback + * after the last event has fired:: + * + * let cb = new DebounceCallback(event => { + * // fires after the final resize event + * console.log("resize", event); + * }); + * window.addEventListener("resize", cb); + * + * Note that it is not possible to use this synchronisation primitive + * with `addEventListener(..., {once: true})`. + * + * @param {function(Event): void} fn + * Callback function that is guaranteed to be invoked once only, + * after `timeout`. + * @param {number=} [timeout = 250] timeout + * Time since last event firing, before `fn` will be invoked. + */ +export class DebounceCallback { + constructor(fn, { timeout = 250 } = {}) { + if (typeof fn != "function" || typeof timeout != "number") { + throw new TypeError(); + } + if (!Number.isInteger(timeout) || timeout < 0) { + throw new RangeError(); + } + + this.fn = fn; + this.timeout = timeout; + this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + } + + handleEvent(ev) { + this.timer.cancel(); + this.timer.initWithCallback( + () => { + this.timer.cancel(); + this.fn(ev); + }, + this.timeout, + TYPE_ONE_SHOT + ); + } +} + +/** + * Wait for a message to be fired from a particular message manager. + * + * This method has been duplicated from BrowserTestUtils.sys.mjs. + * + * @param {nsIMessageManager} messageManager + * The message manager that should be used. + * @param {string} messageName + * The message to wait for. + * @param {object=} options + * Extra options. + * @param {function(Message): boolean=} options.checkFn + * Called with the ``Message`` object as argument, should return ``true`` + * if the message is the expected one, or ``false`` if it should be + * ignored and listening should continue. If not specified, the first + * message with the specified name resolves the returned promise. + * + * @returns {Promise.<object>} + * Promise which resolves to the data property of the received + * ``Message``. + */ +export function waitForMessage( + messageManager, + messageName, + { checkFn = undefined } = {} +) { + if (messageManager == null || !("addMessageListener" in messageManager)) { + throw new TypeError(); + } + if (typeof messageName != "string") { + throw new TypeError(); + } + if (checkFn && typeof checkFn != "function") { + throw new TypeError(); + } + + return new Promise(resolve => { + messageManager.addMessageListener(messageName, function onMessage(msg) { + lazy.logger.trace(`Received ${messageName} for ${msg.target}`); + if (checkFn && !checkFn(msg)) { + return; + } + messageManager.removeMessageListener(messageName, onMessage); + resolve(msg.data); + }); + }); +} + +/** + * Wait for the specified observer topic to be observed. + * + * This method has been duplicated from TestUtils.sys.mjs. + * + * Because this function is intended for testing, any error in checkFn + * will cause the returned promise to be rejected instead of waiting for + * the next notification, since this is probably a bug in the test. + * + * @param {string} topic + * The topic to observe. + * @param {object=} options + * Extra options. + * @param {function(string, object): boolean=} options.checkFn + * Called with ``subject``, and ``data`` as arguments, should return true + * if the notification is the expected one, or false if it should be + * ignored and listening should continue. If not specified, the first + * notification for the specified topic resolves the returned promise. + * @param {number=} options.timeout + * Timeout duration in milliseconds, if provided. + * If specified, then the returned promise will be rejected with + * TimeoutError, if not already resolved, after this duration has elapsed. + * If not specified, then no timeout is used. Defaults to null. + * + * @returns {Promise.<Array<string, object>>} + * Promise which is either resolved to an array of ``subject``, and ``data`` + * from the observed notification, or rejected with TimeoutError after + * options.timeout milliseconds if specified. + * + * @throws {TypeError} + * @throws {RangeError} + */ +export function waitForObserverTopic(topic, options = {}) { + const { checkFn = null, timeout = null } = options; + if (typeof topic != "string") { + throw new TypeError(); + } + if ( + (checkFn != null && typeof checkFn != "function") || + (timeout !== null && typeof timeout != "number") + ) { + throw new TypeError(); + } + if (timeout && (!Number.isInteger(timeout) || timeout < 0)) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let timer; + + function cleanUp() { + Services.obs.removeObserver(observer, topic); + timer?.cancel(); + } + + function observer(subject, topic, data) { + lazy.logger.trace(`Received observer notification ${topic}`); + try { + if (checkFn && !checkFn(subject, data)) { + return; + } + cleanUp(); + resolve({ subject, data }); + } catch (ex) { + cleanUp(); + reject(ex); + } + } + + Services.obs.addObserver(observer, topic); + + if (timeout !== null) { + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + cleanUp(); + reject( + new lazy.error.TimeoutError( + `waitForObserverTopic timed out after ${timeout} ms` + ) + ); + }, + timeout, + TYPE_ONE_SHOT + ); + } + }); +} diff --git a/remote/marionette/test/README b/remote/marionette/test/README new file mode 100644 index 0000000000..9305b92cab --- /dev/null +++ b/remote/marionette/test/README @@ -0,0 +1 @@ +See ../doc/Testing.md
\ No newline at end of file diff --git a/remote/marionette/test/xpcshell/.eslintrc.js b/remote/marionette/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..2ef179ab5e --- /dev/null +++ b/remote/marionette/test/xpcshell/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + rules: { + camelcase: "off", + }, +}; diff --git a/remote/marionette/test/xpcshell/README b/remote/marionette/test/xpcshell/README new file mode 100644 index 0000000000..ce516d17ca --- /dev/null +++ b/remote/marionette/test/xpcshell/README @@ -0,0 +1,16 @@ +To run the tests in this directory, from the top source directory, +either invoke the test despatcher in mach: + + % ./mach test remote/marionette/test/xpcshell + +Or call out the harness specifically: + + % ./mach xpcshell-test remote/marionette/test/xpcshell + +The latter gives you the --sequential option which can be useful +when debugging to prevent tests from running in parallel. + +When adding new tests you must make sure they are listed in +xpcshell.ini, otherwise they will not run on try. + +See also ../../doc/Testing.md for more advice on our other types of tests. diff --git a/remote/marionette/test/xpcshell/test_actors.js b/remote/marionette/test/xpcshell/test_actors.js new file mode 100644 index 0000000000..9b24d1d10f --- /dev/null +++ b/remote/marionette/test/xpcshell/test_actors.js @@ -0,0 +1,55 @@ +/* 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 { + getMarionetteCommandsActorProxy, + registerCommandsActor, + unregisterCommandsActor, +} = ChromeUtils.importESModule( + "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs" +); +const { enableEventsActor, disableEventsActor } = ChromeUtils.importESModule( + "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs" +); + +registerCleanupFunction(function () { + unregisterCommandsActor(); + disableEventsActor(); +}); + +add_task(function test_commandsActor_register() { + registerCommandsActor(); + unregisterCommandsActor(); + + registerCommandsActor(); + registerCommandsActor(); + unregisterCommandsActor(); +}); + +add_task(async function test_commandsActor_getActorProxy_noBrowsingContext() { + registerCommandsActor(); + + try { + await getMarionetteCommandsActorProxy(() => null).sendQuery("foo", "bar"); + ok(false, "Expected NoBrowsingContext error not raised"); + } catch (e) { + ok( + e.message.includes("No BrowsingContext found"), + "Expected default error message found" + ); + } + + unregisterCommandsActor(); +}); + +add_task(function test_eventsActor_enable_disable() { + enableEventsActor(); + disableEventsActor(); + + enableEventsActor(); + enableEventsActor(); + disableEventsActor(); +}); diff --git a/remote/marionette/test/xpcshell/test_browser.js b/remote/marionette/test/xpcshell/test_browser.js new file mode 100644 index 0000000000..fdd83ba7e3 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_browser.js @@ -0,0 +1,21 @@ +const { Context } = ChromeUtils.importESModule( + "chrome://remote/content/marionette/browser.sys.mjs" +); + +add_task(function test_Context() { + ok(Context.hasOwnProperty("Chrome")); + ok(Context.hasOwnProperty("Content")); + equal(typeof Context.Chrome, "string"); + equal(typeof Context.Content, "string"); + equal(Context.Chrome, "chrome"); + equal(Context.Content, "content"); +}); + +add_task(function test_Context_fromString() { + equal(Context.fromString("chrome"), Context.Chrome); + equal(Context.fromString("content"), Context.Content); + + for (let typ of ["", "foo", true, 42, [], {}, null, undefined]) { + Assert.throws(() => Context.fromString(typ), /TypeError/); + } +}); diff --git a/remote/marionette/test/xpcshell/test_cookie.js b/remote/marionette/test/xpcshell/test_cookie.js new file mode 100644 index 0000000000..b5ce5e9008 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_cookie.js @@ -0,0 +1,362 @@ +/* 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/. */ + +const { cookie } = ChromeUtils.importESModule( + "chrome://remote/content/marionette/cookie.sys.mjs" +); + +/* eslint-disable mozilla/use-chromeutils-generateqi */ + +cookie.manager = { + cookies: [], + + add( + domain, + path, + name, + value, + secure, + httpOnly, + session, + expiry, + originAttributes, + sameSite + ) { + if (name === "fail") { + throw new Error("An error occurred while adding cookie"); + } + let newCookie = { + host: domain, + path, + name, + value, + isSecure: secure, + isHttpOnly: httpOnly, + isSession: session, + expiry, + originAttributes, + sameSite, + }; + cookie.manager.cookies.push(newCookie); + }, + + remove(host, name, path) { + for (let i = 0; i < this.cookies.length; ++i) { + let candidate = this.cookies[i]; + if ( + candidate.host === host && + candidate.name === name && + candidate.path === path + ) { + return this.cookies.splice(i, 1); + } + } + return false; + }, + + getCookiesFromHost(host) { + let hostCookies = this.cookies.filter( + c => c.host === host || c.host === "." + host + ); + + return hostCookies; + }, +}; + +add_task(function test_fromJSON() { + // object + for (let invalidType of ["foo", 42, true, [], null, undefined]) { + Assert.throws(() => cookie.fromJSON(invalidType), /Expected cookie object/); + } + + // name and value + for (let invalidType of [42, true, [], {}, null, undefined]) { + Assert.throws( + () => cookie.fromJSON({ name: invalidType }), + /Cookie name must be string/ + ); + Assert.throws( + () => cookie.fromJSON({ name: "foo", value: invalidType }), + /Cookie value must be string/ + ); + } + + // domain + for (let invalidType of [42, true, [], {}, null]) { + let domainTest = { + name: "foo", + value: "bar", + domain: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(domainTest), + /Cookie domain must be string/ + ); + } + let domainTest = { + name: "foo", + value: "bar", + domain: "domain", + }; + let parsedCookie = cookie.fromJSON(domainTest); + equal(parsedCookie.domain, "domain"); + + // path + for (let invalidType of [42, true, [], {}, null]) { + let pathTest = { + name: "foo", + value: "bar", + path: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(pathTest), + /Cookie path must be string/ + ); + } + + // secure + for (let invalidType of ["foo", 42, [], {}, null]) { + let secureTest = { + name: "foo", + value: "bar", + secure: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(secureTest), + /Cookie secure flag must be boolean/ + ); + } + + // httpOnly + for (let invalidType of ["foo", 42, [], {}, null]) { + let httpOnlyTest = { + name: "foo", + value: "bar", + httpOnly: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(httpOnlyTest), + /Cookie httpOnly flag must be boolean/ + ); + } + + // expiry + for (let invalidType of [ + -1, + Number.MAX_SAFE_INTEGER + 1, + "foo", + true, + [], + {}, + null, + ]) { + let expiryTest = { + name: "foo", + value: "bar", + expiry: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(expiryTest), + /Cookie expiry must be a positive integer/ + ); + } + + // sameSite + for (let invalidType of ["foo", 42, [], {}, null]) { + const sameSiteTest = { + name: "foo", + value: "bar", + sameSite: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(sameSiteTest), + /Cookie SameSite flag must be one of None, Lax, or Strict/ + ); + } + + // bare requirements + let bare = cookie.fromJSON({ name: "name", value: "value" }); + equal("name", bare.name); + equal("value", bare.value); + for (let missing of [ + "path", + "secure", + "httpOnly", + "session", + "expiry", + "sameSite", + ]) { + ok(!bare.hasOwnProperty(missing)); + } + + // everything + let full = cookie.fromJSON({ + name: "name", + value: "value", + domain: ".domain", + path: "path", + secure: true, + httpOnly: true, + expiry: 42, + sameSite: "Lax", + }); + equal("name", full.name); + equal("value", full.value); + equal(".domain", full.domain); + equal("path", full.path); + equal(true, full.secure); + equal(true, full.httpOnly); + equal(42, full.expiry); + equal("Lax", full.sameSite); +}); + +add_task(function test_add() { + cookie.manager.cookies = []; + + for (let invalidType of [42, true, [], {}, null, undefined]) { + Assert.throws( + () => cookie.add({ name: invalidType }), + /Cookie name must be string/ + ); + Assert.throws( + () => cookie.add({ name: "name", value: invalidType }), + /Cookie value must be string/ + ); + Assert.throws( + () => cookie.add({ name: "name", value: "value", domain: invalidType }), + /Cookie domain must be string/ + ); + } + + cookie.add({ + name: "name", + value: "value", + domain: "domain", + }); + equal(1, cookie.manager.cookies.length); + equal("name", cookie.manager.cookies[0].name); + equal("value", cookie.manager.cookies[0].value); + equal(".domain", cookie.manager.cookies[0].host); + equal("/", cookie.manager.cookies[0].path); + ok(cookie.manager.cookies[0].expiry > new Date(Date.now()).getTime() / 1000); + + cookie.add({ + name: "name2", + value: "value2", + domain: "domain2", + }); + equal(2, cookie.manager.cookies.length); + + Assert.throws(() => { + let biscuit = { name: "name3", value: "value3", domain: "domain3" }; + cookie.add(biscuit, { restrictToHost: "other domain" }); + }, /Cookies may only be set for the current domain/); + + cookie.add({ + name: "name4", + value: "value4", + domain: "my.domain:1234", + }); + equal(".my.domain", cookie.manager.cookies[2].host); + + cookie.add({ + name: "name5", + value: "value5", + domain: "domain5", + path: "/foo/bar", + }); + equal("/foo/bar", cookie.manager.cookies[3].path); + + cookie.add({ + name: "name6", + value: "value", + domain: ".domain", + }); + equal(".domain", cookie.manager.cookies[4].host); + + const sameSiteMap = new Map([ + ["None", Ci.nsICookie.SAMESITE_NONE], + ["Lax", Ci.nsICookie.SAMESITE_LAX], + ["Strict", Ci.nsICookie.SAMESITE_STRICT], + ]); + + Array.from(sameSiteMap.keys()).forEach((entry, index) => { + cookie.add({ + name: "name" + index, + value: "value", + domain: ".domain", + sameSite: entry, + }); + equal(sameSiteMap.get(entry), cookie.manager.cookies[5 + index].sameSite); + }); + + Assert.throws(() => { + cookie.add({ name: "fail", value: "value6", domain: "domain6" }); + }, /UnableToSetCookieError/); +}); + +add_task(function test_remove() { + cookie.manager.cookies = []; + + let crumble = { + name: "test_remove", + value: "value", + domain: "domain", + path: "/custom/path", + }; + + equal(0, cookie.manager.cookies.length); + cookie.add(crumble); + equal(1, cookie.manager.cookies.length); + + cookie.remove(crumble); + equal(0, cookie.manager.cookies.length); + equal(undefined, cookie.manager.cookies[0]); +}); + +add_task(function test_iter() { + cookie.manager.cookies = []; + let tomorrow = new Date(); + tomorrow.setHours(tomorrow.getHours() + 24); + + cookie.add({ + expiry: tomorrow, + name: "0", + value: "", + domain: "foo.example.com", + }); + cookie.add({ + expiry: tomorrow, + name: "1", + value: "", + domain: "bar.example.com", + }); + + let fooCookies = [...cookie.iter("foo.example.com")]; + equal(1, fooCookies.length); + equal(".foo.example.com", fooCookies[0].domain); + equal(true, fooCookies[0].hasOwnProperty("expiry")); + + cookie.add({ + name: "aSessionCookie", + value: "", + domain: "session.com", + }); + + let sessionCookies = [...cookie.iter("session.com")]; + equal(1, sessionCookies.length); + equal("aSessionCookie", sessionCookies[0].name); + equal(false, sessionCookies[0].hasOwnProperty("expiry")); + + cookie.add({ + name: "2", + value: "", + domain: "samesite.example.com", + sameSite: "Lax", + }); + + let sameSiteCookies = [...cookie.iter("samesite.example.com")]; + equal(1, sameSiteCookies.length); + equal("Lax", sameSiteCookies[0].sameSite); +}); diff --git a/remote/marionette/test/xpcshell/test_json.js b/remote/marionette/test/xpcshell/test_json.js new file mode 100644 index 0000000000..f606681a8e --- /dev/null +++ b/remote/marionette/test/xpcshell/test_json.js @@ -0,0 +1,472 @@ +const { json, getKnownElement, getKnownShadowRoot } = + ChromeUtils.importESModule("chrome://remote/content/marionette/json.sys.mjs"); +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); +const { ShadowRoot, WebElement, WebReference } = ChromeUtils.importESModule( + "chrome://remote/content/marionette/web-reference.sys.mjs" +); + +const MemoryReporter = Cc["@mozilla.org/memory-reporter-manager;1"].getService( + Ci.nsIMemoryReporterManager +); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + const nodeCache = new NodeCache(); + + const videoEl = browser.document.createElement("video"); + browser.document.body.appendChild(videoEl); + + const svgEl = browser.document.createElementNS( + "http://www.w3.org/2000/svg", + "rect" + ); + browser.document.body.appendChild(svgEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + const iframeEl = browser.document.createElement("iframe"); + browser.document.body.appendChild(iframeEl); + const childEl = iframeEl.contentDocument.createElement("div"); + + return { + browser, + browsingContext: browser.browsingContext, + nodeCache, + childEl, + iframeEl, + seenNodeIds: new Map(), + shadowRoot, + svgEl, + videoEl, + }; +} + +function assert_cloned_value(value, clonedValue, nodeCache, seenNodes = []) { + const { seenNodeIds, serializedValue } = json.clone(value, nodeCache); + + deepEqual(serializedValue, clonedValue); + deepEqual([...seenNodeIds.values()], seenNodes); +} + +add_task(function test_clone_generalTypes() { + const { nodeCache } = setupTest(); + + // null + assert_cloned_value(undefined, null, nodeCache); + assert_cloned_value(null, null, nodeCache); + + // primitives + assert_cloned_value(true, true, nodeCache); + assert_cloned_value(42, 42, nodeCache); + assert_cloned_value("foo", "foo", nodeCache); + + // toJSON + assert_cloned_value( + { + toJSON() { + return "foo"; + }, + }, + "foo", + nodeCache + ); +}); + +add_task(function test_clone_ShadowRoot() { + const { nodeCache, seenNodeIds, shadowRoot } = setupTest(); + + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + assert_cloned_value( + shadowRoot, + WebReference.from(shadowRoot, shadowRootRef).toJSON(), + nodeCache, + seenNodeIds + ); +}); + +add_task(function test_clone_WebElement() { + const { videoEl, nodeCache, seenNodeIds, svgEl } = setupTest(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + assert_cloned_value( + videoEl, + WebReference.from(videoEl, videoElRef).toJSON(), + nodeCache, + seenNodeIds + ); + + // Check an element with a different namespace + const svgElRef = nodeCache.getOrCreateNodeReference(svgEl, seenNodeIds); + assert_cloned_value( + svgEl, + WebReference.from(svgEl, svgElRef).toJSON(), + nodeCache, + seenNodeIds + ); +}); + +add_task(function test_clone_Sequences() { + const { videoEl, nodeCache, seenNodeIds } = setupTest(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + + const input = [ + null, + true, + [42], + videoEl, + { + toJSON() { + return "foo"; + }, + }, + { bar: "baz" }, + ]; + + assert_cloned_value( + input, + [ + null, + true, + [42], + { [WebElement.Identifier]: videoElRef }, + "foo", + { bar: "baz" }, + ], + nodeCache, + seenNodeIds + ); +}); + +add_task(function test_clone_objects() { + const { videoEl, nodeCache, seenNodeIds } = setupTest(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + + const input = { + null: null, + boolean: true, + array: [42], + element: videoEl, + toJSON: { + toJSON() { + return "foo"; + }, + }, + object: { bar: "baz" }, + }; + + assert_cloned_value( + input, + { + null: null, + boolean: true, + array: [42], + element: { [WebElement.Identifier]: videoElRef }, + toJSON: "foo", + object: { bar: "baz" }, + }, + nodeCache, + seenNodeIds + ); +}); + +add_task(function test_clone_сyclicReference() { + const { nodeCache } = setupTest(); + + // object + Assert.throws(() => { + const obj = {}; + obj.reference = obj; + json.clone(obj, nodeCache); + }, /JavaScriptError/); + + // array + Assert.throws(() => { + const array = []; + array.push(array); + json.clone(array, nodeCache); + }, /JavaScriptError/); + + // array in object + Assert.throws(() => { + const array = []; + array.push(array); + json.clone({ array }, nodeCache); + }, /JavaScriptError/); + + // object in array + Assert.throws(() => { + const obj = {}; + obj.reference = obj; + json.clone([obj], nodeCache); + }, /JavaScriptError/); +}); + +add_task(function test_deserialize_generalTypes() { + const { browsingContext, nodeCache } = setupTest(); + + // null + equal(json.deserialize(undefined, nodeCache, browsingContext), undefined); + equal(json.deserialize(null, nodeCache, browsingContext), null); + + // primitives + equal(json.deserialize(true, nodeCache, browsingContext), true); + equal(json.deserialize(42, nodeCache, browsingContext), 42); + equal(json.deserialize("foo", nodeCache, browsingContext), "foo"); +}); + +add_task(function test_deserialize_ShadowRoot() { + const { browsingContext, nodeCache, seenNodeIds, shadowRoot } = setupTest(); + const seenNodes = new Set(); + + // Fails to resolve for unknown elements + const unknownShadowRootId = { [ShadowRoot.Identifier]: "foo" }; + Assert.throws(() => { + json.deserialize( + unknownShadowRootId, + nodeCache, + browsingContext, + seenNodes + ); + }, /NoSuchShadowRootError/); + + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + const shadowRootEl = { [ShadowRoot.Identifier]: shadowRootRef }; + + // Fails to resolve for missing window reference + Assert.throws(() => json.deserialize(shadowRootEl, nodeCache), /TypeError/); + + // Previously seen element is associated with original web element reference + seenNodes.add(shadowRootRef); + const root = json.deserialize( + shadowRootEl, + nodeCache, + browsingContext, + seenNodes + ); + deepEqual(root, shadowRoot); + deepEqual(root, nodeCache.getNode(browsingContext, shadowRootRef)); +}); + +add_task(function test_deserialize_WebElement() { + const { browser, browsingContext, videoEl, nodeCache, seenNodeIds } = + setupTest(); + const seenNodes = new Set(); + + // Fails to resolve for unknown elements + const unknownWebElId = { [WebElement.Identifier]: "foo" }; + Assert.throws(() => { + json.deserialize(unknownWebElId, nodeCache, browsingContext, seenNodes); + }, /NoSuchElementError/); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + const htmlWebEl = { [WebElement.Identifier]: videoElRef }; + + // Fails to resolve for missing window reference + Assert.throws(() => json.deserialize(htmlWebEl, nodeCache), /TypeError/); + + // Previously seen element is associated with original web element reference + seenNodes.add(videoElRef); + const el = json.deserialize(htmlWebEl, nodeCache, browsingContext, seenNodes); + deepEqual(el, videoEl); + deepEqual(el, nodeCache.getNode(browser.browsingContext, videoElRef)); +}); + +add_task(function test_deserialize_Sequences() { + const { browsingContext, videoEl, nodeCache, seenNodeIds } = setupTest(); + const seenNodes = new Set(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + seenNodes.add(videoElRef); + + const input = [ + null, + true, + [42], + { [WebElement.Identifier]: videoElRef }, + { bar: "baz" }, + ]; + + const actual = json.deserialize(input, nodeCache, browsingContext, seenNodes); + + equal(actual[0], null); + equal(actual[1], true); + deepEqual(actual[2], [42]); + deepEqual(actual[3], videoEl); + deepEqual(actual[4], { bar: "baz" }); +}); + +add_task(function test_deserialize_objects() { + const { browsingContext, videoEl, nodeCache, seenNodeIds } = setupTest(); + const seenNodes = new Set(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + seenNodes.add(videoElRef); + + const input = { + null: null, + boolean: true, + array: [42], + element: { [WebElement.Identifier]: videoElRef }, + object: { bar: "baz" }, + }; + + const actual = json.deserialize(input, nodeCache, browsingContext, seenNodes); + + equal(actual.null, null); + equal(actual.boolean, true); + deepEqual(actual.array, [42]); + deepEqual(actual.element, videoEl); + deepEqual(actual.object, { bar: "baz" }); + + nodeCache.clear({ all: true }); +}); + +add_task(async function test_getKnownElement() { + const { browser, nodeCache, seenNodeIds, shadowRoot, videoEl } = setupTest(); + const seenNodes = new Set(); + + // Unknown element reference + Assert.throws(() => { + getKnownElement(browser.browsingContext, "foo", nodeCache, seenNodes); + }, /NoSuchElementError/); + + // With a ShadowRoot reference + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + seenNodes.add(shadowRootRef); + + Assert.throws(() => { + getKnownElement( + browser.browsingContext, + shadowRootRef, + nodeCache, + seenNodes + ); + }, /NoSuchElementError/); + + let detachedEl = browser.document.createElement("div"); + const detachedElRef = nodeCache.getOrCreateNodeReference( + detachedEl, + seenNodeIds + ); + seenNodes.add(detachedElRef); + + // Element not connected to the DOM + Assert.throws(() => { + getKnownElement( + browser.browsingContext, + detachedElRef, + nodeCache, + seenNodes + ); + }, /StaleElementReferenceError/); + + // Element garbage collected + detachedEl = null; + + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); + Assert.throws(() => { + getKnownElement( + browser.browsingContext, + detachedElRef, + nodeCache, + seenNodes + ); + }, /StaleElementReferenceError/); + + // Known element reference + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + seenNodes.add(videoElRef); + + equal( + getKnownElement(browser.browsingContext, videoElRef, nodeCache, seenNodes), + videoEl + ); +}); + +add_task(async function test_getKnownShadowRoot() { + const { browser, nodeCache, seenNodeIds, shadowRoot, videoEl } = setupTest(); + const seenNodes = new Set(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + seenNodes.add(videoElRef); + + // Unknown ShadowRoot reference + Assert.throws(() => { + getKnownShadowRoot(browser.browsingContext, "foo", nodeCache, seenNodes); + }, /NoSuchShadowRootError/); + + // With a videoElement reference + Assert.throws(() => { + getKnownShadowRoot( + browser.browsingContext, + videoElRef, + nodeCache, + seenNodes + ); + }, /NoSuchShadowRootError/); + + // Known ShadowRoot reference + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + seenNodes.add(shadowRootRef); + + equal( + getKnownShadowRoot( + browser.browsingContext, + shadowRootRef, + nodeCache, + seenNodes + ), + shadowRoot + ); + + // Detached ShadowRoot host + let el = browser.document.createElement("div"); + let detachedShadowRoot = el.attachShadow({ mode: "open" }); + detachedShadowRoot.innerHTML = "<input></input>"; + + const detachedShadowRootRef = nodeCache.getOrCreateNodeReference( + detachedShadowRoot, + seenNodeIds + ); + seenNodes.add(detachedShadowRootRef); + + // ... not connected to the DOM + Assert.throws(() => { + getKnownShadowRoot( + browser.browsingContext, + detachedShadowRootRef, + nodeCache, + seenNodes + ); + }, /DetachedShadowRootError/); + + // ... host and shadow root garbage collected + el = null; + detachedShadowRoot = null; + + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); + Assert.throws(() => { + getKnownShadowRoot( + browser.browsingContext, + detachedShadowRootRef, + nodeCache, + seenNodes + ); + }, /DetachedShadowRootError/); +}); diff --git a/remote/marionette/test/xpcshell/test_message.js b/remote/marionette/test/xpcshell/test_message.js new file mode 100644 index 0000000000..9926aea191 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_message.js @@ -0,0 +1,245 @@ +/* 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/. */ + +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); +const { Command, Message, Response } = ChromeUtils.importESModule( + "chrome://remote/content/marionette/message.sys.mjs" +); + +add_task(function test_Message_Origin() { + equal(0, Message.Origin.Client); + equal(1, Message.Origin.Server); +}); + +add_task(function test_Message_fromPacket() { + let cmd = new Command(4, "foo"); + let resp = new Response(5, () => {}); + resp.error = "foo"; + + ok(Message.fromPacket(cmd.toPacket()) instanceof Command); + ok(Message.fromPacket(resp.toPacket()) instanceof Response); + Assert.throws( + () => Message.fromPacket([3, 4, 5, 6]), + /Unrecognised message type in packet/ + ); +}); + +add_task(function test_Command() { + let cmd = new Command(42, "foo", { bar: "baz" }); + equal(42, cmd.id); + equal("foo", cmd.name); + deepEqual({ bar: "baz" }, cmd.parameters); + equal(null, cmd.onerror); + equal(null, cmd.onresult); + equal(Message.Origin.Client, cmd.origin); + equal(false, cmd.sent); +}); + +add_task(function test_Command_onresponse() { + let onerrorOk = false; + let onresultOk = false; + + let cmd = new Command(7, "foo"); + cmd.onerror = () => (onerrorOk = true); + cmd.onresult = () => (onresultOk = true); + + let errorResp = new Response(8, () => {}); + errorResp.error = new error.WebDriverError("foo"); + + let bodyResp = new Response(9, () => {}); + bodyResp.body = "bar"; + + cmd.onresponse(errorResp); + equal(true, onerrorOk); + equal(false, onresultOk); + + cmd.onresponse(bodyResp); + equal(true, onresultOk); +}); + +add_task(function test_Command_ctor() { + let cmd = new Command(42, "bar", { bar: "baz" }); + let msg = cmd.toPacket(); + + equal(Command.Type, msg[0]); + equal(cmd.id, msg[1]); + equal(cmd.name, msg[2]); + equal(cmd.parameters, msg[3]); +}); + +add_task(function test_Command_toString() { + let cmd = new Command(42, "foo", { bar: "baz" }); + equal(JSON.stringify(cmd.toPacket()), cmd.toString()); +}); + +add_task(function test_Command_fromPacket() { + let c1 = new Command(42, "foo", { bar: "baz" }); + + let msg = c1.toPacket(); + let c2 = Command.fromPacket(msg); + + equal(c1.id, c2.id); + equal(c1.name, c2.name); + equal(c1.parameters, c2.parameters); + + Assert.throws( + () => Command.fromPacket([null, 2, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Command.fromPacket([1, 2, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Command.fromPacket([0, null, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Command.fromPacket([0, 2, null, {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Command.fromPacket([0, 2, "foo", false]), + /InvalidArgumentError/ + ); + + let nullParams = Command.fromPacket([0, 2, "foo", null]); + equal( + "[object Object]", + Object.prototype.toString.call(nullParams.parameters) + ); +}); + +add_task(function test_Command_Type() { + equal(0, Command.Type); +}); + +add_task(function test_Response_ctor() { + let handler = () => { + throw new Error("foo"); + }; + + let resp = new Response(42, handler); + equal(42, resp.id); + equal(null, resp.error); + ok("origin" in resp); + equal(Message.Origin.Server, resp.origin); + equal(false, resp.sent); + equal(handler, resp.respHandler_); +}); + +add_task(function test_Response_sendConditionally() { + let fired = false; + let resp = new Response(42, () => (fired = true)); + resp.sendConditionally(() => false); + equal(false, resp.sent); + equal(false, fired); + resp.sendConditionally(() => true); + equal(true, resp.sent); + equal(true, fired); +}); + +add_task(function test_Response_send() { + let fired = false; + let resp = new Response(42, () => (fired = true)); + resp.send(); + equal(true, resp.sent); + equal(true, fired); +}); + +add_task(function test_Response_sendError_sent() { + let resp = new Response(42, r => equal(false, r.sent)); + resp.sendError(new error.WebDriverError()); + ok(resp.sent); + Assert.throws(() => resp.send(), /already been sent/); +}); + +add_task(function test_Response_sendError_body() { + let resp = new Response(42, r => equal(null, r.body)); + resp.sendError(new error.WebDriverError()); +}); + +add_task(function test_Response_sendError_errorSerialisation() { + let err1 = new error.WebDriverError(); + let resp1 = new Response(42); + resp1.sendError(err1); + equal(err1.status, resp1.error.error); + deepEqual(err1.toJSON(), resp1.error); + + let err2 = new error.InvalidArgumentError(); + let resp2 = new Response(43); + resp2.sendError(err2); + equal(err2.status, resp2.error.error); + deepEqual(err2.toJSON(), resp2.error); +}); + +add_task(function test_Response_sendError_wrapInternalError() { + let err = new ReferenceError("foo"); + + // errors that originate from JavaScript (i.e. Marionette implementation + // issues) should be converted to UnknownError for transport + let resp = new Response(42, r => { + equal("unknown error", r.error.error); + equal(false, resp.sent); + }); + + // they should also throw after being sent + Assert.throws(() => resp.sendError(err), /foo/); + equal(true, resp.sent); +}); + +add_task(function test_Response_toPacket() { + let resp = new Response(42, () => {}); + let msg = resp.toPacket(); + + equal(Response.Type, msg[0]); + equal(resp.id, msg[1]); + equal(resp.error, msg[2]); + equal(resp.body, msg[3]); +}); + +add_task(function test_Response_toString() { + let resp = new Response(42, () => {}); + resp.error = "foo"; + resp.body = "bar"; + + equal(JSON.stringify(resp.toPacket()), resp.toString()); +}); + +add_task(function test_Response_fromPacket() { + let r1 = new Response(42, () => {}); + r1.error = "foo"; + r1.body = "bar"; + + let msg = r1.toPacket(); + let r2 = Response.fromPacket(msg); + + equal(r1.id, r2.id); + equal(r1.error, r2.error); + equal(r1.body, r2.body); + + Assert.throws( + () => Response.fromPacket([null, 2, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Response.fromPacket([0, 2, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Response.fromPacket([1, null, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Response.fromPacket([1, 2, null, {}]), + /InvalidArgumentError/ + ); + Response.fromPacket([1, 2, "foo", null]); +}); + +add_task(function test_Response_Type() { + equal(1, Response.Type); +}); diff --git a/remote/marionette/test/xpcshell/test_navigate.js b/remote/marionette/test/xpcshell/test_navigate.js new file mode 100644 index 0000000000..9b5e2a1bc7 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_navigate.js @@ -0,0 +1,90 @@ +/* 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/. */ + +const { navigate } = ChromeUtils.importESModule( + "chrome://remote/content/marionette/navigate.sys.mjs" +); + +const mockTopContext = { + get children() { + return [mockNestedContext]; + }, + id: 7, + get top() { + return this; + }, +}; + +const mockNestedContext = { + id: 8, + parent: mockTopContext, + top: mockTopContext, +}; + +add_task(function test_isLoadEventExpectedForCurrent() { + Assert.throws( + () => navigate.isLoadEventExpected(undefined), + /Expected at least one URL/ + ); + + ok(navigate.isLoadEventExpected(new URL("http://a/"))); +}); + +add_task(function test_isLoadEventExpectedForFuture() { + const data = [ + { current: "http://a/", future: undefined, expected: true }, + { current: "http://a/", future: "http://a/", expected: true }, + { current: "http://a/", future: "http://a/#", expected: true }, + { current: "http://a/#", future: "http://a/", expected: true }, + { current: "http://a/#a", future: "http://a/#A", expected: true }, + { current: "http://a/#a", future: "http://a/#a", expected: false }, + { current: "http://a/", future: "javascript:whatever", expected: false }, + ]; + + for (const entry of data) { + const current = new URL(entry.current); + const future = entry.future ? new URL(entry.future) : undefined; + equal(navigate.isLoadEventExpected(current, { future }), entry.expected); + } +}); + +add_task(function test_isLoadEventExpectedForTarget() { + for (const target of ["_parent", "_top"]) { + Assert.throws( + () => navigate.isLoadEventExpected(new URL("http://a"), { target }), + /Expected browsingContext when target is _parent or _top/ + ); + } + + const data = [ + { cur: "http://a/", target: "", expected: true }, + { cur: "http://a/", target: "_blank", expected: false }, + { cur: "http://a/", target: "_parent", bc: mockTopContext, expected: true }, + { + cur: "http://a/", + target: "_parent", + bc: mockNestedContext, + expected: false, + }, + { cur: "http://a/", target: "_self", expected: true }, + { cur: "http://a/", target: "_top", bc: mockTopContext, expected: true }, + { + cur: "http://a/", + target: "_top", + bc: mockNestedContext, + expected: false, + }, + ]; + + for (const entry of data) { + const current = entry.cur ? new URL(entry.cur) : undefined; + equal( + navigate.isLoadEventExpected(current, { + target: entry.target, + browsingContext: entry.bc, + }), + entry.expected + ); + } +}); diff --git a/remote/marionette/test/xpcshell/test_prefs.js b/remote/marionette/test/xpcshell/test_prefs.js new file mode 100644 index 0000000000..ac3432544b --- /dev/null +++ b/remote/marionette/test/xpcshell/test_prefs.js @@ -0,0 +1,98 @@ +/* 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 { Branch, EnvironmentPrefs, MarionettePrefs } = + ChromeUtils.importESModule( + "chrome://remote/content/marionette/prefs.sys.mjs" + ); + +function reset() { + Services.prefs.setBoolPref("test.bool", false); + Services.prefs.setStringPref("test.string", "foo"); + Services.prefs.setIntPref("test.int", 777); +} + +// Give us something to work with: +reset(); + +add_task(function test_Branch_get_root() { + let root = new Branch(null); + equal(false, root.get("test.bool")); + equal("foo", root.get("test.string")); + equal(777, root.get("test.int")); + Assert.throws(() => root.get("doesnotexist"), /TypeError/); +}); + +add_task(function test_Branch_get_branch() { + let test = new Branch("test."); + equal(false, test.get("bool")); + equal("foo", test.get("string")); + equal(777, test.get("int")); + Assert.throws(() => test.get("doesnotexist"), /TypeError/); +}); + +add_task(function test_Branch_set_root() { + let root = new Branch(null); + + try { + root.set("test.string", "bar"); + root.set("test.in", 777); + root.set("test.bool", true); + + equal("bar", Services.prefs.getStringPref("test.string")); + equal(777, Services.prefs.getIntPref("test.int")); + equal(true, Services.prefs.getBoolPref("test.bool")); + } finally { + reset(); + } +}); + +add_task(function test_Branch_set_branch() { + let test = new Branch("test."); + + try { + test.set("string", "bar"); + test.set("int", 888); + test.set("bool", true); + + equal("bar", Services.prefs.getStringPref("test.string")); + equal(888, Services.prefs.getIntPref("test.int")); + equal(true, Services.prefs.getBoolPref("test.bool")); + } finally { + reset(); + } +}); + +add_task(function test_EnvironmentPrefs_from() { + let prefsTable = { + "test.bool": true, + "test.int": 888, + "test.string": "bar", + }; + Services.env.set("FOO", JSON.stringify(prefsTable)); + + try { + for (let [key, value] of EnvironmentPrefs.from("FOO")) { + equal(prefsTable[key], value); + } + } finally { + Services.env.set("FOO", null); + } +}); + +add_task(function test_MarionettePrefs_getters() { + equal(false, MarionettePrefs.clickToStart); + equal(2828, MarionettePrefs.port); +}); + +add_task(function test_MarionettePrefs_setters() { + try { + MarionettePrefs.port = 777; + equal(777, MarionettePrefs.port); + } finally { + Services.prefs.clearUserPref("marionette.port"); + } +}); diff --git a/remote/marionette/test/xpcshell/test_sync.js b/remote/marionette/test/xpcshell/test_sync.js new file mode 100644 index 0000000000..87ec44e960 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_sync.js @@ -0,0 +1,419 @@ +/* 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/. */ + +const { + DebounceCallback, + IdlePromise, + PollPromise, + Sleep, + TimedPromise, + waitForMessage, + waitForObserverTopic, +} = ChromeUtils.importESModule( + "chrome://remote/content/marionette/sync.sys.mjs" +); + +/** + * Mimic a message manager for sending messages. + */ +class MessageManager { + constructor() { + this.func = null; + this.message = null; + } + + addMessageListener(message, func) { + this.func = func; + this.message = message; + } + + removeMessageListener(message) { + this.func = null; + this.message = null; + } + + send(message, data) { + if (this.func) { + this.func({ + data, + message, + target: this, + }); + } + } +} + +/** + * Mimics nsITimer, but instead of using a system clock you can + * preprogram it to invoke the callback after a given number of ticks. + */ +class MockTimer { + constructor(ticksBeforeFiring) { + this.goal = ticksBeforeFiring; + this.ticks = 0; + this.cancelled = false; + } + + initWithCallback(cb, timeout, type) { + this.ticks++; + if (this.ticks >= this.goal) { + cb(); + } + } + + cancel() { + this.cancelled = true; + } +} + +add_task(function test_executeSoon_callback() { + // executeSoon() is already defined for xpcshell in head.js. As such import + // our implementation into a custom namespace. + let sync = ChromeUtils.importESModule( + "chrome://remote/content/marionette/sync.sys.mjs" + ); + + for (let func of ["foo", null, true, [], {}]) { + Assert.throws(() => sync.executeSoon(func), /TypeError/); + } + + let a; + sync.executeSoon(() => { + a = 1; + }); + executeSoon(() => equal(1, a)); +}); + +add_task(function test_PollPromise_funcTypes() { + for (let type of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new PollPromise(type), /TypeError/); + } + new PollPromise(() => {}); + new PollPromise(function () {}); +}); + +add_task(function test_PollPromise_timeoutTypes() { + for (let timeout of ["foo", true, [], {}]) { + Assert.throws(() => new PollPromise(() => {}, { timeout }), /TypeError/); + } + for (let timeout of [1.2, -1]) { + Assert.throws(() => new PollPromise(() => {}, { timeout }), /RangeError/); + } + for (let timeout of [null, undefined, 42]) { + new PollPromise(resolve => resolve(1), { timeout }); + } +}); + +add_task(function test_PollPromise_intervalTypes() { + for (let interval of ["foo", null, true, [], {}]) { + Assert.throws(() => new PollPromise(() => {}, { interval }), /TypeError/); + } + for (let interval of [1.2, -1]) { + Assert.throws(() => new PollPromise(() => {}, { interval }), /RangeError/); + } + new PollPromise(() => {}, { interval: 42 }); +}); + +add_task(async function test_PollPromise_retvalTypes() { + for (let typ of [true, false, "foo", 42, [], {}]) { + strictEqual(typ, await new PollPromise(resolve => resolve(typ))); + } +}); + +add_task(async function test_PollPromise_rethrowError() { + let nevals = 0; + let err; + try { + await PollPromise(() => { + ++nevals; + throw new Error(); + }); + } catch (e) { + err = e; + } + equal(1, nevals); + ok(err instanceof Error); +}); + +add_task(async function test_PollPromise_noTimeout() { + let nevals = 0; + await new PollPromise((resolve, reject) => { + ++nevals; + nevals < 100 ? reject() : resolve(); + }); + equal(100, nevals); +}); + +add_task(async function test_PollPromise_zeroTimeout() { + // run at least once when timeout is 0 + let nevals = 0; + let start = new Date().getTime(); + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 0 } + ); + let end = new Date().getTime(); + equal(1, nevals); + less(end - start, 500); +}); + +add_task(async function test_PollPromise_timeoutElapse() { + let nevals = 0; + let start = new Date().getTime(); + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 100 } + ); + let end = new Date().getTime(); + lessOrEqual(nevals, 11); + greaterOrEqual(end - start, 100); +}); + +add_task(async function test_PollPromise_interval() { + let nevals = 0; + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 100, interval: 100 } + ); + equal(2, nevals); +}); + +add_task(function test_TimedPromise_funcTypes() { + for (let type of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new TimedPromise(type), /TypeError/); + } + new TimedPromise(resolve => resolve()); + new TimedPromise(function (resolve) { + resolve(); + }); +}); + +add_task(function test_TimedPromise_timeoutTypes() { + for (let timeout of ["foo", null, true, [], {}]) { + Assert.throws( + () => new TimedPromise(resolve => resolve(), { timeout }), + /TypeError/ + ); + } + for (let timeout of [1.2, -1]) { + Assert.throws( + () => new TimedPromise(resolve => resolve(), { timeout }), + /RangeError/ + ); + } + new TimedPromise(resolve => resolve(), { timeout: 42 }); +}); + +add_task(async function test_TimedPromise_errorMessage() { + try { + await new TimedPromise(resolve => {}, { timeout: 0 }); + ok(false, "Expected Timeout error not raised"); + } catch (e) { + ok( + e.message.includes("TimedPromise timed out after"), + "Expected default error message found" + ); + } + + try { + await new TimedPromise(resolve => {}, { + errorMessage: "Not found", + timeout: 0, + }); + ok(false, "Expected Timeout error not raised"); + } catch (e) { + ok( + e.message.includes("Not found after"), + "Expected custom error message found" + ); + } +}); + +add_task(async function test_Sleep() { + await Sleep(0); + for (let type of ["foo", true, null, undefined]) { + Assert.throws(() => new Sleep(type), /TypeError/); + } + Assert.throws(() => new Sleep(1.2), /RangeError/); + Assert.throws(() => new Sleep(-1), /RangeError/); +}); + +add_task(async function test_IdlePromise() { + let called = false; + let win = { + requestAnimationFrame(callback) { + called = true; + callback(); + }, + }; + await IdlePromise(win); + ok(called); +}); + +add_task(async function test_IdlePromiseAbortWhenWindowClosed() { + let win = { + closed: true, + requestAnimationFrame() {}, + }; + await IdlePromise(win); +}); + +add_task(function test_DebounceCallback_constructor() { + for (let cb of [42, "foo", true, null, undefined, [], {}]) { + Assert.throws(() => new DebounceCallback(cb), /TypeError/); + } + for (let timeout of ["foo", true, [], {}, () => {}]) { + Assert.throws( + () => new DebounceCallback(() => {}, { timeout }), + /TypeError/ + ); + } + for (let timeout of [-1, 2.3, NaN]) { + Assert.throws( + () => new DebounceCallback(() => {}, { timeout }), + /RangeError/ + ); + } +}); + +add_task(async function test_DebounceCallback_repeatedCallback() { + let uniqueEvent = {}; + let ncalls = 0; + + let cb = ev => { + ncalls++; + equal(ev, uniqueEvent); + }; + let debouncer = new DebounceCallback(cb); + debouncer.timer = new MockTimer(3); + + // flood the debouncer with events, + // we only expect the last one to fire + debouncer.handleEvent(uniqueEvent); + debouncer.handleEvent(uniqueEvent); + debouncer.handleEvent(uniqueEvent); + + equal(ncalls, 1); + ok(debouncer.timer.cancelled); +}); + +add_task(async function test_waitForMessage_messageManagerAndMessageTypes() { + let messageManager = new MessageManager(); + + for (let manager of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => waitForMessage(manager, "message"), /TypeError/); + } + + for (let message of [42, null, undefined, true, [], {}]) { + Assert.throws(() => waitForMessage(messageManager, message), /TypeError/); + } + + let data = { foo: "bar" }; + let sent = waitForMessage(messageManager, "message"); + messageManager.send("message", data); + equal(data, await sent); +}); + +add_task(async function test_waitForMessage_checkFnTypes() { + let messageManager = new MessageManager(); + + for (let checkFn of ["foo", 42, true, [], {}]) { + Assert.throws( + () => waitForMessage(messageManager, "message", { checkFn }), + /TypeError/ + ); + } + + let data1 = { fo: "bar" }; + let data2 = { foo: "bar" }; + + for (let checkFn of [null, undefined, msg => "foo" in msg.data]) { + let expected_data = checkFn == null ? data1 : data2; + + messageManager = new MessageManager(); + let sent = waitForMessage(messageManager, "message", { checkFn }); + messageManager.send("message", data1); + messageManager.send("message", data2); + equal(expected_data, await sent); + } +}); + +add_task(async function test_waitForObserverTopic_topicTypes() { + for (let topic of [42, null, undefined, true, [], {}]) { + Assert.throws(() => waitForObserverTopic(topic), /TypeError/); + } + + let data = { foo: "bar" }; + let sent = waitForObserverTopic("message"); + Services.obs.notifyObservers(this, "message", data); + let result = await sent; + equal(this, result.subject); + equal(data, result.data); +}); + +add_task(async function test_waitForObserverTopic_checkFnTypes() { + for (let checkFn of ["foo", 42, true, [], {}]) { + Assert.throws( + () => waitForObserverTopic("message", { checkFn }), + /TypeError/ + ); + } + + let data1 = { fo: "bar" }; + let data2 = { foo: "bar" }; + + for (let checkFn of [null, undefined, (subject, data) => data == data2]) { + let expected_data = checkFn == null ? data1 : data2; + + let sent = waitForObserverTopic("message"); + Services.obs.notifyObservers(this, "message", data1); + Services.obs.notifyObservers(this, "message", data2); + let result = await sent; + equal(expected_data, result.data); + } +}); + +add_task(async function test_waitForObserverTopic_timeoutTypes() { + for (let timeout of ["foo", true, [], {}]) { + Assert.throws( + () => waitForObserverTopic("message", { timeout }), + /TypeError/ + ); + } + for (let timeout of [1.2, -1]) { + Assert.throws( + () => waitForObserverTopic("message", { timeout }), + /RangeError/ + ); + } + for (let timeout of [null, undefined, 42]) { + let data = { foo: "bar" }; + let sent = waitForObserverTopic("message", { timeout }); + Services.obs.notifyObservers(this, "message", data); + let result = await sent; + equal(this, result.subject); + equal(data, result.data); + } +}); + +add_task(async function test_waitForObserverTopic_timeoutElapse() { + try { + await waitForObserverTopic("message", { timeout: 0 }); + ok(false, "Expected Timeout error not raised"); + } catch (e) { + ok( + e.message.includes("waitForObserverTopic timed out after"), + "Expected error received" + ); + } +}); diff --git a/remote/marionette/test/xpcshell/test_web-reference.js b/remote/marionette/test/xpcshell/test_web-reference.js new file mode 100644 index 0000000000..4884901ae5 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_web-reference.js @@ -0,0 +1,293 @@ +/* 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/. */ + +const { ShadowRoot, WebElement, WebFrame, WebReference, WebWindow } = + ChromeUtils.importESModule( + "chrome://remote/content/marionette/web-reference.sys.mjs" + ); +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +class MockElement { + constructor(tagName, attrs = {}) { + this.tagName = tagName; + this.localName = tagName; + + this.isConnected = false; + this.ownerGlobal = { + document: { + isActive() { + return true; + }, + }, + }; + + for (let attr in attrs) { + this[attr] = attrs[attr]; + } + } + + get nodeType() { + return 1; + } + + get ELEMENT_NODE() { + return 1; + } + + // this is a severely limited CSS selector + // that only supports lists of tag names + matches(selector) { + let tags = selector.split(","); + return tags.includes(this.localName); + } +} + +class MockXULElement extends MockElement { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = XUL_NS; + + if (typeof this.ownerDocument == "undefined") { + this.ownerDocument = {}; + } + if (typeof this.ownerDocument.documentElement == "undefined") { + this.ownerDocument.documentElement = { namespaceURI: XUL_NS }; + } + } +} + +const xulEl = new MockXULElement("text"); + +const domElInPrivilegedDocument = new MockElement("input", { + nodePrincipal: { isSystemPrincipal: true }, +}); +const xulElInPrivilegedDocument = new MockXULElement("text", { + nodePrincipal: { isSystemPrincipal: true }, +}); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + + browser.document.body.innerHTML = ` + <div id="foo" style="margin: 50px"> + <iframe></iframe> + <video></video> + <svg xmlns="http://www.w3.org/2000/svg"></svg> + <textarea></textarea> + </div> + `; + + const divEl = browser.document.querySelector("div"); + const svgEl = browser.document.querySelector("svg"); + const textareaEl = browser.document.querySelector("textarea"); + const videoEl = browser.document.querySelector("video"); + + const iframeEl = browser.document.querySelector("iframe"); + const childEl = iframeEl.contentDocument.createElement("div"); + iframeEl.contentDocument.body.appendChild(childEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + return { + browser, + childEl, + divEl, + iframeEl, + nodeCache: new NodeCache(), + shadowRoot, + svgEl, + textareaEl, + videoEl, + }; +} + +add_task(function test_WebReference_ctor() { + const el = new WebReference("foo"); + equal(el.uuid, "foo"); + + for (let t of [42, true, [], {}, null, undefined]) { + Assert.throws(() => new WebReference(t), /to be a string/); + } +}); + +add_task(function test_WebReference_from() { + const { divEl, iframeEl } = setupTest(); + + ok(WebReference.from(divEl) instanceof WebElement); + ok(WebReference.from(xulEl) instanceof WebElement); + ok(WebReference.from(divEl.ownerGlobal) instanceof WebWindow); + ok(WebReference.from(iframeEl.contentWindow) instanceof WebFrame); + ok(WebReference.from(domElInPrivilegedDocument) instanceof WebElement); + ok(WebReference.from(xulElInPrivilegedDocument) instanceof WebElement); + + Assert.throws(() => WebReference.from({}), /InvalidArgumentError/); +}); + +add_task(function test_WebReference_fromJSON_malformed() { + Assert.throws(() => WebReference.fromJSON({}), /InvalidArgumentError/); + Assert.throws(() => WebReference.fromJSON(null), /InvalidArgumentError/); +}); + +add_task(function test_WebReference_fromJSON_ShadowRoot() { + const { Identifier } = ShadowRoot; + + const ref = { [Identifier]: "foo" }; + const shadowRootEl = WebReference.fromJSON(ref); + ok(shadowRootEl instanceof ShadowRoot); + equal(shadowRootEl.uuid, "foo"); + + let identifierPrecedence = { + [Identifier]: "identifier-uuid", + }; + const precedenceShadowRoot = WebReference.fromJSON(identifierPrecedence); + ok(precedenceShadowRoot instanceof ShadowRoot); + equal(precedenceShadowRoot.uuid, "identifier-uuid"); +}); + +add_task(function test_WebReference_fromJSON_WebElement() { + const { Identifier } = WebElement; + + const ref = { [Identifier]: "foo" }; + const webEl = WebReference.fromJSON(ref); + ok(webEl instanceof WebElement); + equal(webEl.uuid, "foo"); + + let identifierPrecedence = { + [Identifier]: "identifier-uuid", + }; + const precedenceEl = WebReference.fromJSON(identifierPrecedence); + ok(precedenceEl instanceof WebElement); + equal(precedenceEl.uuid, "identifier-uuid"); +}); + +add_task(function test_WebReference_fromJSON_WebFrame() { + const ref = { [WebFrame.Identifier]: "foo" }; + const frame = WebReference.fromJSON(ref); + ok(frame instanceof WebFrame); + equal(frame.uuid, "foo"); +}); + +add_task(function test_WebReference_fromJSON_WebWindow() { + const ref = { [WebWindow.Identifier]: "foo" }; + const win = WebReference.fromJSON(ref); + + ok(win instanceof WebWindow); + equal(win.uuid, "foo"); +}); + +add_task(function test_WebReference_is() { + const a = new WebReference("a"); + const b = new WebReference("b"); + + ok(a.is(a)); + ok(b.is(b)); + ok(!a.is(b)); + ok(!b.is(a)); + + ok(!a.is({})); +}); + +add_task(function test_WebReference_isReference() { + for (let t of [42, true, "foo", [], {}]) { + ok(!WebReference.isReference(t)); + } + + ok(WebReference.isReference({ [WebElement.Identifier]: "foo" })); + ok(WebReference.isReference({ [WebWindow.Identifier]: "foo" })); + ok(WebReference.isReference({ [WebFrame.Identifier]: "foo" })); +}); + +add_task(function test_ShadowRoot_fromJSON() { + const { Identifier } = ShadowRoot; + + const shadowRoot = ShadowRoot.fromJSON({ [Identifier]: "foo" }); + ok(shadowRoot instanceof ShadowRoot); + equal(shadowRoot.uuid, "foo"); + + Assert.throws(() => ShadowRoot.fromJSON({}), /InvalidArgumentError/); +}); + +add_task(function test_ShadowRoot_fromUUID() { + const shadowRoot = ShadowRoot.fromUUID("baz"); + + ok(shadowRoot instanceof ShadowRoot); + equal(shadowRoot.uuid, "baz"); + + Assert.throws(() => ShadowRoot.fromUUID(), /InvalidArgumentError/); +}); + +add_task(function test_ShadowRoot_toJSON() { + const { Identifier } = ShadowRoot; + + const shadowRoot = new ShadowRoot("foo"); + const json = shadowRoot.toJSON(); + + ok(Identifier in json); + equal(json[Identifier], "foo"); +}); + +add_task(function test_WebElement_fromJSON() { + const { Identifier } = WebElement; + + const el = WebElement.fromJSON({ [Identifier]: "foo" }); + ok(el instanceof WebElement); + equal(el.uuid, "foo"); + + Assert.throws(() => WebElement.fromJSON({}), /InvalidArgumentError/); +}); + +add_task(function test_WebElement_fromUUID() { + const domWebEl = WebElement.fromUUID("bar"); + + ok(domWebEl instanceof WebElement); + equal(domWebEl.uuid, "bar"); + + Assert.throws(() => WebElement.fromUUID(), /InvalidArgumentError/); +}); + +add_task(function test_WebElement_toJSON() { + const { Identifier } = WebElement; + + const el = new WebElement("foo"); + const json = el.toJSON(); + + ok(Identifier in json); + equal(json[Identifier], "foo"); +}); + +add_task(function test_WebFrame_fromJSON() { + const ref = { [WebFrame.Identifier]: "foo" }; + const win = WebFrame.fromJSON(ref); + + ok(win instanceof WebFrame); + equal(win.uuid, "foo"); +}); + +add_task(function test_WebFrame_toJSON() { + const frame = new WebFrame("foo"); + const json = frame.toJSON(); + + ok(WebFrame.Identifier in json); + equal(json[WebFrame.Identifier], "foo"); +}); + +add_task(function test_WebWindow_fromJSON() { + const ref = { [WebWindow.Identifier]: "foo" }; + const win = WebWindow.fromJSON(ref); + + ok(win instanceof WebWindow); + equal(win.uuid, "foo"); +}); + +add_task(function test_WebWindow_toJSON() { + const win = new WebWindow("foo"); + const json = win.toJSON(); + + ok(WebWindow.Identifier in json); + equal(json[WebWindow.Identifier], "foo"); +}); diff --git a/remote/marionette/test/xpcshell/xpcshell.toml b/remote/marionette/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..46ee8581fd --- /dev/null +++ b/remote/marionette/test/xpcshell/xpcshell.toml @@ -0,0 +1,20 @@ +[DEFAULT] +skip-if = ["appname == 'thunderbird'"] + +["test_actors.js"] + +["test_browser.js"] + +["test_cookie.js"] + +["test_json.js"] + +["test_message.js"] + +["test_navigate.js"] + +["test_prefs.js"] + +["test_sync.js"] + +["test_web-reference.js"] diff --git a/remote/marionette/transport.sys.mjs b/remote/marionette/transport.sys.mjs new file mode 100644 index 0000000000..3c05c8603e --- /dev/null +++ b/remote/marionette/transport.sys.mjs @@ -0,0 +1,527 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + BulkPacket: "chrome://remote/content/marionette/packets.sys.mjs", + executeSoon: "chrome://remote/content/marionette/sync.sys.mjs", + JSONPacket: "chrome://remote/content/marionette/packets.sys.mjs", + Packet: "chrome://remote/content/marionette/packets.sys.mjs", + StreamUtils: "chrome://remote/content/marionette/stream-utils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "ScriptableInputStream", () => { + return Components.Constructor( + "@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init" + ); +}); + +const flags = { wantVerbose: false, wantLogging: false }; + +const dumpv = flags.wantVerbose + ? function (msg) { + dump(msg + "\n"); + } + : function () {}; + +const PACKET_HEADER_MAX = 200; + +/** + * An adapter that handles data transfers between the debugger client + * and server. It can work with both nsIPipe and nsIServerSocket + * transports so long as the properly created input and output streams + * are specified. (However, for intra-process connections, + * LocalDebuggerTransport, below, is more efficient than using an nsIPipe + * pair with DebuggerTransport.) + * + * @param {nsIAsyncInputStream} input + * The input stream. + * @param {nsIAsyncOutputStream} output + * The output stream. + * + * Given a DebuggerTransport instance dt: + * 1) Set dt.hooks to a packet handler object (described below). + * 2) Call dt.ready() to begin watching for input packets. + * 3) Call dt.send() / dt.startBulkSend() to send packets. + * 4) Call dt.close() to close the connection, and disengage from + * the event loop. + * + * A packet handler is an object with the following methods: + * + * - onPacket(packet) - called when we have received a complete packet. + * |packet| is the parsed form of the packet --- a JavaScript value, not + * a JSON-syntax string. + * + * - onBulkPacket(packet) - called when we have switched to bulk packet + * receiving mode. |packet| is an object containing: + * actor: Name of actor that will receive the packet + * type: Name of actor's method that should be called on receipt + * length: Size of the data to be read + * stream: This input stream should only be used directly if you + * can ensure that you will read exactly |length| bytes and + * will not close the stream when reading is complete + * done: If you use the stream directly (instead of |copyTo| + * below), you must signal completion by resolving/rejecting + * this deferred. If it's rejected, the transport will + * be closed. If an Error is supplied as a rejection value, + * it will be logged via |dump|. If you do use |copyTo|, + * resolving is taken care of for you when copying completes. + * copyTo: A helper function for getting your data out of the + * stream that meets the stream handling requirements above, + * and has the following signature: + * + * - params + * {nsIAsyncOutputStream} output + * The stream to copy to. + * - returns {Promise} + * The promise is resolved when copying completes or + * rejected if any (unexpected) errors occur. This object + * also emits "progress" events for each chunk that is + * copied. See stream-utils.js. + * + * - onClosed(reason) - called when the connection is closed. |reason| + * is an optional nsresult or object, typically passed when the + * transport is closed due to some error in a underlying stream. + * + * See ./packets.js and the Remote Debugging Protocol specification for + * more details on the format of these packets. + * + * @class + */ +export function DebuggerTransport(input, output) { + lazy.EventEmitter.decorate(this); + + this._input = input; + this._scriptableInput = new lazy.ScriptableInputStream(input); + this._output = output; + + // The current incoming (possibly partial) header, which will determine + // which type of Packet |_incoming| below will become. + this._incomingHeader = ""; + // The current incoming Packet object + this._incoming = null; + // A queue of outgoing Packet objects + this._outgoing = []; + + this.hooks = null; + this.active = false; + + this._incomingEnabled = true; + this._outgoingEnabled = true; + + this.close = this.close.bind(this); +} + +DebuggerTransport.prototype = { + /** + * Transmit an object as a JSON packet. + * + * This method returns immediately, without waiting for the entire + * packet to be transmitted, registering event handlers as needed to + * transmit the entire packet. Packets are transmitted in the order they + * are passed to this method. + */ + send(object) { + this.emit("send", object); + + let packet = new lazy.JSONPacket(this); + packet.object = object; + this._outgoing.push(packet); + this._flushOutgoing(); + }, + + /** + * Transmit streaming data via a bulk packet. + * + * This method initiates the bulk send process by queuing up the header + * data. The caller receives eventual access to a stream for writing. + * + * N.B.: Do *not* attempt to close the stream handed to you, as it + * will continue to be used by this transport afterwards. Most users + * should instead use the provided |copyFrom| function instead. + * + * @param {object} header + * This is modeled after the format of JSON packets above, but does + * not actually contain the data, but is instead just a routing + * header: + * + * - actor: Name of actor that will receive the packet + * - type: Name of actor's method that should be called on receipt + * - length: Size of the data to be sent + * + * @returns {Promise} + * The promise will be resolved when you are allowed to write to + * the stream with an object containing: + * + * - stream: This output stream should only be used directly + * if you can ensure that you will write exactly + * |length| bytes and will not close the stream when + * writing is complete. + * - done: If you use the stream directly (instead of + * |copyFrom| below), you must signal completion by + * resolving/rejecting this deferred. If it's + * rejected, the transport will be closed. If an + * Error is supplied as a rejection value, it will + * be logged via |dump|. If you do use |copyFrom|, + * resolving is taken care of for you when copying + * completes. + * - copyFrom: A helper function for getting your data onto the + * stream that meets the stream handling requirements + * above, and has the following signature: + * + * - params + * {nsIAsyncInputStream} input + * The stream to copy from. + * - returns {Promise} + * The promise is resolved when copying completes + * or rejected if any (unexpected) errors occur. + * This object also emits "progress" events for + * each chunkthat is copied. See stream-utils.js. + */ + startBulkSend(header) { + this.emit("startbulksend", header); + + let packet = new lazy.BulkPacket(this); + packet.header = header; + this._outgoing.push(packet); + this._flushOutgoing(); + return packet.streamReadyForWriting; + }, + + /** + * Close the transport. + * + * @param {(nsresult|object)=} reason + * The status code or error message that corresponds to the reason + * for closing the transport (likely because a stream closed + * or failed). + */ + close(reason) { + this.emit("close", reason); + + this.active = false; + this._input.close(); + this._scriptableInput.close(); + this._output.close(); + this._destroyIncoming(); + this._destroyAllOutgoing(); + if (this.hooks) { + this.hooks.onClosed(reason); + this.hooks = null; + } + if (reason) { + dumpv("Transport closed: " + reason); + } else { + dumpv("Transport closed."); + } + }, + + /** + * The currently outgoing packet (at the top of the queue). + */ + get _currentOutgoing() { + return this._outgoing[0]; + }, + + /** + * Flush data to the outgoing stream. Waits until the output + * stream notifies us that it is ready to be written to (via + * onOutputStreamReady). + */ + _flushOutgoing() { + if (!this._outgoingEnabled || this._outgoing.length === 0) { + return; + } + + // If the top of the packet queue has nothing more to send, remove it. + if (this._currentOutgoing.done) { + this._finishCurrentOutgoing(); + } + + if (this._outgoing.length) { + let threadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + this._output.asyncWait(this, 0, 0, threadManager.currentThread); + } + }, + + /** + * Pause this transport's attempts to write to the output stream. + * This is used when we've temporarily handed off our output stream for + * writing bulk data. + */ + pauseOutgoing() { + this._outgoingEnabled = false; + }, + + /** + * Resume this transport's attempts to write to the output stream. + */ + resumeOutgoing() { + this._outgoingEnabled = true; + this._flushOutgoing(); + }, + + // nsIOutputStreamCallback + /** + * This is called when the output stream is ready for more data to + * be written. The current outgoing packet will attempt to write some + * amount of data, but may not complete. + */ + onOutputStreamReady(stream) { + if (!this._outgoingEnabled || this._outgoing.length === 0) { + return; + } + + try { + this._currentOutgoing.write(stream); + } catch (e) { + if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this.close(e.result); + return; + } + throw e; + } + + this._flushOutgoing(); + }, + + /** + * Remove the current outgoing packet from the queue upon completion. + */ + _finishCurrentOutgoing() { + if (this._currentOutgoing) { + this._currentOutgoing.destroy(); + this._outgoing.shift(); + } + }, + + /** + * Clear the entire outgoing queue. + */ + _destroyAllOutgoing() { + for (let packet of this._outgoing) { + packet.destroy(); + } + this._outgoing = []; + }, + + /** + * Initialize the input stream for reading. Once this method has been + * called, we watch for packets on the input stream, and pass them to + * the appropriate handlers via this.hooks. + */ + ready() { + this.active = true; + this._waitForIncoming(); + }, + + /** + * Asks the input stream to notify us (via onInputStreamReady) when it is + * ready for reading. + */ + _waitForIncoming() { + if (this._incomingEnabled) { + let threadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + this._input.asyncWait(this, 0, 0, threadManager.currentThread); + } + }, + + /** + * Pause this transport's attempts to read from the input stream. + * This is used when we've temporarily handed off our input stream for + * reading bulk data. + */ + pauseIncoming() { + this._incomingEnabled = false; + }, + + /** + * Resume this transport's attempts to read from the input stream. + */ + resumeIncoming() { + this._incomingEnabled = true; + this._flushIncoming(); + this._waitForIncoming(); + }, + + // nsIInputStreamCallback + /** + * Called when the stream is either readable or closed. + */ + onInputStreamReady(stream) { + try { + while ( + stream.available() && + this._incomingEnabled && + this._processIncoming(stream, stream.available()) + ) { + // Loop until there is nothing more to process + } + this._waitForIncoming(); + } catch (e) { + if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this.close(e.result); + } else { + throw e; + } + } + }, + + /** + * Process the incoming data. Will create a new currently incoming + * Packet if needed. Tells the incoming Packet to read as much data + * as it can, but reading may not complete. The Packet signals that + * its data is ready for delivery by calling one of this transport's + * _on*Ready methods (see ./packets.js and the _on*Ready methods below). + * + * @returns {boolean} + * Whether incoming stream processing should continue for any + * remaining data. + */ + _processIncoming(stream, count) { + dumpv("Data available: " + count); + + if (!count) { + dumpv("Nothing to read, skipping"); + return false; + } + + try { + if (!this._incoming) { + dumpv("Creating a new packet from incoming"); + + if (!this._readHeader(stream)) { + // Not enough data to read packet type + return false; + } + + // Attempt to create a new Packet by trying to parse each possible + // header pattern. + this._incoming = lazy.Packet.fromHeader(this._incomingHeader, this); + if (!this._incoming) { + throw new Error( + "No packet types for header: " + this._incomingHeader + ); + } + } + + if (!this._incoming.done) { + // We have an incomplete packet, keep reading it. + dumpv("Existing packet incomplete, keep reading"); + this._incoming.read(stream, this._scriptableInput); + } + } catch (e) { + dump(`Error reading incoming packet: (${e} - ${e.stack})\n`); + + // Now in an invalid state, shut down the transport. + this.close(); + return false; + } + + if (!this._incoming.done) { + // Still not complete, we'll wait for more data. + dumpv("Packet not done, wait for more"); + return true; + } + + // Ready for next packet + this._flushIncoming(); + return true; + }, + + /** + * Read as far as we can into the incoming data, attempting to build + * up a complete packet header (which terminates with ":"). We'll only + * read up to PACKET_HEADER_MAX characters. + * + * @returns {boolean} + * True if we now have a complete header. + */ + _readHeader() { + let amountToRead = PACKET_HEADER_MAX - this._incomingHeader.length; + this._incomingHeader += lazy.StreamUtils.delimitedRead( + this._scriptableInput, + ":", + amountToRead + ); + if (flags.wantVerbose) { + dumpv("Header read: " + this._incomingHeader); + } + + if (this._incomingHeader.endsWith(":")) { + if (flags.wantVerbose) { + dumpv("Found packet header successfully: " + this._incomingHeader); + } + return true; + } + + if (this._incomingHeader.length >= PACKET_HEADER_MAX) { + throw new Error("Failed to parse packet header!"); + } + + // Not enough data yet. + return false; + }, + + /** + * If the incoming packet is done, log it as needed and clear the buffer. + */ + _flushIncoming() { + if (!this._incoming.done) { + return; + } + if (flags.wantLogging) { + dumpv("Got: " + this._incoming); + } + this._destroyIncoming(); + }, + + /** + * Handler triggered by an incoming JSONPacket completing it's |read| + * method. Delivers the packet to this.hooks.onPacket. + */ + _onJSONObjectReady(object) { + lazy.executeSoon(() => { + // Ensure the transport is still alive by the time this runs. + if (this.active) { + this.emit("packet", object); + this.hooks.onPacket(object); + } + }); + }, + + /** + * Handler triggered by an incoming BulkPacket entering the |read| + * phase for the stream portion of the packet. Delivers info about the + * incoming streaming data to this.hooks.onBulkPacket. See the main + * comment on the transport at the top of this file for more details. + */ + _onBulkReadReady(...args) { + lazy.executeSoon(() => { + // Ensure the transport is still alive by the time this runs. + if (this.active) { + this.emit("bulkpacket", ...args); + this.hooks.onBulkPacket(...args); + } + }); + }, + + /** + * Remove all handlers and references related to the current incoming + * packet, either because it is now complete or because the transport + * is closing. + */ + _destroyIncoming() { + if (this._incoming) { + this._incoming.destroy(); + } + this._incomingHeader = ""; + this._incoming = null; + }, +}; diff --git a/remote/marionette/web-reference.sys.mjs b/remote/marionette/web-reference.sys.mjs new file mode 100644 index 0000000000..5d2d510265 --- /dev/null +++ b/remote/marionette/web-reference.sys.mjs @@ -0,0 +1,297 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +/** + * A web reference is an abstraction used to identify an element when + * it is transported via the protocol, between remote- and local ends. + * + * In Marionette this abstraction can represent DOM elements, + * WindowProxies, and XUL elements. + */ +export class WebReference { + /** + * @param {string} uuid + * Identifier that must be unique across all browsing contexts + * for the contract to be upheld. + */ + constructor(uuid) { + this.uuid = lazy.assert.string(uuid); + } + + /** + * Performs an equality check between this web element and + * <var>other</var>. + * + * @param {WebReference} other + * Web element to compare with this. + * + * @returns {boolean} + * True if this and <var>other</var> are the same. False + * otherwise. + */ + is(other) { + return other instanceof WebReference && this.uuid === other.uuid; + } + + toString() { + return `[object ${this.constructor.name} uuid=${this.uuid}]`; + } + + /** + * Returns a new {@link WebReference} reference for a DOM or XUL element, + * <code>WindowProxy</code>, or <code>ShadowRoot</code>. + * + * @param {(Element|ShadowRoot|WindowProxy|MockXULElement)} node + * Node to construct a web element reference for. + * @param {string=} uuid + * Optional unique identifier of the WebReference if already known. + * If not defined a new unique identifier will be created. + * + * @returns {WebReference} + * Web reference for <var>node</var>. + * + * @throws {InvalidArgumentError} + * If <var>node</var> is neither a <code>WindowProxy</code>, + * DOM or XUL element, or <code>ShadowRoot</code>. + */ + static from(node, uuid) { + if (uuid === undefined) { + uuid = lazy.generateUUID(); + } + + if (lazy.dom.isShadowRoot(node) && !lazy.dom.isInPrivilegedDocument(node)) { + // When we support Chrome Shadowroots we will need to + // do a check here of shadowroot.host being in a privileged document + // See Bug 1743541 + return new ShadowRoot(uuid); + } else if (lazy.dom.isElement(node)) { + return new WebElement(uuid); + } else if (lazy.dom.isDOMWindow(node)) { + if (node.parent === node) { + return new WebWindow(uuid); + } + return new WebFrame(uuid); + } + + throw new lazy.error.InvalidArgumentError( + "Expected DOM window/element " + lazy.pprint`or XUL element, got: ${node}` + ); + } + + /** + * Unmarshals a JSON Object to one of {@link ShadowRoot}, {@link WebElement}, + * {@link WebFrame}, or {@link WebWindow}. + * + * @param {Object<string, string>} json + * Web reference, which is supposed to be a JSON Object + * where the key is one of the {@link WebReference} concrete + * classes' UUID identifiers. + * + * @returns {WebReference} + * Web reference for the JSON object. + * + * @throws {InvalidArgumentError} + * If <var>json</var> is not a web reference. + */ + static fromJSON(json) { + lazy.assert.object(json); + if (json instanceof WebReference) { + return json; + } + let keys = Object.keys(json); + + for (let key of keys) { + switch (key) { + case ShadowRoot.Identifier: + return ShadowRoot.fromJSON(json); + + case WebElement.Identifier: + return WebElement.fromJSON(json); + + case WebFrame.Identifier: + return WebFrame.fromJSON(json); + + case WebWindow.Identifier: + return WebWindow.fromJSON(json); + } + } + + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected web reference, got: ${json}` + ); + } + + /** + * Checks if <var>obj<var> is a {@link WebReference} reference. + * + * @param {Object<string, string>} obj + * Object that represents a {@link WebReference}. + * + * @returns {boolean} + * True if <var>obj</var> is a {@link WebReference}, false otherwise. + */ + static isReference(obj) { + if (Object.prototype.toString.call(obj) != "[object Object]") { + return false; + } + + if ( + ShadowRoot.Identifier in obj || + WebElement.Identifier in obj || + WebFrame.Identifier in obj || + WebWindow.Identifier in obj + ) { + return true; + } + return false; + } +} + +/** + * Shadow Root elements are represented as shadow root references when they are + * transported over the wire protocol + */ +export class ShadowRoot extends WebReference { + toJSON() { + return { [ShadowRoot.Identifier]: this.uuid }; + } + + static fromJSON(json) { + const { Identifier } = ShadowRoot; + + if (!(Identifier in json)) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected shadow root reference, got: ${json}` + ); + } + + let uuid = json[Identifier]; + return new ShadowRoot(uuid); + } + + /** + * Constructs a {@link ShadowRoot} from a string <var>uuid</var>. + * + * This whole function is a workaround for the fact that clients + * to Marionette occasionally pass <code>{id: <uuid>}</code> JSON + * Objects instead of shadow root representations. + * + * @param {string} uuid + * UUID to be associated with the web reference. + * + * @returns {ShadowRoot} + * The shadow root reference. + * + * @throws {InvalidArgumentError} + * If <var>uuid</var> is not a string. + */ + static fromUUID(uuid) { + lazy.assert.string(uuid); + + return new ShadowRoot(uuid); + } +} + +ShadowRoot.Identifier = "shadow-6066-11e4-a52e-4f735466cecf"; + +/** + * DOM elements are represented as web elements when they are + * transported over the wire protocol. + */ +export class WebElement extends WebReference { + toJSON() { + return { [WebElement.Identifier]: this.uuid }; + } + + static fromJSON(json) { + const { Identifier } = WebElement; + + if (!(Identifier in json)) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected web element reference, got: ${json}` + ); + } + + let uuid = json[Identifier]; + return new WebElement(uuid); + } + + /** + * Constructs a {@link WebElement} from a string <var>uuid</var>. + * + * This whole function is a workaround for the fact that clients + * to Marionette occasionally pass <code>{id: <uuid>}</code> JSON + * Objects instead of web element representations. + * + * @param {string} uuid + * UUID to be associated with the web reference. + * + * @returns {WebElement} + * The web element reference. + * + * @throws {InvalidArgumentError} + * If <var>uuid</var> is not a string. + */ + static fromUUID(uuid) { + return new WebElement(uuid); + } +} + +WebElement.Identifier = "element-6066-11e4-a52e-4f735466cecf"; + +/** + * Nested browsing contexts, such as the <code>WindowProxy</code> + * associated with <tt><frame></tt> and <tt><iframe></tt>, + * are represented as web frames over the wire protocol. + */ +export class WebFrame extends WebReference { + toJSON() { + return { [WebFrame.Identifier]: this.uuid }; + } + + static fromJSON(json) { + if (!(WebFrame.Identifier in json)) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected web frame reference, got: ${json}` + ); + } + let uuid = json[WebFrame.Identifier]; + return new WebFrame(uuid); + } +} + +WebFrame.Identifier = "frame-075b-4da1-b6ba-e579c2d3230a"; + +/** + * Top-level browsing contexts, such as <code>WindowProxy</code> + * whose <code>opener</code> is null, are represented as web windows + * over the wire protocol. + */ +export class WebWindow extends WebReference { + toJSON() { + return { [WebWindow.Identifier]: this.uuid }; + } + + static fromJSON(json) { + if (!(WebWindow.Identifier in json)) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected web window reference, got: ${json}` + ); + } + let uuid = json[WebWindow.Identifier]; + return new WebWindow(uuid); + } +} + +WebWindow.Identifier = "window-fcc6-11e5-b4f8-330a88ab9d7f"; diff --git a/remote/marionette/webauthn.sys.mjs b/remote/marionette/webauthn.sys.mjs new file mode 100644 index 0000000000..c52bf6cb5c --- /dev/null +++ b/remote/marionette/webauthn.sys.mjs @@ -0,0 +1,134 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "webauthnService", + "@mozilla.org/webauthn/service;1", + "nsIWebAuthnService" +); + +/** @namespace */ +export const webauthn = {}; + +/** + * Add a virtual authenticator. + * + * @param {string} protocol one of "ctap1/u2f", "ctap2", "ctap2_1" + * @param {string} transport one of "usb", "nfc", "ble", "smart-card", + * "hybrid", "internal" + * @param {boolean} hasResidentKey + * @param {boolean} hasUserVerification + * @param {boolean} isUserConsenting + * @param {boolean} isUserVerified + * @returns {id} the id of the added authenticator + */ +webauthn.addVirtualAuthenticator = function ( + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified +) { + return lazy.webauthnService.addVirtualAuthenticator( + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified + ); +}; + +/** + * Removes a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + */ +webauthn.removeVirtualAuthenticator = function (authenticatorId) { + lazy.webauthnService.removeVirtualAuthenticator(authenticatorId); +}; + +/** + * Adds a credential to a previously-added virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + * @param {string} credentialId a probabilistically-unique byte sequence + * identifying a public key credential source and its + * authentication assertions (encoded using Base64url + * Encoding). + * @param {boolean} isResidentCredential if set to true, a client-side + * discoverable credential is created. If set to false, a + * server-side credential is created instead. + * @param {string} rpId The Relying Party ID the credential is scoped to. + * @param {string} privateKey An asymmetric key package containing a single + * private key per RFC5958, encoded using Base64url Encoding. + * @param {string} userHandle The userHandle associated to the credential + * encoded using Base64url Encoding. + * @param {number} signCount The initial value for a signature counter + * associated to the public key credential source. + */ +webauthn.addCredential = function ( + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount +) { + lazy.webauthnService.addCredential( + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount + ); +}; + +/** + * Gets all credentials from a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + * @returns {object} the credentials on the authenticator + */ +webauthn.getCredentials = function (authenticatorId) { + return lazy.webauthnService.getCredentials(authenticatorId); +}; + +/** + * Removes a credential from a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + * @param {string} credentialId the id of the credential + */ +webauthn.removeCredential = function (authenticatorId, credentialId) { + lazy.webauthnService.removeCredential(authenticatorId, credentialId); +}; + +/** + * Removes all credentials from a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + */ +webauthn.removeAllCredentials = function (authenticatorId) { + lazy.webauthnService.removeAllCredentials(authenticatorId); +}; + +/** + * Sets the "isUserVerified" bit on a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + * @param {bool} isUserVerified the value to set the "isUserVerified" bit to + */ +webauthn.setUserVerified = function (authenticatorId, isUserVerified) { + lazy.webauthnService.setUserVerified(authenticatorId, isUserVerified); +}; diff --git a/remote/moz.build b/remote/moz.build new file mode 100644 index 0000000000..e508edcb31 --- /dev/null +++ b/remote/moz.build @@ -0,0 +1,22 @@ +# 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/. + +DIRS += [ + "cdp", + "components", + "marionette", + "shared", + "webdriver-bidi", +] + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Remote Protocol", "Agent") + +with Files("doc/**"): + SCHEDULES.exclusive = ["docs"] + +SPHINX_TREES["/remote"] = "doc" +SPHINX_TREES["/testing/marionette"] = "doc/marionette" diff --git a/remote/server/README b/remote/server/README new file mode 100644 index 0000000000..00184130d3 --- /dev/null +++ b/remote/server/README @@ -0,0 +1,8 @@ +These files provide functionality for serving and responding to HTTP +requests, and handling WebSocket connections. For this we rely on +httpd.js and the chrome-only WebSocket.createServerWebSocket function. + +Generally speaking, this is all held together with a piece of string. +It is a known problem that we do not have a high-quality HTTPD +implementation in central, and we’d like to move away from using +any of this code. diff --git a/remote/server/WebSocketHandshake.sys.mjs b/remote/server/WebSocketHandshake.sys.mjs new file mode 100644 index 0000000000..f137b484ae --- /dev/null +++ b/remote/server/WebSocketHandshake.sys.mjs @@ -0,0 +1,315 @@ +/* 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/. */ + +// This file is an XPCOM service-ified copy of ../devtools/server/socket/websocket-server.js. + +const CC = Components.Constructor; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + executeSoon: "chrome://remote/content/shared/Sync.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +ChromeUtils.defineLazyGetter(lazy, "CryptoHash", () => { + return CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString"); +}); + +ChromeUtils.defineLazyGetter(lazy, "threadManager", () => { + return Cc["@mozilla.org/thread-manager;1"].getService(); +}); + +/** + * Allowed origins are exposed through 2 separate getters because while most + * of the values should be valid URIs, `null` is also a valid origin and cannot + * be converted to a URI. Call sites interested in checking for null should use + * `allowedOrigins`, those interested in URIs should use `allowedOriginURIs`. + */ +ChromeUtils.defineLazyGetter(lazy, "allowedOrigins", () => + lazy.RemoteAgent.allowOrigins !== null ? lazy.RemoteAgent.allowOrigins : [] +); + +ChromeUtils.defineLazyGetter(lazy, "allowedOriginURIs", () => { + return lazy.allowedOrigins + .map(origin => { + try { + const originURI = Services.io.newURI(origin); + // Make sure to read host/port/scheme as those getters could throw for + // invalid URIs. + return { + host: originURI.host, + port: originURI.port, + scheme: originURI.scheme, + }; + } catch (e) { + return null; + } + }) + .filter(uri => uri !== null); +}); + +/** + * Write a string of bytes to async output stream + * and return promise that resolves once all data has been written. + * Doesn't do any UTF-16/UTF-8 conversion. + * The string is treated as an array of bytes. + */ +function writeString(output, data) { + return new Promise((resolve, reject) => { + const wait = () => { + if (data.length === 0) { + resolve(); + return; + } + + output.asyncWait( + stream => { + try { + const written = output.write(data, data.length); + data = data.slice(written); + wait(); + } catch (ex) { + reject(ex); + } + }, + 0, + 0, + lazy.threadManager.currentThread + ); + }; + + wait(); + }); +} + +/** + * Write HTTP response with headers (array of strings) and body + * to async output stream. + */ +function writeHttpResponse(output, headers, body = "") { + headers.push(`Content-Length: ${body.length}`); + + const s = headers.join("\r\n") + `\r\n\r\n${body}`; + return writeString(output, s); +} + +/** + * Check if the provided URI's host is an IP address. + * + * @param {nsIURI} uri + * The URI to check. + * @returns {boolean} + */ +function isIPAddress(uri) { + try { + // getBaseDomain throws an explicit error if the uri host is an IP address. + Services.eTLD.getBaseDomain(uri); + } catch (e) { + return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS; + } + return false; +} + +function isHostValid(hostHeader) { + try { + // Might throw both when calling newURI or when accessing the host/port. + const hostUri = Services.io.newURI(`https://${hostHeader}`); + const { host, port } = hostUri; + const isHostnameValid = + isIPAddress(hostUri) || lazy.RemoteAgent.allowHosts.includes(host); + // For nsIURI a port value of -1 corresponds to the protocol's default port. + const isPortValid = [-1, lazy.RemoteAgent.port].includes(port); + return isHostnameValid && isPortValid; + } catch (e) { + return false; + } +} + +function isOriginValid(originHeader) { + if (originHeader === undefined) { + // Always accept no origin header. + return true; + } + + // Special case "null" origins, used for privacy sensitive or opaque origins. + if (originHeader === "null") { + return lazy.allowedOrigins.includes("null"); + } + + try { + // Extract the host, port and scheme from the provided origin header. + const { host, port, scheme } = Services.io.newURI(originHeader); + // Check if any allowed origin matches the provided host, port and scheme. + return lazy.allowedOriginURIs.some( + uri => uri.host === host && uri.port === port && uri.scheme === scheme + ); + } catch (e) { + // Reject invalid origin headers + return false; + } +} + +/** + * Process the WebSocket handshake headers and return the key to be sent in + * Sec-WebSocket-Accept response header. + */ +function processRequest({ requestLine, headers }) { + if (!isOriginValid(headers.get("origin"))) { + lazy.logger.debug( + `Incorrect Origin header, allowed origins: [${lazy.allowedOrigins}]` + ); + throw new Error( + `The handshake request has incorrect Origin header ${headers.get( + "origin" + )}` + ); + } + + if (!isHostValid(headers.get("host"))) { + lazy.logger.debug( + `Incorrect Host header, allowed hosts: [${lazy.RemoteAgent.allowHosts}]` + ); + throw new Error( + `The handshake request has incorrect Host header ${headers.get("host")}` + ); + } + + const method = requestLine.split(" ")[0]; + if (method !== "GET") { + throw new Error("The handshake request must use GET method"); + } + + const upgrade = headers.get("upgrade"); + if (!upgrade || upgrade.toLowerCase() !== "websocket") { + throw new Error( + `The handshake request has incorrect Upgrade header: ${upgrade}` + ); + } + + const connection = headers.get("connection"); + if ( + !connection || + !connection + .split(",") + .map(t => t.trim().toLowerCase()) + .includes("upgrade") + ) { + throw new Error("The handshake request has incorrect Connection header"); + } + + const version = headers.get("sec-websocket-version"); + if (!version || version !== "13") { + throw new Error( + "The handshake request must have Sec-WebSocket-Version: 13" + ); + } + + // Compute the accept key + const key = headers.get("sec-websocket-key"); + if (!key) { + throw new Error( + "The handshake request must have a Sec-WebSocket-Key header" + ); + } + + return { acceptKey: computeKey(key) }; +} + +function computeKey(key) { + const str = `${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`; + const data = Array.from(str, ch => ch.charCodeAt(0)); + const hash = new lazy.CryptoHash("sha1"); + hash.update(data, data.length); + return hash.finish(true); +} + +/** + * Perform the server part of a WebSocket opening handshake + * on an incoming connection. + */ +async function serverHandshake(request, output) { + try { + // Check and extract info from the request + const { acceptKey } = processRequest(request); + + // Send response headers + await writeHttpResponse(output, [ + "HTTP/1.1 101 Switching Protocols", + "Server: httpd.js", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${acceptKey}`, + ]); + } catch (error) { + // Send error response in case of error + await writeHttpResponse( + output, + [ + "HTTP/1.1 400 Bad Request", + "Server: httpd.js", + "Content-Type: text/plain", + ], + error.message + ); + + throw error; + } +} + +async function createWebSocket(transport, input, output) { + const transportProvider = { + setListener(upgradeListener) { + // onTransportAvailable callback shouldn't be called synchronously + lazy.executeSoon(() => { + upgradeListener.onTransportAvailable(transport, input, output); + }); + }, + }; + + return new Promise((resolve, reject) => { + const socket = WebSocket.createServerWebSocket( + null, + [], + transportProvider, + "" + ); + socket.addEventListener("close", () => { + input.close(); + output.close(); + }); + + socket.onopen = () => resolve(socket); + socket.onerror = err => reject(err); + }); +} + +/** Upgrade an existing HTTP request from httpd.js to WebSocket. */ +async function upgrade(request, response) { + // handle response manually, allowing us to send arbitrary data + response._powerSeized = true; + + const { transport, input, output } = response._connection; + + lazy.logger.info( + `Perform WebSocket upgrade for incoming connection from ${transport.host}:${transport.port}` + ); + + const headers = new Map(); + for (let [key, values] of Object.entries(request._headers._headers)) { + headers.set(key, values.join("\n")); + } + const convertedRequest = { + requestLine: `${request.method} ${request.path}`, + headers, + }; + await serverHandshake(convertedRequest, output); + + return createWebSocket(transport, input, output); +} + +export const WebSocketHandshake = { upgrade }; diff --git a/remote/server/WebSocketTransport.sys.mjs b/remote/server/WebSocketTransport.sys.mjs new file mode 100644 index 0000000000..0ac6a6399a --- /dev/null +++ b/remote/server/WebSocketTransport.sys.mjs @@ -0,0 +1,130 @@ +/* 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/. */ + +// This is an XPCOM service-ified copy of ../devtools/shared/transport/websocket-transport.js. + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +export function WebSocketTransport(socket) { + lazy.EventEmitter.decorate(this); + + this.active = false; + this.hooks = null; + this.socket = socket; +} + +WebSocketTransport.prototype = { + ready() { + if (this.active) { + return; + } + + this.socket.addEventListener("message", this); + this.socket.addEventListener("close", this); + + this.active = true; + }, + + send(object) { + this.emit("send", object); + if (this.socket) { + this.socket.send(JSON.stringify(object)); + } + }, + + startBulkSend() { + throw new Error("Bulk send is not supported by WebSocket transport"); + }, + + /** + * Force closing the active connection and WebSocket. + */ + close() { + if (!this.socket) { + return; + } + this.emit("close"); + + if (this.hooks) { + this.hooks.onConnectionClose(); + } + + this.active = false; + + this.socket.removeEventListener("message", this); + + // Remove the listener that is used when the connection + // is closed because we already emitted the 'close' event. + // Instead listen for the closing of the WebSocket. + this.socket.removeEventListener("close", this); + this.socket.addEventListener("close", this.onSocketClose.bind(this)); + + // Close socket with code `1000` for a normal closure. + this.socket.close(1000); + }, + + /** + * Callback for socket on close event, + * it is used in case websocket was closed by the client. + */ + onClose() { + if (!this.socket) { + return; + } + this.emit("close"); + + if (this.hooks) { + this.hooks.onConnectionClose(); + this.hooks.onSocketClose(); + this.hooks = null; + } + + this.active = false; + + this.socket.removeEventListener("message", this); + this.socket.removeEventListener("close", this); + this.socket = null; + }, + + /** + * Callback which is called when we can be sure that websocket is closed. + */ + onSocketClose() { + this.socket = null; + + if (this.hooks) { + this.hooks.onSocketClose(); + this.hooks = null; + } + }, + + handleEvent(event) { + switch (event.type) { + case "message": + this.onMessage(event); + break; + case "close": + this.onClose(); + break; + } + }, + + onMessage({ data }) { + if (typeof data !== "string") { + throw new Error( + "Binary messages are not supported by WebSocket transport" + ); + } + + const object = JSON.parse(data); + this.emit("packet", object); + if (this.hooks) { + this.hooks.onPacket(object); + } + }, +}; diff --git a/remote/shared/AppInfo.sys.mjs b/remote/shared/AppInfo.sys.mjs new file mode 100644 index 0000000000..9e354503ef --- /dev/null +++ b/remote/shared/AppInfo.sys.mjs @@ -0,0 +1,78 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const ID_FIREFOX = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; +const ID_THUNDERBIRD = "{3550f703-e582-4d05-9a08-453d09bdfdc6}"; + +/** + * Extends Services.appinfo with further properties that are + * used by different protocols as handled by the Remote Agent. + * + * @typedef {object} RemoteAgent.AppInfo + * @property {boolean} isAndroid - Whether the application runs on Android. + * @property {boolean} isLinux - Whether the application runs on Linux. + * @property {boolean} isMac - Whether the application runs on Mac OS. + * @property {boolean} isWindows - Whether the application runs on Windows. + * @property {boolean} isFirefox - Whether the application is Firefox. + * @property {boolean} isThunderbird - Whether the application is Thunderbird. + * + * @since 88 + */ +export const AppInfo = new Proxy( + {}, + { + get(target, prop, receiver) { + if (target.hasOwnProperty(prop)) { + return target[prop]; + } + + return Services.appinfo[prop]; + }, + } +); + +// Platform support + +ChromeUtils.defineLazyGetter(AppInfo, "isAndroid", () => { + return Services.appinfo.OS === "Android"; +}); + +ChromeUtils.defineLazyGetter(AppInfo, "isLinux", () => { + return Services.appinfo.OS === "Linux"; +}); + +ChromeUtils.defineLazyGetter(AppInfo, "isMac", () => { + return Services.appinfo.OS === "Darwin"; +}); + +ChromeUtils.defineLazyGetter(AppInfo, "isWindows", () => { + return Services.appinfo.OS === "WINNT"; +}); + +// Application type + +ChromeUtils.defineLazyGetter(AppInfo, "isFirefox", () => { + return Services.appinfo.ID == ID_FIREFOX; +}); + +ChromeUtils.defineLazyGetter(AppInfo, "isThunderbird", () => { + return Services.appinfo.ID == ID_THUNDERBIRD; +}); + +export function getTimeoutMultiplier() { + if ( + AppConstants.DEBUG || + AppConstants.MOZ_CODE_COVERAGE || + AppConstants.ASAN + ) { + return 4; + } + if (AppConstants.TSAN) { + return 8; + } + + return 1; +} diff --git a/remote/shared/Browser.sys.mjs b/remote/shared/Browser.sys.mjs new file mode 100644 index 0000000000..c8bff1f55a --- /dev/null +++ b/remote/shared/Browser.sys.mjs @@ -0,0 +1,102 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + pprint: "chrome://remote/content/shared/Format.sys.mjs", + waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +/** + * Quits the application with the provided flags. + * + * Optional {@link nsIAppStartup} flags may be provided as + * an array of masks, and these will be combined by ORing + * them with a bitmask. The available masks are defined in + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIAppStartup. + * + * Crucially, only one of the *Quit flags can be specified. The |eRestart| + * flag may be bit-wise combined with one of the *Quit flags to cause + * the application to restart after it quits. + * + * @param {Array.<string>=} flags + * Constant name of masks to pass to |Services.startup.quit|. + * If empty or undefined, |nsIAppStartup.eAttemptQuit| is used. + * @param {boolean=} safeMode + * Optional flag to indicate that the application has to + * be restarted in safe mode. + * @param {boolean=} isWindowless + * Optional flag to indicate that the browser was started in windowless mode. + * + * @returns {Object<string,boolean>} + * Dictionary containing information that explains the shutdown reason. + * The value for `cause` contains the shutdown kind like "shutdown" or + * "restart", while `forced` will indicate if it was a normal or forced + * shutdown of the application. "in_app" is always set to indicate that + * it is a shutdown triggered from within the application. + */ +export async function quit(flags = [], safeMode = false, isWindowless = false) { + if (flags.includes("eSilently")) { + if (!isWindowless) { + throw new Error( + `Silent restarts only allowed with "moz:windowless" capability set` + ); + } + if (!flags.includes("eRestart")) { + throw new TypeError(`"silently" only works with restart flag`); + } + } + + const quits = ["eConsiderQuit", "eAttemptQuit", "eForceQuit"]; + + let quitSeen; + let mode = 0; + if (flags.length) { + for (let k of flags) { + if (!(k in Ci.nsIAppStartup)) { + throw new TypeError(lazy.pprint`Expected ${k} in ${Ci.nsIAppStartup}`); + } + + if (quits.includes(k)) { + if (quitSeen) { + throw new TypeError(`${k} cannot be combined with ${quitSeen}`); + } + quitSeen = k; + } + + mode |= Ci.nsIAppStartup[k]; + } + } + + if (!quitSeen) { + mode |= Ci.nsIAppStartup.eAttemptQuit; + } + + // Notify all windows that an application quit has been requested. + const cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested"); + + // If the shutdown of the application is prevented force quit it instead. + if (cancelQuit.data) { + mode |= Ci.nsIAppStartup.eForceQuit; + } + + // Delay response until the application is about to quit. + const quitApplication = lazy.waitForObserverTopic("quit-application"); + + if (safeMode) { + Services.startup.restartInSafeMode(mode); + } else { + Services.startup.quit(mode); + } + + return { + cause: (await quitApplication).data, + forced: cancelQuit.data, + in_app: true, + }; +} diff --git a/remote/shared/Capture.sys.mjs b/remote/shared/Capture.sys.mjs new file mode 100644 index 0000000000..ec34d09aba --- /dev/null +++ b/remote/shared/Capture.sys.mjs @@ -0,0 +1,203 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const CONTEXT_2D = "2d"; +const BG_COLOUR = "rgb(255,255,255)"; +const MAX_CANVAS_DIMENSION = 32767; +const MAX_CANVAS_AREA = 472907776; +const PNG_MIME = "image/png"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; + +/** + * Provides primitives to capture screenshots. + * + * @namespace + */ +export const capture = {}; + +capture.Format = { + Base64: 0, + Hash: 1, +}; + +/** + * Draw a rectangle off the framebuffer. + * + * @param {DOMWindow} win + * The DOM window used for the framebuffer, and providing the interfaces + * for creating an HTMLCanvasElement. + * @param {BrowsingContext} browsingContext + * The BrowsingContext from which the snapshot should be taken. + * @param {number} left + * The left, X axis offset of the rectangle. + * @param {number} top + * The top, Y axis offset of the rectangle. + * @param {number} width + * The width dimension of the rectangle to paint. + * @param {number} height + * The height dimension of the rectangle to paint. + * @param {object=} options + * @param {HTMLCanvasElement=} options.canvas + * Optional canvas to reuse for the screenshot. + * @param {number=} options.flags + * Optional integer representing flags to pass to drawWindow; these + * are defined on CanvasRenderingContext2D. + * @param {number=} options.dX + * Horizontal offset between the browser window and content area. Defaults to 0. + * @param {number=} options.dY + * Vertical offset between the browser window and content area. Defaults to 0. + * @param {boolean=} options.readback + * If true, read back a snapshot of the pixel data currently in the + * compositor/window. Defaults to false. + * + * @returns {HTMLCanvasElement} + * The canvas on which the selection from the window's framebuffer + * has been painted on. + */ +capture.canvas = async function ( + win, + browsingContext, + left, + top, + width, + height, + { canvas = null, flags = null, dX = 0, dY = 0, readback = false } = {} +) { + // FIXME(bug 1761032): This looks a bit sketchy, overrideDPPX doesn't + // influence rendering... + const scale = win.browsingContext.overrideDPPX || win.devicePixelRatio; + + let canvasHeight = height * scale; + let canvasWidth = width * scale; + + // Cap the screenshot size for width and height at 2^16 pixels, + // which is the maximum allowed canvas size. Higher dimensions will + // trigger exceptions in Gecko. + if (canvasWidth > MAX_CANVAS_DIMENSION) { + lazy.logger.warn( + "Limiting screen capture width to maximum allowed " + + MAX_CANVAS_DIMENSION + + " pixels" + ); + width = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasWidth = width * scale; + } + + if (canvasHeight > MAX_CANVAS_DIMENSION) { + lazy.logger.warn( + "Limiting screen capture height to maximum allowed " + + MAX_CANVAS_DIMENSION + + " pixels" + ); + height = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasHeight = height * scale; + } + + // If the area is larger, reduce the height to keep the full width. + if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) { + lazy.logger.warn( + "Limiting screen capture area to maximum allowed " + + MAX_CANVAS_AREA + + " pixels" + ); + height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale)); + canvasHeight = height * scale; + } + + if (canvas === null) { + canvas = win.document.createElementNS(XHTML_NS, "canvas"); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + } + + const ctx = canvas.getContext(CONTEXT_2D); + + if (readback) { + if (flags === null) { + flags = + ctx.DRAWWINDOW_DRAW_CARET | + ctx.DRAWWINDOW_DRAW_VIEW | + ctx.DRAWWINDOW_USE_WIDGET_LAYERS; + } + + // drawWindow doesn't take scaling into account. + ctx.scale(scale, scale); + ctx.drawWindow(win, left + dX, top + dY, width, height, BG_COLOUR, flags); + } else { + let rect = new DOMRect(left, top, width, height); + let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + BG_COLOUR + ); + + ctx.drawImage(snapshot, 0, 0); + + // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies + // of the bitmap will exist in memory. Force the removal of the snapshot + // because it is no longer needed. + snapshot.close(); + } + + return canvas; +}; + +/** + * Encode the contents of an HTMLCanvasElement to a Base64 encoded string. + * + * @param {HTMLCanvasElement} canvas + * The canvas to encode. + * + * @returns {string} + * A Base64 encoded string. + */ +capture.toBase64 = function (canvas) { + let u = canvas.toDataURL(PNG_MIME); + return u.substring(u.indexOf(",") + 1); +}; + +/** + * Hash the contents of an HTMLCanvasElement to a SHA-256 hex digest. + * + * @param {HTMLCanvasElement} canvas + * The canvas to encode. + * + * @returns {string} + * A hex digest of the SHA-256 hash of the base64 encoded string. + */ +capture.toHash = function (canvas) { + let u = capture.toBase64(canvas); + let buffer = new TextEncoder().encode(u); + return crypto.subtle.digest("SHA-256", buffer).then(hash => hex(hash)); +}; + +/** + * Convert buffer into to hex. + * + * @param {ArrayBuffer} buffer + * The buffer containing the data to convert to hex. + * + * @returns {string} + * A hex digest of the input buffer. + */ +function hex(buffer) { + let hexCodes = []; + let view = new DataView(buffer); + for (let i = 0; i < view.byteLength; i += 4) { + let value = view.getUint32(i); + let stringValue = value.toString(16); + let padding = "00000000"; + let paddedValue = (padding + stringValue).slice(-padding.length); + hexCodes.push(paddedValue); + } + return hexCodes.join(""); +} diff --git a/remote/shared/ChallengeHeaderParser.sys.mjs b/remote/shared/ChallengeHeaderParser.sys.mjs new file mode 100644 index 0000000000..7cb73a4146 --- /dev/null +++ b/remote/shared/ChallengeHeaderParser.sys.mjs @@ -0,0 +1,74 @@ +/* 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/. */ + +/** + * Parse the parameter in a name/value pair and remove quotes. + * + * @param {string} paramValue + * A string representing a challenge parameter. + * + * @returns {object} + * An object with name and value string properties. + */ +function parseChallengeParameter(paramValue) { + const [name, value] = paramValue.split("="); + return { name, value: value?.replace(/["']/g, "") }; +} + +/** + * Simple parser for authenticate (WWW-Authenticate or Proxy-Authenticate) + * headers. + * + * Bug 1857847: Replace with Necko's ChallengeParser once exposed to JS. + * + * @param {string} headerValue + * The value of an authenticate header. + * + * @returns {Array<object>} + * Array of challenge objects containing two properties: + * - {string} scheme: The scheme for the challenge + * - {Array<object>} params: Array of { name, value } objects representing + * all the parameters of the challenge. + */ +export function parseChallengeHeader(headerValue) { + const challenges = []; + const parts = headerValue.split(",").map(part => part.trim()); + + let scheme = null; + let params = []; + + const schemeRegex = /^(\w+)(?:\s+(.*))?$/; + for (const part of parts) { + const matches = part.match(schemeRegex); + if (matches !== null) { + // This is a new scheme. + if (scheme !== null) { + // If we have a challenge recorded, add it to the array. + challenges.push({ scheme, params }); + } + + // Reset the state for a new scheme. + scheme = matches[1]; + params = []; + if (matches[2]) { + params.push(parseChallengeParameter(matches[2])); + } + } else { + if (scheme === null) { + // A scheme should always be found before parameters, this header + // probably needs a more careful parsing solution. + return []; + } + + params.push(parseChallengeParameter(part)); + } + } + + if (scheme !== null) { + // If we have a challenge recorded, add it to the array. + challenges.push({ scheme, params }); + } + + return challenges; +} diff --git a/remote/shared/DOM.sys.mjs b/remote/shared/DOM.sys.mjs new file mode 100644 index 0000000000..664f02328c --- /dev/null +++ b/remote/shared/DOM.sys.mjs @@ -0,0 +1,1219 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + atom: "chrome://remote/content/marionette/atom.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + PollPromise: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +const ORDERED_NODE_ITERATOR_TYPE = 5; +const FIRST_ORDERED_NODE_TYPE = 9; + +const DOCUMENT_FRAGMENT_NODE = 11; +const ELEMENT_NODE = 1; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** XUL elements that support checked property. */ +const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]); + +/** XUL elements that support selected property. */ +const XUL_SELECTED_ELS = new Set([ + "menu", + "menuitem", + "menuseparator", + "radio", + "richlistitem", + "tab", +]); + +/** + * This module provides shared functionality for dealing with DOM- + * and web elements in Marionette. + * + * A web element is an abstraction used to identify an element when it + * is transported across the protocol, between remote- and local ends. + * + * Each element has an associated web element reference (a UUID) that + * uniquely identifies the the element across all browsing contexts. The + * web element reference for every element representing the same element + * is the same. + * + * @namespace + */ +export const dom = {}; + +dom.Strategy = { + ClassName: "class name", + Selector: "css selector", + ID: "id", + Name: "name", + LinkText: "link text", + PartialLinkText: "partial link text", + TagName: "tag name", + XPath: "xpath", +}; + +/** + * Find a single element or a collection of elements starting at the + * document root or a given node. + * + * If |timeout| is above 0, an implicit search technique is used. + * This will wait for the duration of <var>timeout</var> for the + * element to appear in the DOM. + * + * See the {@link dom.Strategy} enum for a full list of supported + * search strategies that can be passed to <var>strategy</var>. + * + * @param {Object<string, WindowProxy>} container + * Window object. + * @param {string} strategy + * Search strategy whereby to locate the element(s). + * @param {string} selector + * Selector search pattern. The selector must be compatible with + * the chosen search <var>strategy</var>. + * @param {object=} options + * @param {boolean=} options.all + * If true, a multi-element search selector is used and a sequence of + * elements will be returned, otherwise a single element. Defaults to false. + * @param {Element=} options.startNode + * Element to use as the root of the search. + * @param {number=} options.timeout + * Duration to wait before timing out the search. If <code>all</code> + * is false, a {@link NoSuchElementError} is thrown if unable to + * find the element within the timeout duration. + * + * @returns {Promise.<(Element|Array.<Element>)>} + * Single element or a sequence of elements. + * + * @throws InvalidSelectorError + * If <var>strategy</var> is unknown. + * @throws InvalidSelectorError + * If <var>selector</var> is malformed. + * @throws NoSuchElementError + * If a single element is requested, this error will throw if the + * element is not found. + */ +dom.find = function (container, strategy, selector, options = {}) { + const { all = false, startNode, timeout = 0 } = options; + + let searchFn; + if (all) { + searchFn = findElements.bind(this); + } else { + searchFn = findElement.bind(this); + } + + return new Promise((resolve, reject) => { + let findElements = new lazy.PollPromise( + async (resolve, reject) => { + try { + let res = await find_(container, strategy, selector, searchFn, { + all, + startNode, + }); + if (res.length) { + resolve(Array.from(res)); + } else { + reject([]); + } + } catch (e) { + reject(e); + } + }, + { timeout } + ); + + findElements.then(foundEls => { + // the following code ought to be moved into findElement + // and findElements when bug 1254486 is addressed + if (!all && (!foundEls || !foundEls.length)) { + let msg = `Unable to locate element: ${selector}`; + reject(new lazy.error.NoSuchElementError(msg)); + } + + if (all) { + resolve(foundEls); + } + resolve(foundEls[0]); + }, reject); + }); +}; + +async function find_( + container, + strategy, + selector, + searchFn, + { startNode = null, all = false } = {} +) { + let rootNode; + + if (dom.isShadowRoot(startNode)) { + rootNode = startNode.ownerDocument; + } else { + rootNode = container.frame.document; + } + + if (!startNode) { + startNode = rootNode; + } + + let res; + try { + res = await searchFn(strategy, selector, rootNode, startNode); + } catch (e) { + throw new lazy.error.InvalidSelectorError( + `Given ${strategy} expression "${selector}" is invalid: ${e}` + ); + } + + if (res) { + if (all) { + return res; + } + return [res]; + } + return []; +} + +/** + * Find a single element by XPath expression. + * + * @param {Document} document + * Document root. + * @param {Element} startNode + * Where in the DOM hiearchy to begin searching. + * @param {string} expression + * XPath search expression. + * + * @returns {Node} + * First element matching <var>expression</var>. + */ +dom.findByXPath = function (document, startNode, expression) { + let iter = document.evaluate( + expression, + startNode, + null, + FIRST_ORDERED_NODE_TYPE, + null + ); + return iter.singleNodeValue; +}; + +/** + * Find elements by XPath expression. + * + * @param {Document} document + * Document root. + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {string} expression + * XPath search expression. + * + * @returns {Iterable.<Node>} + * Iterator over nodes matching <var>expression</var>. + */ +dom.findByXPathAll = function* (document, startNode, expression) { + let iter = document.evaluate( + expression, + startNode, + null, + ORDERED_NODE_ITERATOR_TYPE, + null + ); + let el = iter.iterateNext(); + while (el) { + yield el; + el = iter.iterateNext(); + } +}; + +/** + * Find all hyperlinks descendant of <var>startNode</var> which + * link text is <var>linkText</var>. + * + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {string} linkText + * Link text to search for. + * + * @returns {Iterable.<HTMLAnchorElement>} + * Sequence of link elements which text is <var>s</var>. + */ +dom.findByLinkText = function (startNode, linkText) { + return filterLinks(startNode, async link => { + const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal); + return visibleText.trim() === linkText; + }); +}; + +/** + * Find all hyperlinks descendant of <var>startNode</var> which + * link text contains <var>linkText</var>. + * + * @param {Element} startNode + * Where in the DOM hierachy to begin searching. + * @param {string} linkText + * Link text to search for. + * + * @returns {Iterable.<HTMLAnchorElement>} + * Iterator of link elements which text containins + * <var>linkText</var>. + */ +dom.findByPartialLinkText = function (startNode, linkText) { + return filterLinks(startNode, async link => { + const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal); + + return visibleText.includes(linkText); + }); +}; + +/** + * Filters all hyperlinks that are descendant of <var>startNode</var> + * by <var>predicate</var>. + * + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {function(HTMLAnchorElement): boolean} predicate + * Function that determines if given link should be included in + * return value or filtered away. + * + * @returns {Array.<HTMLAnchorElement>} + * Array of link elements matching <var>predicate</var>. + */ +async function filterLinks(startNode, predicate) { + const links = []; + + for (const link of getLinks(startNode)) { + if (await predicate(link)) { + links.push(link); + } + } + + return links; +} + +/** + * Finds a single element. + * + * @param {dom.Strategy} strategy + * Selector strategy to use. + * @param {string} selector + * Selector expression. + * @param {Document} document + * Document root. + * @param {Element=} startNode + * Optional Element from which to start searching. + * + * @returns {Element} + * Found element. + * + * @throws {InvalidSelectorError} + * If strategy <var>using</var> is not recognised. + * @throws {Error} + * If selector expression <var>selector</var> is malformed. + */ +async function findElement( + strategy, + selector, + document, + startNode = undefined +) { + switch (strategy) { + case dom.Strategy.ID: { + if (startNode.getElementById) { + return startNode.getElementById(selector); + } + let expr = `.//*[@id="${selector}"]`; + return dom.findByXPath(document, startNode, expr); + } + + case dom.Strategy.Name: { + if (startNode.getElementsByName) { + return startNode.getElementsByName(selector)[0]; + } + let expr = `.//*[@name="${selector}"]`; + return dom.findByXPath(document, startNode, expr); + } + + case dom.Strategy.ClassName: + return startNode.getElementsByClassName(selector)[0]; + + case dom.Strategy.TagName: + return startNode.getElementsByTagName(selector)[0]; + + case dom.Strategy.XPath: + return dom.findByXPath(document, startNode, selector); + + case dom.Strategy.LinkText: { + const links = getLinks(startNode); + for (const link of links) { + const visibleText = await lazy.atom.getVisibleText( + link, + link.ownerGlobal + ); + if (visibleText.trim() === selector) { + return link; + } + } + return undefined; + } + + case dom.Strategy.PartialLinkText: { + const links = getLinks(startNode); + for (const link of links) { + const visibleText = await lazy.atom.getVisibleText( + link, + link.ownerGlobal + ); + if (visibleText.includes(selector)) { + return link; + } + } + return undefined; + } + + case dom.Strategy.Selector: + try { + return startNode.querySelector(selector); + } catch (e) { + throw new lazy.error.InvalidSelectorError( + `${e.message}: "${selector}"` + ); + } + } + + throw new lazy.error.InvalidSelectorError(`No such strategy: ${strategy}`); +} + +/** + * Find multiple elements. + * + * @param {dom.Strategy} strategy + * Selector strategy to use. + * @param {string} selector + * Selector expression. + * @param {Document} document + * Document root. + * @param {Element=} startNode + * Optional Element from which to start searching. + * + * @returns {Array.<Element>} + * Found elements. + * + * @throws {InvalidSelectorError} + * If strategy <var>strategy</var> is not recognised. + * @throws {Error} + * If selector expression <var>selector</var> is malformed. + */ +async function findElements( + strategy, + selector, + document, + startNode = undefined +) { + switch (strategy) { + case dom.Strategy.ID: + selector = `.//*[@id="${selector}"]`; + + // fall through + case dom.Strategy.XPath: + return [...dom.findByXPathAll(document, startNode, selector)]; + + case dom.Strategy.Name: + if (startNode.getElementsByName) { + return startNode.getElementsByName(selector); + } + return [ + ...dom.findByXPathAll(document, startNode, `.//*[@name="${selector}"]`), + ]; + + case dom.Strategy.ClassName: + return startNode.getElementsByClassName(selector); + + case dom.Strategy.TagName: + return startNode.getElementsByTagName(selector); + + case dom.Strategy.LinkText: + return [...(await dom.findByLinkText(startNode, selector))]; + + case dom.Strategy.PartialLinkText: + return [...(await dom.findByPartialLinkText(startNode, selector))]; + + case dom.Strategy.Selector: + return startNode.querySelectorAll(selector); + + default: + throw new lazy.error.InvalidSelectorError( + `No such strategy: ${strategy}` + ); + } +} + +function getLinks(startNode) { + // DocumentFragment doesn't have `getElementsByTagName` so using `querySelectorAll`. + if (dom.isShadowRoot(startNode)) { + return startNode.querySelectorAll("a"); + } + return startNode.getElementsByTagName("a"); +} + +/** + * Finds the closest parent node of <var>startNode</var> matching a CSS + * <var>selector</var> expression. + * + * @param {Node} startNode + * Cycle through <var>startNode</var>'s parent nodes in tree-order + * and return the first match to <var>selector</var>. + * @param {string} selector + * CSS selector expression. + * + * @returns {Node=} + * First match to <var>selector</var>, or null if no match was found. + */ +dom.findClosest = function (startNode, selector) { + let node = startNode; + while (node.parentNode && node.parentNode.nodeType == ELEMENT_NODE) { + node = node.parentNode; + if (node.matches(selector)) { + return node; + } + } + return null; +}; + +/** + * Determines if <var>obj<var> is an HTML or JS collection. + * + * @param {object} seq + * Type to determine. + * + * @returns {boolean} + * True if <var>seq</va> is a collection. + */ +dom.isCollection = function (seq) { + switch (Object.prototype.toString.call(seq)) { + case "[object Arguments]": + case "[object Array]": + case "[object DOMTokenList]": + case "[object FileList]": + case "[object HTMLAllCollection]": + case "[object HTMLCollection]": + case "[object HTMLFormControlsCollection]": + case "[object HTMLOptionsCollection]": + case "[object NodeList]": + return true; + + default: + return false; + } +}; + +/** + * Determines if <var>shadowRoot</var> is detached. + * + * A ShadowRoot is detached if its node document is not the active document + * or if the element node referred to as its host is stale. + * + * @param {ShadowRoot} shadowRoot + * ShadowRoot to check for detached state. + * + * @returns {boolean} + * True if <var>shadowRoot</var> is detached, false otherwise. + */ +dom.isDetached = function (shadowRoot) { + return !shadowRoot.ownerDocument.isActive() || dom.isStale(shadowRoot.host); +}; + +/** + * Determines if <var>el</var> is stale. + * + * An element is stale if its node document is not the active document + * or if it is not connected. + * + * @param {Element} el + * Element to check for staleness. + * + * @returns {boolean} + * True if <var>el</var> is stale, false otherwise. + */ +dom.isStale = function (el) { + if (!el.ownerGlobal) { + // Without a valid inner window the document is basically closed. + return true; + } + + return !el.ownerDocument.isActive() || !el.isConnected; +}; + +/** + * Determine if <var>el</var> is selected or not. + * + * This operation only makes sense on + * <tt><input type=checkbox></tt>, + * <tt><input type=radio></tt>, + * and <tt>>option></tt> elements. + * + * @param {Element} el + * Element to test if selected. + * + * @returns {boolean} + * True if element is selected, false otherwise. + */ +dom.isSelected = function (el) { + if (!el) { + return false; + } + + if (dom.isXULElement(el)) { + if (XUL_CHECKED_ELS.has(el.tagName)) { + return el.checked; + } else if (XUL_SELECTED_ELS.has(el.tagName)) { + return el.selected; + } + } else if (dom.isDOMElement(el)) { + if (el.localName == "input" && ["checkbox", "radio"].includes(el.type)) { + return el.checked; + } else if (el.localName == "option") { + return el.selected; + } + } + + return false; +}; + +/** + * An element is considered read only if it is an + * <code><input></code> or <code><textarea></code> + * element whose <code>readOnly</code> content IDL attribute is set. + * + * @param {Element} el + * Element to test is read only. + * + * @returns {boolean} + * True if element is read only. + */ +dom.isReadOnly = function (el) { + return ( + dom.isDOMElement(el) && + ["input", "textarea"].includes(el.localName) && + el.readOnly + ); +}; + +/** + * An element is considered disabled if it is a an element + * that can be disabled, or it belongs to a container group which + * <code>disabled</code> content IDL attribute affects it. + * + * @param {Element} el + * Element to test for disabledness. + * + * @returns {boolean} + * True if element, or its container group, is disabled. + */ +dom.isDisabled = function (el) { + if (!dom.isDOMElement(el)) { + return false; + } + + switch (el.localName) { + case "option": + case "optgroup": + if (el.disabled) { + return true; + } + let parent = dom.findClosest(el, "optgroup,select"); + return dom.isDisabled(parent); + + case "button": + case "input": + case "select": + case "textarea": + return el.disabled; + + default: + return false; + } +}; + +/** + * Denotes elements that can be used for typing and clearing. + * + * Elements that are considered WebDriver-editable are non-readonly + * and non-disabled <code><input></code> elements in the Text, + * Search, URL, Telephone, Email, Password, Date, Month, Date and + * Time Local, Number, Range, Color, and File Upload states, and + * <code><textarea></code> elements. + * + * @param {Element} el + * Element to test. + * + * @returns {boolean} + * True if editable, false otherwise. + */ +dom.isMutableFormControl = function (el) { + if (!dom.isDOMElement(el)) { + return false; + } + if (dom.isReadOnly(el) || dom.isDisabled(el)) { + return false; + } + + if (el.localName == "textarea") { + return true; + } + + if (el.localName != "input") { + return false; + } + + switch (el.type) { + case "color": + case "date": + case "datetime-local": + case "email": + case "file": + case "month": + case "number": + case "password": + case "range": + case "search": + case "tel": + case "text": + case "time": + case "url": + case "week": + return true; + + default: + return false; + } +}; + +/** + * An editing host is a node that is either an HTML element with a + * <code>contenteditable</code> attribute, or the HTML element child + * of a document whose <code>designMode</code> is enabled. + * + * @param {Element} el + * Element to determine if is an editing host. + * + * @returns {boolean} + * True if editing host, false otherwise. + */ +dom.isEditingHost = function (el) { + return ( + dom.isDOMElement(el) && + (el.isContentEditable || el.ownerDocument.designMode == "on") + ); +}; + +/** + * Determines if an element is editable according to WebDriver. + * + * An element is considered editable if it is not read-only or + * disabled, and one of the following conditions are met: + * + * <ul> + * <li>It is a <code><textarea></code> element. + * + * <li>It is an <code><input></code> element that is not of + * the <code>checkbox</code>, <code>radio</code>, <code>hidden</code>, + * <code>submit</code>, <code>button</code>, or <code>image</code> types. + * + * <li>It is content-editable. + * + * <li>It belongs to a document in design mode. + * </ul> + * + * @param {Element} el + * Element to test if editable. + * + * @returns {boolean} + * True if editable, false otherwise. + */ +dom.isEditable = function (el) { + if (!dom.isDOMElement(el)) { + return false; + } + + if (dom.isReadOnly(el) || dom.isDisabled(el)) { + return false; + } + + return dom.isMutableFormControl(el) || dom.isEditingHost(el); +}; + +/** + * This function generates a pair of coordinates relative to the viewport + * given a target element and coordinates relative to that element's + * top-left corner. + * + * @param {Node} node + * Target node. + * @param {number=} xOffset + * Horizontal offset relative to target's top-left corner. + * Defaults to the centre of the target's bounding box. + * @param {number=} yOffset + * Vertical offset relative to target's top-left corner. Defaults to + * the centre of the target's bounding box. + * + * @returns {Object<string, number>} + * X- and Y coordinates. + * + * @throws TypeError + * If <var>xOffset</var> or <var>yOffset</var> are not numbers. + */ +dom.coordinates = function (node, xOffset = undefined, yOffset = undefined) { + let box = node.getBoundingClientRect(); + + if (typeof xOffset == "undefined" || xOffset === null) { + xOffset = box.width / 2.0; + } + if (typeof yOffset == "undefined" || yOffset === null) { + yOffset = box.height / 2.0; + } + + if (typeof yOffset != "number" || typeof xOffset != "number") { + throw new TypeError("Offset must be a number"); + } + + return { + x: box.left + xOffset, + y: box.top + yOffset, + }; +}; + +/** + * This function returns true if the node is in the viewport. + * + * @param {Element} el + * Target element. + * @param {number=} x + * Horizontal offset relative to target. Defaults to the centre of + * the target's bounding box. + * @param {number=} y + * Vertical offset relative to target. Defaults to the centre of + * the target's bounding box. + * + * @returns {boolean} + * True if if <var>el</var> is in viewport, false otherwise. + */ +dom.inViewport = function (el, x = undefined, y = undefined) { + let win = el.ownerGlobal; + let c = dom.coordinates(el, x, y); + let vp = { + top: win.pageYOffset, + left: win.pageXOffset, + bottom: win.pageYOffset + win.innerHeight, + right: win.pageXOffset + win.innerWidth, + }; + + return ( + vp.left <= c.x + win.pageXOffset && + c.x + win.pageXOffset <= vp.right && + vp.top <= c.y + win.pageYOffset && + c.y + win.pageYOffset <= vp.bottom + ); +}; + +/** + * Gets the element's container element. + * + * An element container is defined by the WebDriver + * specification to be an <tt><option></tt> element in a + * <a href="https://html.spec.whatwg.org/#concept-element-contexts">valid + * element context</a>, meaning that it has an ancestral element + * that is either <tt><datalist></tt> or <tt><select></tt>. + * + * If the element does not have a valid context, its container element + * is itself. + * + * @param {Element} el + * Element to get the container of. + * + * @returns {Element} + * Container element of <var>el</var>. + */ +dom.getContainer = function (el) { + // Does <option> or <optgroup> have a valid context, + // meaning is it a child of <datalist> or <select>? + if (["option", "optgroup"].includes(el.localName)) { + return dom.findClosest(el, "datalist,select") || el; + } + + return el; +}; + +/** + * An element is in view if it is a member of its own pointer-interactable + * paint tree. + * + * This means an element is considered to be in view, but not necessarily + * pointer-interactable, if it is found somewhere in the + * <code>elementsFromPoint</code> list at <var>el</var>'s in-view + * centre coordinates. + * + * Before running the check, we change <var>el</var>'s pointerEvents + * style property to "auto", since elements without pointer events + * enabled do not turn up in the paint tree we get from + * document.elementsFromPoint. This is a specialisation that is only + * relevant when checking if the element is in view. + * + * @param {Element} el + * Element to check if is in view. + * + * @returns {boolean} + * True if <var>el</var> is inside the viewport, or false otherwise. + */ +dom.isInView = function (el) { + let originalPointerEvents = el.style.pointerEvents; + + try { + el.style.pointerEvents = "auto"; + const tree = dom.getPointerInteractablePaintTree(el); + + // Bug 1413493 - <tr> is not part of the returned paint tree yet. As + // workaround check the visibility based on the first contained cell. + if (el.localName === "tr" && el.cells && el.cells.length) { + return tree.includes(el.cells[0]); + } + + return tree.includes(el); + } finally { + el.style.pointerEvents = originalPointerEvents; + } +}; + +/** + * This function throws the visibility of the element error if the element is + * not displayed or the given coordinates are not within the viewport. + * + * @param {Element} el + * Element to check if visible. + * @param {number=} x + * Horizontal offset relative to target. Defaults to the centre of + * the target's bounding box. + * @param {number=} y + * Vertical offset relative to target. Defaults to the centre of + * the target's bounding box. + * + * @returns {boolean} + * True if visible, false otherwise. + */ +dom.isVisible = async function (el, x = undefined, y = undefined) { + let win = el.ownerGlobal; + + if (!(await lazy.atom.isElementDisplayed(el, win))) { + return false; + } + + if (el.tagName.toLowerCase() == "body") { + return true; + } + + if (!dom.inViewport(el, x, y)) { + dom.scrollIntoView(el); + if (!dom.inViewport(el)) { + return false; + } + } + return true; +}; + +/** + * A pointer-interactable element is defined to be the first + * non-transparent element, defined by the paint order found at the centre + * point of its rectangle that is inside the viewport, excluding the size + * of any rendered scrollbars. + * + * An element is obscured if the pointer-interactable paint tree at its + * centre point is empty, or the first element in this tree is not an + * inclusive descendant of itself. + * + * @param {DOMElement} el + * Element determine if is pointer-interactable. + * + * @returns {boolean} + * True if element is obscured, false otherwise. + */ +dom.isObscured = function (el) { + let tree = dom.getPointerInteractablePaintTree(el); + return !el.contains(tree[0]); +}; + +// TODO(ato): Only used by deprecated action API +// https://bugzil.la/1354578 +/** + * Calculates the in-view centre point of an element's client rect. + * + * The portion of an element that is said to be _in view_, is the + * intersection of two squares: the first square being the initial + * viewport, and the second a DOM element. From this square we + * calculate the in-view _centre point_ and convert it into CSS pixels. + * + * Although Gecko's system internals allow click points to be + * given in floating point precision, the DOM operates in CSS pixels. + * When the in-view centre point is later used to retrieve a coordinate's + * paint tree, we need to ensure to operate in the same language. + * + * As a word of warning, there appears to be inconsistencies between + * how `DOMElement.elementsFromPoint` and `DOMWindowUtils.sendMouseEvent` + * internally rounds (ceils/floors) coordinates. + * + * @param {DOMRect} rect + * Element off a DOMRect sequence produced by calling + * `getClientRects` on an {@link Element}. + * @param {WindowProxy} win + * Current window global. + * + * @returns {Map.<string, number>} + * X and Y coordinates that denotes the in-view centre point of + * `rect`. + */ +dom.getInViewCentrePoint = function (rect, win) { + const { floor, max, min } = Math; + + // calculate the intersection of the rect that is inside the viewport + let visible = { + left: max(0, min(rect.x, rect.x + rect.width)), + right: min(win.innerWidth, max(rect.x, rect.x + rect.width)), + top: max(0, min(rect.y, rect.y + rect.height)), + bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)), + }; + + // arrive at the centre point of the visible rectangle + let x = (visible.left + visible.right) / 2.0; + let y = (visible.top + visible.bottom) / 2.0; + + // convert to CSS pixels, as centre point can be float + x = floor(x); + y = floor(y); + + return { x, y }; +}; + +/** + * Produces a pointer-interactable elements tree from a given element. + * + * The tree is defined by the paint order found at the centre point of + * the element's rectangle that is inside the viewport, excluding the size + * of any rendered scrollbars. + * + * @param {DOMElement} el + * Element to determine if is pointer-interactable. + * + * @returns {Array.<DOMElement>} + * Sequence of elements in paint order. + */ +dom.getPointerInteractablePaintTree = function (el) { + const win = el.ownerGlobal; + const rootNode = el.getRootNode(); + + // pointer-interactable elements tree, step 1 + if (!el.isConnected) { + return []; + } + + // steps 2-3 + let rects = el.getClientRects(); + if (!rects.length) { + return []; + } + + // step 4 + let centre = dom.getInViewCentrePoint(rects[0], win); + + // step 5 + return rootNode.elementsFromPoint(centre.x, centre.y); +}; + +// TODO(ato): Not implemented. +// In fact, it's not defined in the spec. +dom.isKeyboardInteractable = () => true; + +/** + * Attempts to scroll into view |el|. + * + * @param {DOMElement} el + * Element to scroll into view. + */ +dom.scrollIntoView = function (el) { + if (el.scrollIntoView) { + el.scrollIntoView({ block: "end", inline: "nearest" }); + } +}; + +/** + * Ascertains whether <var>obj</var> is a DOM-, SVG-, or XUL element. + * + * @param {object} obj + * Object thought to be an <code>Element</code> or + * <code>XULElement</code>. + * + * @returns {boolean} + * True if <var>obj</var> is an element, false otherwise. + */ +dom.isElement = function (obj) { + return dom.isDOMElement(obj) || dom.isXULElement(obj); +}; + +/** + * Returns the shadow root of an element. + * + * @param {Element} el + * Element thought to have a <code>shadowRoot</code> + * @returns {ShadowRoot} + * Shadow root of the element. + */ +dom.getShadowRoot = function (el) { + const shadowRoot = el.openOrClosedShadowRoot; + if (!shadowRoot) { + throw new lazy.error.NoSuchShadowRootError(); + } + return shadowRoot; +}; + +/** + * Ascertains whether <var>node</var> is a shadow root. + * + * @param {ShadowRoot} node + * The node that will be checked to see if it has a shadow root + * + * @returns {boolean} + * True if <var>node</var> is a shadow root, false otherwise. + */ +dom.isShadowRoot = function (node) { + return ( + node && + node.nodeType === DOCUMENT_FRAGMENT_NODE && + node.containingShadowRoot == node + ); +}; + +/** + * Ascertains whether <var>obj</var> is a DOM element. + * + * @param {object} obj + * Object to check. + * + * @returns {boolean} + * True if <var>obj</var> is a DOM element, false otherwise. + */ +dom.isDOMElement = function (obj) { + return obj && obj.nodeType == ELEMENT_NODE && !dom.isXULElement(obj); +}; + +/** + * Ascertains whether <var>obj</var> is a XUL element. + * + * @param {object} obj + * Object to check. + * + * @returns {boolean} + * True if <var>obj</var> is a XULElement, false otherwise. + */ +dom.isXULElement = function (obj) { + return obj && obj.nodeType === ELEMENT_NODE && obj.namespaceURI === XUL_NS; +}; + +/** + * Ascertains whether <var>node</var> is in a privileged document. + * + * @param {Node} node + * Node to check. + * + * @returns {boolean} + * True if <var>node</var> is in a privileged document, + * false otherwise. + */ +dom.isInPrivilegedDocument = function (node) { + return !!node?.nodePrincipal?.isSystemPrincipal; +}; + +/** + * Ascertains whether <var>obj</var> is a <code>WindowProxy</code>. + * + * @param {object} obj + * Object to check. + * + * @returns {boolean} + * True if <var>obj</var> is a DOM window. + */ +dom.isDOMWindow = function (obj) { + // TODO(ato): This should use Object.prototype.toString.call(node) + // but it's not clear how to write a good xpcshell test for that, + // seeing as we stub out a WindowProxy. + return ( + typeof obj == "object" && + obj !== null && + typeof obj.toString == "function" && + obj.toString() == "[object Window]" && + obj.self === obj + ); +}; + +const boolEls = { + audio: ["autoplay", "controls", "loop", "muted"], + button: ["autofocus", "disabled", "formnovalidate"], + details: ["open"], + dialog: ["open"], + fieldset: ["disabled"], + form: ["novalidate"], + iframe: ["allowfullscreen"], + img: ["ismap"], + input: [ + "autofocus", + "checked", + "disabled", + "formnovalidate", + "multiple", + "readonly", + "required", + ], + keygen: ["autofocus", "disabled"], + menuitem: ["checked", "default", "disabled"], + ol: ["reversed"], + optgroup: ["disabled"], + option: ["disabled", "selected"], + script: ["async", "defer"], + select: ["autofocus", "disabled", "multiple", "required"], + textarea: ["autofocus", "disabled", "readonly", "required"], + track: ["default"], + video: ["autoplay", "controls", "loop", "muted"], +}; + +/** + * Tests if the attribute is a boolean attribute on element. + * + * @param {Element} el + * Element to test if <var>attr</var> is a boolean attribute on. + * @param {string} attr + * Attribute to test is a boolean attribute. + * + * @returns {boolean} + * True if the attribute is boolean, false otherwise. + */ +dom.isBooleanAttribute = function (el, attr) { + if (!dom.isDOMElement(el)) { + return false; + } + + // global boolean attributes that apply to all HTML elements, + // except for custom elements + const customElement = !el.localName.includes("-"); + if ((attr == "hidden" || attr == "itemscope") && customElement) { + return true; + } + + if (!boolEls.hasOwnProperty(el.localName)) { + return false; + } + return boolEls[el.localName].includes(attr); +}; diff --git a/remote/shared/Format.sys.mjs b/remote/shared/Format.sys.mjs new file mode 100644 index 0000000000..5da8bc9161 --- /dev/null +++ b/remote/shared/Format.sys.mjs @@ -0,0 +1,186 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "truncateLog", + "remote.log.truncate", + false +); + +const ELEMENT_NODE = 1; +const MAX_STRING_LENGTH = 250; + +/** + * Pretty-print values passed to template strings. + * + * Usage:: + * + * let bool = {value: true}; + * pprint`Expected boolean, got ${bool}`; + * => 'Expected boolean, got [object Object] {"value": true}' + * + * let htmlElement = document.querySelector("input#foo"); + * pprint`Expected element ${htmlElement}`; + * => 'Expected element <input id="foo" class="bar baz" type="input">' + * + * pprint`Current window: ${window}`; + * => '[object Window https://www.mozilla.org/]' + */ +export function pprint(ss, ...values) { + function pretty(val) { + let proto = Object.prototype.toString.call(val); + if ( + typeof val == "object" && + val !== null && + "nodeType" in val && + val.nodeType === ELEMENT_NODE + ) { + return prettyElement(val); + } else if (["[object Window]", "[object ChromeWindow]"].includes(proto)) { + return prettyWindowGlobal(val); + } else if (proto == "[object Attr]") { + return prettyAttr(val); + } + return prettyObject(val); + } + + function prettyElement(el) { + let attrs = ["id", "class", "href", "name", "src", "type"]; + + let idents = ""; + for (let attr of attrs) { + if (el.hasAttribute(attr)) { + idents += ` ${attr}="${el.getAttribute(attr)}"`; + } + } + + return `<${el.localName}${idents}>`; + } + + function prettyWindowGlobal(win) { + let proto = Object.prototype.toString.call(win); + return `[${proto.substring(1, proto.length - 1)} ${win.location}]`; + } + + function prettyAttr(obj) { + return `[object Attr ${obj.name}="${obj.value}"]`; + } + + function prettyObject(obj) { + let proto = Object.prototype.toString.call(obj); + let s = ""; + try { + s = JSON.stringify(obj); + } catch (e) { + if (e instanceof TypeError) { + s = `<${e.message}>`; + } else { + throw e; + } + } + return `${proto} ${s}`; + } + + let res = []; + for (let i = 0; i < ss.length; i++) { + res.push(ss[i]); + if (i < values.length) { + let s; + try { + s = pretty(values[i]); + } catch (e) { + lazy.logger.warn("Problem pretty printing:", e); + s = typeof values[i]; + } + res.push(s); + } + } + return res.join(""); +} + +/** + * Template literal that truncates string values in arbitrary objects. + * + * Given any object, the template will walk the object and truncate + * any strings it comes across to a reasonable limit. This is suitable + * when you have arbitrary data and data integrity is not important. + * + * The strings are truncated in the middle so that the beginning and + * the end is preserved. This will make a long, truncated string look + * like "X <...> Y", where X and Y are half the number of characters + * of the maximum string length from either side of the string. + * + * + * Usage:: + * + * truncate`Hello ${"x".repeat(260)}!`; + * // Hello xxx ... xxx! + * + * Functions named `toJSON` or `toString` on objects will be called. + */ +export function truncate(strings, ...values) { + function walk(obj) { + const typ = Object.prototype.toString.call(obj); + + switch (typ) { + case "[object Undefined]": + case "[object Null]": + case "[object Boolean]": + case "[object Number]": + return obj; + + case "[object String]": + if (lazy.truncateLog && obj.length > MAX_STRING_LENGTH) { + let s1 = obj.substring(0, MAX_STRING_LENGTH / 2); + let s2 = obj.substring(obj.length - MAX_STRING_LENGTH / 2); + return `${s1} ... ${s2}`; + } + return obj; + + case "[object Array]": + return obj.map(walk); + + // arbitrary object + default: + if ( + Object.getOwnPropertyNames(obj).includes("toString") && + typeof obj.toString == "function" + ) { + return walk(obj.toString()); + } + + let rv = {}; + for (let prop in obj) { + rv[prop] = walk(obj[prop]); + } + return rv; + } + } + + let res = []; + for (let i = 0; i < strings.length; ++i) { + res.push(strings[i]); + if (i < values.length) { + let obj = walk(values[i]); + let t = Object.prototype.toString.call(obj); + if (t == "[object Array]" || t == "[object Object]") { + res.push(JSON.stringify(obj)); + } else { + res.push(obj); + } + } + } + return res.join(""); +} diff --git a/remote/shared/Log.sys.mjs b/remote/shared/Log.sys.mjs new file mode 100644 index 0000000000..f1b3706391 --- /dev/null +++ b/remote/shared/Log.sys.mjs @@ -0,0 +1,71 @@ +/* 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/. */ + +import { Log as StdLog } from "resource://gre/modules/Log.sys.mjs"; + +const PREF_REMOTE_LOG_LEVEL = "remote.log.level"; + +const lazy = {}; + +// Lazy getter which returns a cached value of the remote log level. Should be +// used for static getters used to guard hot paths for logging, eg +// isTraceLevelOrMore. +ChromeUtils.defineLazyGetter(lazy, "logLevel", () => + Services.prefs.getCharPref(PREF_REMOTE_LOG_LEVEL, StdLog.Level.Fatal) +); + +/** E10s compatible wrapper for the standard logger from Log.sys.mjs. */ +export class Log { + static TYPES = { + CDP: "CDP", + MARIONETTE: "Marionette", + REMOTE_AGENT: "RemoteAgent", + WEBDRIVER_BIDI: "WebDriver BiDi", + }; + + /** + * Get a logger instance. For each provided type, a dedicated logger instance + * will be returned, but all loggers are relying on the same preference. + * + * @param {string} type + * The type of logger to use. Protocol-specific modules should use the + * corresponding logger type. Eg. files under /marionette should use + * Log.TYPES.MARIONETTE. + */ + static get(type = Log.TYPES.REMOTE_AGENT) { + const logger = StdLog.repository.getLogger(type); + if (!logger.ownAppenders.length) { + logger.addAppender(new StdLog.DumpAppender()); + logger.manageLevelFromPref(PREF_REMOTE_LOG_LEVEL); + } + return logger; + } + + /** + * Check if the current log level matches the Debug log level, or any level + * above that. This should be used to guard logger.debug calls and avoid + * instanciating logger instances unnecessarily. + */ + static get isDebugLevelOrMore() { + // Debug is assigned 20, more verbose log levels have lower values. + return StdLog.Level[lazy.logLevel] <= StdLog.Level.Debug; + } + + /** + * Check if the current log level matches the Trace log level, or any level + * above that. This should be used to guard logger.trace calls and avoid + * instanciating logger instances unnecessarily. + */ + static get isTraceLevelOrMore() { + // Trace is assigned 10, more verbose log levels have lower values. + return StdLog.Level[lazy.logLevel] <= StdLog.Level.Trace; + } + + static get verbose() { + // we can't use Preferences.sys.mjs before first paint, + // see ../browser/base/content/test/performance/browser_startup.js + const level = Services.prefs.getStringPref(PREF_REMOTE_LOG_LEVEL, "Info"); + return StdLog.Level[level] >= StdLog.Level.Info; + } +} diff --git a/remote/shared/MobileTabBrowser.sys.mjs b/remote/shared/MobileTabBrowser.sys.mjs new file mode 100644 index 0000000000..b61a1f9a9b --- /dev/null +++ b/remote/shared/MobileTabBrowser.sys.mjs @@ -0,0 +1,86 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewTabUtil: "resource://gre/modules/GeckoViewTestUtils.sys.mjs", + + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +// GeckoView shim for Desktop's gBrowser +export class MobileTabBrowser { + constructor(window) { + this.window = window; + } + + get tabs() { + return [this.window.tab]; + } + + get selectedTab() { + return this.window.tab; + } + + set selectedTab(tab) { + if (tab != this.selectedTab) { + throw new Error("GeckoView only supports a single tab"); + } + + // Synthesize a custom TabSelect event to indicate that a tab has been + // selected even when we don't change it. + const event = this.window.CustomEvent("TabSelect", { + bubbles: true, + cancelable: false, + detail: { + previousTab: this.selectedTab, + }, + }); + this.window.document.dispatchEvent(event); + } + + get selectedBrowser() { + return this.selectedTab.linkedBrowser; + } + + addEventListener() { + this.window.addEventListener(...arguments); + } + + /** + * Create a new tab. + * + * @param {string} uriString + * The URI string to load within the newly opened tab. + * + * @returns {Promise<Tab>} + * The created tab. + * @throws {Error} + * Throws an error if the tab cannot be created. + */ + addTab(uriString) { + return lazy.GeckoViewTabUtil.createNewTab(uriString); + } + + getTabForBrowser(browser) { + if (browser != this.selectedBrowser) { + throw new Error("GeckoView only supports a single tab"); + } + + return this.selectedTab; + } + + removeEventListener() { + this.window.removeEventListener(...arguments); + } + + removeTab(tab) { + if (tab != this.selectedTab) { + throw new Error("GeckoView only supports a single tab"); + } + + return lazy.windowManager.closeWindow(this.window); + } +} diff --git a/remote/shared/Navigate.sys.mjs b/remote/shared/Navigate.sys.mjs new file mode 100644 index 0000000000..9b72c0dfbf --- /dev/null +++ b/remote/shared/Navigate.sys.mjs @@ -0,0 +1,435 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + + Deferred: "chrome://remote/content/shared/Sync.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT) +); + +// Define a custom multiplier to apply to the unload timer on various platforms. +// This multiplier should only reflect the navigation performance of the +// platform and not the overall performance. +ChromeUtils.defineLazyGetter(lazy, "UNLOAD_TIMEOUT_MULTIPLIER", () => { + if (AppConstants.MOZ_CODE_COVERAGE) { + // Navigation on ccov platforms can be extremely slow because new processes + // need to be instrumented for coverage on startup. + return 16; + } + + if (AppConstants.ASAN || AppConstants.DEBUG || AppConstants.TSAN) { + // Use an extended timeout on slow platforms. + return 8; + } + + return 1; +}); + +export const DEFAULT_UNLOAD_TIMEOUT = 200; + +/** + * Returns the multiplier used for the unload timer. Useful for tests which + * assert the behavior of this timeout. + */ +export function getUnloadTimeoutMultiplier() { + return lazy.UNLOAD_TIMEOUT_MULTIPLIER; +} + +// Used to keep weak references of webProgressListeners alive. +const webProgressListeners = new Set(); + +/** + * Wait until the initial load of the given WebProgress is done. + * + * @param {WebProgress} webProgress + * The WebProgress instance to observe. + * @param {object=} options + * @param {boolean=} options.resolveWhenStarted + * Flag to indicate that the Promise has to be resolved when the + * page load has been started. Otherwise wait until the page has + * finished loading. Defaults to `false`. + * @param {number=} options.unloadTimeout + * Time to allow before the page gets unloaded. See ProgressListener options. + * @returns {Promise} + * Promise which resolves when the page load is in the expected state. + * Values as returned: + * - {nsIURI} currentURI The current URI of the page + * - {nsIURI} targetURI Target URI of the navigation + */ +export async function waitForInitialNavigationCompleted( + webProgress, + options = {} +) { + const { resolveWhenStarted = false, unloadTimeout } = options; + + const browsingContext = webProgress.browsingContext; + + // Start the listener right away to avoid race conditions. + const listener = new ProgressListener(webProgress, { + resolveWhenStarted, + unloadTimeout, + }); + const navigated = listener.start(); + + // Right after a browsing context has been attached it could happen that + // no window global has been set yet. Consider this as nothing has been + // loaded yet. + let isInitial = true; + if (browsingContext.currentWindowGlobal) { + isInitial = browsingContext.currentWindowGlobal.isInitialDocument; + } + + // If the current document is not the initial "about:blank" and is also + // no longer loading, assume the navigation is done and return. + if (!isInitial && !listener.isLoadingDocument) { + lazy.logger.trace( + lazy.truncate`[${browsingContext.id}] Document already finished loading: ${browsingContext.currentURI?.spec}` + ); + + // Will resolve the navigated promise. + listener.stop(); + } + + await navigated; + + return { + currentURI: listener.currentURI, + targetURI: listener.targetURI, + }; +} + +/** + * WebProgressListener to observe for page loads. + */ +export class ProgressListener { + #expectNavigation; + #resolveWhenStarted; + #unloadTimeout; + #waitForExplicitStart; + #webProgress; + + #deferredNavigation; + #seenStartFlag; + #targetURI; + #unloadTimerId; + + /** + * Create a new WebProgressListener instance. + * + * @param {WebProgress} webProgress + * The web progress to attach the listener to. + * @param {object=} options + * @param {boolean=} options.expectNavigation + * Flag to indicate that a navigation is guaranteed to happen. + * When set to `true`, the ProgressListener will ignore options.unloadTimeout + * and will only resolve when the expected navigation happens. + * Defaults to `false`. + * @param {boolean=} options.resolveWhenStarted + * Flag to indicate that the Promise has to be resolved when the + * page load has been started. Otherwise wait until the page has + * finished loading. Defaults to `false`. + * @param {number=} options.unloadTimeout + * Time to allow before the page gets unloaded. Defaults to 200ms on + * regular platforms. A multiplier will be applied on slower platforms + * (eg. debug, ccov...). + * Ignored if options.expectNavigation is set to `true` + * @param {boolean=} options.waitForExplicitStart + * Flag to indicate that the Promise can only resolve after receiving a + * STATE_START state change. In other words, if the webProgress is already + * navigating, the Promise will only resolve for the next navigation. + * Defaults to `false`. + */ + constructor(webProgress, options = {}) { + const { + expectNavigation = false, + resolveWhenStarted = false, + unloadTimeout = DEFAULT_UNLOAD_TIMEOUT, + waitForExplicitStart = false, + } = options; + + this.#expectNavigation = expectNavigation; + this.#resolveWhenStarted = resolveWhenStarted; + this.#unloadTimeout = unloadTimeout * lazy.UNLOAD_TIMEOUT_MULTIPLIER; + this.#waitForExplicitStart = waitForExplicitStart; + this.#webProgress = webProgress; + + this.#deferredNavigation = null; + this.#seenStartFlag = false; + this.#targetURI = null; + this.#unloadTimerId = null; + } + + get #messagePrefix() { + return `[${this.browsingContext.id}] ${this.constructor.name}`; + } + + get browsingContext() { + return this.#webProgress.browsingContext; + } + + get currentURI() { + return this.#webProgress.browsingContext.currentURI; + } + + get isLoadingDocument() { + return this.#webProgress.isLoadingDocument; + } + + get isStarted() { + return !!this.#deferredNavigation; + } + + get targetURI() { + return this.#targetURI; + } + + #checkLoadingState(request, options = {}) { + const { isStart = false, isStop = false, status = 0 } = options; + + this.#trace(`Check loading state: isStart=${isStart} isStop=${isStop}`); + if (isStart && !this.#seenStartFlag) { + this.#seenStartFlag = true; + + this.#targetURI = this.#getTargetURI(request); + + this.#trace(`state=start: ${this.targetURI?.spec}`); + + if (this.#unloadTimerId !== null) { + lazy.clearTimeout(this.#unloadTimerId); + this.#trace("Cleared the unload timer"); + this.#unloadTimerId = null; + } + + if (this.#resolveWhenStarted) { + this.#trace("Request to stop listening when navigation started"); + this.stop(); + return; + } + } + + if (isStop && this.#seenStartFlag) { + // Treat NS_ERROR_PARSED_DATA_CACHED as a success code + // since navigation happened and content has been loaded. + if ( + !Components.isSuccessCode(status) && + status != Cr.NS_ERROR_PARSED_DATA_CACHED + ) { + if ( + status == Cr.NS_BINDING_ABORTED && + this.browsingContext.currentWindowGlobal.isInitialDocument + ) { + this.#trace( + "Ignore aborted navigation error to the initial document, real document will be loaded." + ); + return; + } + + // The navigation request caused an error. + const errorName = ChromeUtils.getXPCOMErrorName(status); + this.#trace( + `state=stop: error=0x${status.toString(16)} (${errorName})` + ); + this.stop({ error: new Error(errorName) }); + return; + } + + this.#trace(`state=stop: ${this.currentURI.spec}`); + + // If a non initial page finished loading the navigation is done. + if (!this.browsingContext.currentWindowGlobal.isInitialDocument) { + this.stop(); + return; + } + + // Otherwise wait for a potential additional page load. + this.#trace( + "Initial document loaded. Wait for a potential further navigation." + ); + this.#seenStartFlag = false; + this.#setUnloadTimer(); + } + } + + #getTargetURI(request) { + try { + return request.QueryInterface(Ci.nsIChannel).originalURI; + } catch (e) {} + + return null; + } + + #setUnloadTimer() { + if (this.#expectNavigation) { + this.#trace("Skip setting the unload timer"); + } else { + this.#trace(`Setting unload timer (${this.#unloadTimeout}ms)`); + + this.#unloadTimerId = lazy.setTimeout(() => { + this.#trace(`No navigation detected: ${this.currentURI?.spec}`); + // Assume the target is the currently loaded URI. + this.#targetURI = this.currentURI; + this.stop(); + }, this.#unloadTimeout); + } + } + + #trace(message) { + lazy.logger.trace(lazy.truncate`${this.#messagePrefix} ${message}`); + } + + onStateChange(progress, request, flag, status) { + this.#checkLoadingState(request, { + isStart: flag & Ci.nsIWebProgressListener.STATE_START, + isStop: flag & Ci.nsIWebProgressListener.STATE_STOP, + status, + }); + } + + onLocationChange(progress, request, location, flag) { + // If an error page has been loaded abort the navigation. + if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) { + this.#trace(`location=errorPage: ${location.spec}`); + this.stop({ error: new Error("Address restricted") }); + return; + } + + // If location has changed in the same document the navigation is done. + if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { + this.#targetURI = location; + this.#trace(`location=sameDocument: ${this.targetURI?.spec}`); + this.stop(); + } + } + + /** + * Start observing web progress changes. + * + * @returns {Promise} + * A promise that will resolve when the navigation has been finished. + */ + start() { + if (this.#deferredNavigation) { + throw new Error(`Progress listener already started`); + } + + this.#trace( + `Start: expectNavigation=${this.#expectNavigation} resolveWhenStarted=${ + this.#resolveWhenStarted + } unloadTimeout=${this.#unloadTimeout} waitForExplicitStart=${ + this.#waitForExplicitStart + }` + ); + + if (this.#webProgress.isLoadingDocument) { + this.#targetURI = this.#getTargetURI(this.#webProgress.documentRequest); + this.#trace(`Document already loading ${this.#targetURI?.spec}`); + + if (this.#resolveWhenStarted && !this.#waitForExplicitStart) { + this.#trace( + "Resolve on document loading if not waiting for a load or a new navigation" + ); + return Promise.resolve(); + } + } + + this.#deferredNavigation = new lazy.Deferred(); + + // Enable all location change and state notifications to get informed about an upcoming load + // as early as possible. + this.#webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + + webProgressListeners.add(this); + + if (this.#webProgress.isLoadingDocument && !this.#waitForExplicitStart) { + this.#checkLoadingState(this.#webProgress.documentRequest, { + isStart: true, + }); + } else { + // If the document is not loading yet wait some time for the navigation + // to be started. + this.#setUnloadTimer(); + } + + return this.#deferredNavigation.promise; + } + + /** + * Stop observing web progress changes. + * + * @param {object=} options + * @param {Error=} options.error + * If specified the navigation promise will be rejected with this error. + */ + stop(options = {}) { + const { error } = options; + + this.#trace(`Stop: has error=${!!error}`); + + if (!this.#deferredNavigation) { + throw new Error("Progress listener not yet started"); + } + + lazy.clearTimeout(this.#unloadTimerId); + this.#unloadTimerId = null; + + this.#webProgress.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + webProgressListeners.delete(this); + + if (!this.#targetURI) { + // If no target URI has been set yet it should be the current URI + this.#targetURI = this.browsingContext.currentURI; + } + + if (error) { + this.#deferredNavigation.reject(error); + } else { + this.#deferredNavigation.resolve(); + } + + this.#deferredNavigation = null; + } + + /** + * Stop the progress listener if and only if we already detected a navigation + * start. + * + * @param {object=} options + * @param {Error=} options.error + * If specified the navigation promise will be rejected with this error. + */ + stopIfStarted(options) { + this.#trace(`Stop if started: seenStartFlag=${this.#seenStartFlag}`); + if (this.#seenStartFlag) { + this.stop(options); + } + } + + toString() { + return `[object ${this.constructor.name}]`; + } + + get QueryInterface() { + return ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]); + } +} diff --git a/remote/shared/NavigationManager.sys.mjs b/remote/shared/NavigationManager.sys.mjs new file mode 100644 index 0000000000..1f19ef3c0d --- /dev/null +++ b/remote/shared/NavigationManager.sys.mjs @@ -0,0 +1,414 @@ +/* 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/. */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + registerNavigationListenerActor: + "chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", + unregisterNavigationListenerActor: + "chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * @typedef {object} BrowsingContextDetails + * @property {string} browsingContextId - The browsing context id. + * @property {string} browserId - The id of the Browser owning the browsing + * context. + * @property {BrowsingContext=} context - The BrowsingContext itself, if + * available. + * @property {boolean} isTopBrowsingContext - Whether the browsing context is + * top level. + */ + +/** + * @typedef {object} NavigationInfo + * @property {boolean} finished - Whether the navigation is finished or not. + * @property {string} navigationId - The UUID for the navigation. + * @property {string} navigable - The UUID for the navigable. + * @property {string} url - The target url for the navigation. + */ + +/** + * The NavigationRegistry is responsible for monitoring all navigations happening + * in the browser. + * + * It relies on a JSWindowActor pair called NavigationListener{Parent|Child}, + * found under remote/shared/js-window-actors. As a simple overview, the + * NavigationListenerChild will monitor navigations in all window globals using + * content process WebProgressListener, and will forward each relevant update to + * the NavigationListenerParent + * + * The NavigationRegistry singleton holds the map of navigations, from navigable + * to NavigationInfo. It will also be called by NavigationListenerParent + * whenever a navigation event happens. + * + * This singleton is not exported outside of this class, and consumers instead + * need to use the NavigationManager class. The NavigationRegistry keeps track + * of how many NavigationListener instances are currently listening in order to + * know if the NavigationListenerActor should be registered or not. + * + * The NavigationRegistry exposes an API to retrieve the current or last + * navigation for a given navigable, and also forwards events to notify about + * navigation updates to individual NavigationManager instances. + * + * @class NavigationRegistry + */ +class NavigationRegistry extends EventEmitter { + #managers; + #navigations; + #navigationIds; + + constructor() { + super(); + + // Set of NavigationManager instances currently used. + this.#managers = new Set(); + + // Maps navigable to NavigationInfo. + this.#navigations = new WeakMap(); + + // Maps navigable id to navigation id. Only used to pre-register navigation + // ids before the actual event is detected. + this.#navigationIds = new Map(); + } + + /** + * Retrieve the last known navigation data for a given browsing context. + * + * @param {BrowsingContext} context + * The browsing context for which the navigation event was recorded. + * @returns {NavigationInfo|null} + * The last known navigation data, or null. + */ + getNavigationForBrowsingContext(context) { + if (!lazy.TabManager.isValidCanonicalBrowsingContext(context)) { + // Bail out if the provided context is not a valid CanonicalBrowsingContext + // instance. + return null; + } + + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + if (!this.#navigations.has(navigable)) { + return null; + } + + return this.#navigations.get(navigable); + } + + /** + * Start monitoring navigations in all browsing contexts. This will register + * the NavigationListener JSWindowActor and will initialize them in all + * existing browsing contexts. + */ + startMonitoring(listener) { + if (this.#managers.size == 0) { + lazy.registerNavigationListenerActor(); + } + + this.#managers.add(listener); + } + + /** + * Stop monitoring navigations. This will unregister the NavigationListener + * JSWindowActor and clear the information collected about navigations so far. + */ + stopMonitoring(listener) { + if (!this.#managers.has(listener)) { + return; + } + + this.#managers.delete(listener); + if (this.#managers.size == 0) { + lazy.unregisterNavigationListenerActor(); + // Clear the map. + this.#navigations = new WeakMap(); + } + } + + /** + * Called when a same-document navigation is recorded from the + * NavigationListener actors. + * + * This entry point is only intended to be called from + * NavigationListenerParent, to avoid setting up observers or listeners, + * which are unnecessary since NavigationManager has to be a singleton. + * + * @param {object} data + * @param {BrowsingContext} data.context + * The browsing context for which the navigation event was recorded. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The navigation created for this same-document navigation. + */ + notifyLocationChanged(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigationId = this.#getOrCreateNavigationId(navigableId); + const navigation = { finished: true, navigationId, url }; + this.#navigations.set(navigable, navigation); + + // Same document navigations are immediately done, fire a single event. + this.emit("location-changed", { navigationId, navigableId, url }); + + return navigation; + } + + /** + * Called when a navigation-started event is recorded from the + * NavigationListener actors. + * + * This entry point is only intended to be called from + * NavigationListenerParent, to avoid setting up observers or listeners, + * which are unnecessary since NavigationManager has to be a singleton. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The created navigation or the ongoing navigation, if applicable. + */ + notifyNavigationStarted(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + let navigation = this.#navigations.get(navigable); + if (navigation && !navigation.finished) { + // If we are already monitoring a navigation for this navigable, for which + // we did not receive a navigation-stopped event, this navigation + // is already tracked and we don't want to create another id & event. + lazy.logger.trace( + `[${navigableId}] Skipping already tracked navigation, navigationId: ${navigation.navigationId}` + ); + return navigation; + } + + const navigationId = this.#getOrCreateNavigationId(navigableId); + navigation = { finished: false, navigationId, url }; + this.#navigations.set(navigable, navigation); + + lazy.logger.trace( + lazy.truncate`[${navigableId}] Navigation started for url: ${url} (${navigationId})` + ); + + this.emit("navigation-started", { navigationId, navigableId, url }); + + return navigation; + } + + /** + * Called when a navigation-stopped event is recorded from the + * NavigationListener actors. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The stopped navigation if any, or null. + */ + notifyNavigationStopped(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigation = this.#navigations.get(navigable); + if (!navigation) { + lazy.logger.trace( + lazy.truncate`[${navigableId}] No navigation found to stop for url: ${url}` + ); + return null; + } + + if (navigation.finished) { + lazy.logger.trace( + `[${navigableId}] Navigation already marked as finished, navigationId: ${navigation.navigationId}` + ); + return navigation; + } + + lazy.logger.trace( + lazy.truncate`[${navigableId}] Navigation finished for url: ${url} (${navigation.navigationId})` + ); + + navigation.finished = true; + + this.emit("navigation-stopped", { + navigationId: navigation.navigationId, + navigableId, + url, + }); + + return navigation; + } + + /** + * Register a navigation id to be used for the next navigation for the + * provided browsing context details. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @returns {string} + * The UUID created the upcoming navigation. + */ + registerNavigationId(data) { + const { contextDetails } = data; + const context = this.#getContextFromContextDetails(contextDetails); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigationId = lazy.generateUUID(); + this.#navigationIds.set(navigableId, navigationId); + + return navigationId; + } + + #getContextFromContextDetails(contextDetails) { + if (contextDetails.context) { + return contextDetails.context; + } + + return contextDetails.isTopBrowsingContext + ? BrowsingContext.getCurrentTopByBrowserId(contextDetails.browserId) + : BrowsingContext.get(contextDetails.browsingContextId); + } + + #getOrCreateNavigationId(navigableId) { + let navigationId; + if (this.#navigationIds.has(navigableId)) { + navigationId = this.#navigationIds.get(navigableId, navigationId); + this.#navigationIds.delete(navigableId); + } else { + navigationId = lazy.generateUUID(); + } + return navigationId; + } +} + +// Create a private NavigationRegistry singleton. +const navigationRegistry = new NavigationRegistry(); + +/** + * See NavigationRegistry.notifyLocationChanged. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyLocationChanged(data) { + return navigationRegistry.notifyLocationChanged(data); +} + +/** + * See NavigationRegistry.notifyNavigationStarted. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyNavigationStarted(data) { + return navigationRegistry.notifyNavigationStarted(data); +} + +/** + * See NavigationRegistry.notifyNavigationStopped. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyNavigationStopped(data) { + return navigationRegistry.notifyNavigationStopped(data); +} + +export function registerNavigationId(data) { + return navigationRegistry.registerNavigationId(data); +} + +/** + * The NavigationManager exposes the NavigationRegistry data via a class which + * needs to be individually instantiated by each consumer. This allow to track + * how many consumers need navigation data at any point so that the + * NavigationRegistry can register or unregister the underlying JSWindowActors + * correctly. + * + * @fires navigation-started + * The NavigationManager emits "navigation-started" when a new navigation is + * detected, with the following object as payload: + * - {string} navigationId - The UUID for the navigation. + * - {string} navigableId - The UUID for the navigable. + * - {string} url - The target url for the navigation. + * @fires navigation-stopped + * The NavigationManager emits "navigation-stopped" when a known navigation + * is stopped, with the following object as payload: + * - {string} navigationId - The UUID for the navigation. + * - {string} navigableId - The UUID for the navigable. + * - {string} url - The target url for the navigation. + */ +export class NavigationManager extends EventEmitter { + #monitoring; + + constructor() { + super(); + + this.#monitoring = false; + } + + destroy() { + this.stopMonitoring(); + } + + getNavigationForBrowsingContext(context) { + return navigationRegistry.getNavigationForBrowsingContext(context); + } + + startMonitoring() { + if (this.#monitoring) { + return; + } + + this.#monitoring = true; + navigationRegistry.startMonitoring(this); + navigationRegistry.on("navigation-started", this.#onNavigationEvent); + navigationRegistry.on("location-changed", this.#onNavigationEvent); + navigationRegistry.on("navigation-stopped", this.#onNavigationEvent); + } + + stopMonitoring() { + if (!this.#monitoring) { + return; + } + + this.#monitoring = false; + navigationRegistry.stopMonitoring(this); + navigationRegistry.off("navigation-started", this.#onNavigationEvent); + navigationRegistry.off("location-changed", this.#onNavigationEvent); + navigationRegistry.off("navigation-stopped", this.#onNavigationEvent); + } + + #onNavigationEvent = (eventName, data) => { + this.emit(eventName, data); + }; +} diff --git a/remote/shared/PDF.sys.mjs b/remote/shared/PDF.sys.mjs new file mode 100644 index 0000000000..10fc2b0bae --- /dev/null +++ b/remote/shared/PDF.sys.mjs @@ -0,0 +1,244 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +export const print = { + maxScaleValue: 2.0, + minScaleValue: 0.1, +}; + +print.defaults = { + // The size of the page in centimeters. + page: { + width: 21.59, + height: 27.94, + }, + margin: { + top: 1.0, + bottom: 1.0, + left: 1.0, + right: 1.0, + }, + orientationValue: ["landscape", "portrait"], +}; + +print.addDefaultSettings = function (settings) { + const { + background = false, + margin = {}, + orientation = "portrait", + page = {}, + pageRanges = [], + scale = 1.0, + shrinkToFit = true, + } = settings; + + lazy.assert.object(page, `Expected "page" to be a object, got ${page}`); + lazy.assert.object(margin, `Expected "margin" to be a object, got ${margin}`); + + if (!("width" in page)) { + page.width = print.defaults.page.width; + } + + if (!("height" in page)) { + page.height = print.defaults.page.height; + } + + if (!("top" in margin)) { + margin.top = print.defaults.margin.top; + } + + if (!("bottom" in margin)) { + margin.bottom = print.defaults.margin.bottom; + } + + if (!("right" in margin)) { + margin.right = print.defaults.margin.right; + } + + if (!("left" in margin)) { + margin.left = print.defaults.margin.left; + } + + return { + background, + margin, + orientation, + page, + pageRanges, + scale, + shrinkToFit, + }; +}; + +print.getPrintSettings = function (settings) { + const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + + let cmToInches = cm => cm / 2.54; + const printSettings = psService.createNewPrintSettings(); + printSettings.isInitializedFromPrinter = true; + printSettings.isInitializedFromPrefs = true; + printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; + printSettings.printerName = "marionette"; + printSettings.printSilent = true; + + // Setting the paperSizeUnit to kPaperSizeMillimeters doesn't work on mac + printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches; + printSettings.paperWidth = cmToInches(settings.page.width); + printSettings.paperHeight = cmToInches(settings.page.height); + printSettings.usePageRuleSizeAsPaperSize = true; + + printSettings.marginBottom = cmToInches(settings.margin.bottom); + printSettings.marginLeft = cmToInches(settings.margin.left); + printSettings.marginRight = cmToInches(settings.margin.right); + printSettings.marginTop = cmToInches(settings.margin.top); + + printSettings.printBGColors = settings.background; + printSettings.printBGImages = settings.background; + printSettings.scaling = settings.scale; + printSettings.shrinkToFit = settings.shrinkToFit; + + printSettings.headerStrCenter = ""; + printSettings.headerStrLeft = ""; + printSettings.headerStrRight = ""; + printSettings.footerStrCenter = ""; + printSettings.footerStrLeft = ""; + printSettings.footerStrRight = ""; + + // Override any os-specific unwriteable margins + printSettings.unwriteableMarginTop = 0; + printSettings.unwriteableMarginLeft = 0; + printSettings.unwriteableMarginBottom = 0; + printSettings.unwriteableMarginRight = 0; + + if (settings.orientation === "landscape") { + printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation; + } + + if (settings.pageRanges?.length) { + printSettings.pageRanges = parseRanges(settings.pageRanges); + } + + return printSettings; +}; + +/** + * Convert array of strings of the form ["1-3", "2-4", "7", "9-"] to an flat array of + * limits, like [1, 4, 7, 7, 9, 2**31 - 1] (meaning 1-4, 7, 9-end) + * + * @param {Array.<string|number>} ranges + * Page ranges to print, e.g., ['1-5', '8', '11-13']. + * Defaults to the empty string, which means print all pages. + * + * @returns {Array.<number>} + * Even-length array containing page range limits + */ +function parseRanges(ranges) { + const MAX_PAGES = 0x7fffffff; + + if (ranges.length === 0) { + return []; + } + + let allLimits = []; + + for (let range of ranges) { + let limits; + if (typeof range !== "string") { + // We got a single integer so the limits are just that page + lazy.assert.positiveInteger(range); + limits = [range, range]; + } else { + // We got a string presumably of the form <int> | <int>? "-" <int>? + const msg = `Expected a range of the form <int> or <int>-<int>, got ${range}`; + + limits = range.split("-").map(x => x.trim()); + lazy.assert.that(o => [1, 2].includes(o.length), msg)(limits); + + // Single numbers map to a range with that page at the start and the end + if (limits.length == 1) { + limits.push(limits[0]); + } + + // Need to check that both limits are strings conisting only of + // decimal digits (or empty strings) + const assertNumeric = lazy.assert.that(o => /^\d*$/.test(o), msg); + limits.every(x => assertNumeric(x)); + + // Convert from strings representing numbers to actual numbers + // If we don't have an upper bound, choose something very large; + // the print code will later truncate this to the number of pages + limits = limits.map((limitStr, i) => { + if (limitStr == "") { + return i == 0 ? 1 : MAX_PAGES; + } + return parseInt(limitStr); + }); + } + lazy.assert.that( + x => x[0] <= x[1], + "Lower limit ${parts[0]} is higher than upper limit ${parts[1]}" + )(limits); + + allLimits.push(limits); + } + // Order by lower limit + allLimits.sort((a, b) => a[0] - b[0]); + let parsedRanges = [allLimits.shift()]; + for (let limits of allLimits) { + let prev = parsedRanges[parsedRanges.length - 1]; + let prevMax = prev[1]; + let [min, max] = limits; + if (min <= prevMax) { + // min is inside previous range, so extend the max if needed + if (max > prevMax) { + prev[1] = max; + } + } else { + // Otherwise we have a new range + parsedRanges.push(limits); + } + } + + let rv = parsedRanges.flat(); + lazy.logger.debug(`Got page ranges [${rv.join(", ")}]`); + return rv; +} + +print.printToBinaryString = async function (browsingContext, printSettings) { + // Create a stream to write to. + const stream = Cc["@mozilla.org/storagestream;1"].createInstance( + Ci.nsIStorageStream + ); + stream.init(4096, 0xffffffff); + + printSettings.outputDestination = + Ci.nsIPrintSettings.kOutputDestinationStream; + printSettings.outputStream = stream.getOutputStream(0); + + await browsingContext.print(printSettings); + + const inputStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + + inputStream.setInputStream(stream.newInputStream(0)); + + const available = inputStream.available(); + const bytes = inputStream.readBytes(available); + + stream.close(); + + return bytes; +}; diff --git a/remote/shared/Prompt.sys.mjs b/remote/shared/Prompt.sys.mjs new file mode 100644 index 0000000000..bacf24c5d1 --- /dev/null +++ b/remote/shared/Prompt.sys.mjs @@ -0,0 +1,233 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const COMMON_DIALOG = "chrome://global/content/commonDialog.xhtml"; + +/** @namespace */ +export const modal = { + ACTION_CLOSED: "closed", + ACTION_OPENED: "opened", +}; + +/** + * Check for already existing modal or tab modal dialogs and + * return the first one. + * + * @param {browser.Context} context + * Reference to the browser context to check for existent dialogs. + * + * @returns {modal.Dialog} + * Returns instance of the Dialog class, or `null` if no modal dialog + * is present. + */ +modal.findPrompt = function (context) { + // First check if there is a modal dialog already present for the + // current browser window. + for (let win of Services.wm.getEnumerator(null)) { + // TODO: Use BrowserWindowTracker.getTopWindow for modal dialogs without + // an opener. + if ( + win.document.documentURI === COMMON_DIALOG && + win.opener && + win.opener === context.window + ) { + lazy.logger.trace("Found open window modal prompt"); + return new modal.Dialog(() => context, win); + } + } + + if (lazy.AppInfo.isAndroid) { + const geckoViewPrompts = context.window.prompts(); + if (geckoViewPrompts.length) { + lazy.logger.trace("Found open GeckoView prompt"); + const prompt = geckoViewPrompts[0]; + return new modal.Dialog(() => context, prompt); + } + } + + const contentBrowser = context.contentBrowser; + + // If no modal dialog has been found yet, also check for tab and content modal + // dialogs for the current tab. + // + // TODO: Find an adequate implementation for Firefox on Android (bug 1708105) + if (contentBrowser?.tabDialogBox) { + let dialogs = contentBrowser.tabDialogBox.getTabDialogManager().dialogs; + if (dialogs.length) { + lazy.logger.trace("Found open tab modal prompt"); + return new modal.Dialog(() => context, dialogs[0].frameContentWindow); + } + + dialogs = contentBrowser.tabDialogBox.getContentDialogManager().dialogs; + + // Even with the dialog manager handing back a dialog, the `Dialog` property + // gets lazily added. If it's not set yet, ignore the dialog for now. + if (dialogs.length && dialogs[0].frameContentWindow.Dialog) { + lazy.logger.trace("Found open content prompt"); + return new modal.Dialog(() => context, dialogs[0].frameContentWindow); + } + } + + // If no modal dialog has been found yet, check for old non SubDialog based + // content modal dialogs. Even with those deprecated in Firefox 89 we should + // keep supporting applications that don't have them implemented yet. + if (contentBrowser?.tabModalPromptBox) { + const prompts = contentBrowser.tabModalPromptBox.listPrompts(); + if (prompts.length) { + lazy.logger.trace("Found open old-style content prompt"); + return new modal.Dialog(() => context, null); + } + } + + return null; +}; + +/** + * Represents a modal dialog. + * + * @param {function(): browser.Context} curBrowserFn + * Function that returns the current |browser.Context|. + * @param {DOMWindow} dialog + * DOMWindow of the dialog. + */ +modal.Dialog = class { + constructor(curBrowserFn, dialog) { + this.curBrowserFn_ = curBrowserFn; + this.win_ = Cu.getWeakReference(dialog); + } + + get args() { + if (lazy.AppInfo.isAndroid) { + return this.window.args; + } + let tm = this.tabModal; + return tm ? tm.args : null; + } + + get curBrowser_() { + return this.curBrowserFn_(); + } + + get isOpen() { + if (lazy.AppInfo.isAndroid) { + return this.window !== null; + } + if (!this.ui) { + return false; + } + return true; + } + + get isWindowModal() { + return [ + Services.prompt.MODAL_TYPE_WINDOW, + Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + ].includes(this.args.modalType); + } + + get tabModal() { + let win = this.window; + if (win) { + return win.Dialog; + } + return this.curBrowser_.getTabModal(); + } + + get promptType() { + const promptType = this.args.promptType; + + if (promptType === "confirmEx" && this.args.inPermitUnload) { + return "beforeunload"; + } + + return promptType; + } + + get ui() { + let tm = this.tabModal; + return tm ? tm.ui : null; + } + + /** + * For Android, this returns a GeckoViewPrompter, which can be used to control prompts. + * Otherwise, this returns the ChromeWindow associated with an open dialog window if + * it is currently attached to the DOM. + */ + get window() { + if (this.win_) { + let win = this.win_.get(); + if (win && (lazy.AppInfo.isAndroid || win.parent)) { + return win; + } + } + return null; + } + + set text(inputText) { + if (lazy.AppInfo.isAndroid) { + this.window.setInputText(inputText); + } else { + // see toolkit/components/prompts/content/commonDialog.js + let { loginTextbox } = this.ui; + loginTextbox.value = inputText; + } + } + + accept() { + if (lazy.AppInfo.isAndroid) { + // GeckoView does not have a UI, so the methods are called directly + this.window.acceptPrompt(); + } else { + const { button0 } = this.ui; + button0.click(); + } + } + + dismiss() { + if (lazy.AppInfo.isAndroid) { + // GeckoView does not have a UI, so the methods are called directly + this.window.dismissPrompt(); + } else { + const { button0, button1 } = this.ui; + (button1 ? button1 : button0).click(); + } + } + + /** + * Returns text of the prompt. + * + * @returns {string | Promise} + * Returns string on desktop and Promise on Android. + */ + async getText() { + if (lazy.AppInfo.isAndroid) { + const textPromise = await this.window.getPromptText(); + return textPromise; + } + return this.ui.infoBody.textContent; + } + + /** + * Returns text of the prompt input. + * + * @returns {string} + * Returns string on desktop and Promise on Android. + */ + async getInputText() { + if (lazy.AppInfo.isAndroid) { + const textPromise = await this.window.getInputText(); + return textPromise; + } + return this.ui.loginTextbox.value; + } +}; diff --git a/remote/shared/Realm.sys.mjs b/remote/shared/Realm.sys.mjs new file mode 100644 index 0000000000..5bf4a2fa3a --- /dev/null +++ b/remote/shared/Realm.sys.mjs @@ -0,0 +1,382 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + addDebuggerToGlobal: "resource://gre/modules/jsdebugger.sys.mjs", + + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "dbg", () => { + // eslint-disable-next-line mozilla/reject-globalThis-modification + lazy.addDebuggerToGlobal(globalThis); + return new Debugger(); +}); + +/** + * @typedef {string} RealmType + */ + +/** + * Enum of realm types. + * + * @readonly + * @enum {RealmType} + */ +export const RealmType = { + AudioWorklet: "audio-worklet", + DedicatedWorker: "dedicated-worker", + PaintWorklet: "paint-worklet", + ServiceWorker: "service-worker", + SharedWorker: "shared-worker", + Window: "window", + Worker: "worker", + Worklet: "worklet", +}; + +/** + * Base class that wraps any kind of WebDriver BiDi realm. + */ +export class Realm { + #handleObjectMap; + #id; + + constructor() { + this.#id = lazy.generateUUID(); + + // Map of unique handles (UUIDs) to objects belonging to this realm. + this.#handleObjectMap = new Map(); + } + + destroy() { + this.#handleObjectMap = null; + } + + /** + * Get the browsing context of the realm instance. + */ + get browsingContext() { + return null; + } + + /** + * Get the unique identifier of the realm instance. + * + * @returns {string} The unique identifier. + */ + get id() { + return this.#id; + } + + /** + * A getter to get a realm origin. + * + * It's required to be implemented in the sub class. + */ + get origin() { + throw new Error("Not implemented"); + } + + /** + * Ensure the provided object can be used within this realm. + + * @param {object} obj + * Any non-primitive object. + + * @returns {object} + * An object usable in the current realm. + */ + cloneIntoRealm(obj) { + return obj; + } + + /** + * Remove the reference corresponding to the provided unique handle. + * + * @param {string} handle + * The unique handle of an object reference tracked in this realm. + */ + removeObjectHandle(handle) { + this.#handleObjectMap.delete(handle); + } + + /** + * Get a new unique handle for the provided object, creating a strong + * reference on the object. + * + * @param {object} object + * Any non-primitive object. + * @returns {string} The unique handle created for this strong reference. + */ + getHandleForObject(object) { + const handle = lazy.generateUUID(); + this.#handleObjectMap.set(handle, object); + return handle; + } + + /** + * Get the basic realm information. + * + * @returns {BaseRealmInfo} + */ + getInfo() { + return { + realm: this.#id, + origin: this.origin, + }; + } + + /** + * Retrieve the object corresponding to the provided unique handle. + * + * @param {string} handle + * The unique handle of an object reference tracked in this realm. + * @returns {object} object + * Any non-primitive object. + */ + getObjectForHandle(handle) { + return this.#handleObjectMap.get(handle); + } +} + +/** + * Wrapper for Window realms including sandbox objects. + */ +export class WindowRealm extends Realm { + #realmAutomationFeaturesEnabled; + #globalObject; + #globalObjectReference; + #isSandbox; + #sandboxName; + #userActivationEnabled; + #window; + + static type = RealmType.Window; + + /** + * + * @param {Window} window + * The window global to wrap. + * @param {object} options + * @param {string=} options.sandboxName + * Name of the sandbox to create if specified. Defaults to `null`. + */ + constructor(window, options = {}) { + const { sandboxName = null } = options; + + super(); + + this.#isSandbox = sandboxName !== null; + this.#sandboxName = sandboxName; + this.#window = window; + this.#globalObject = this.#isSandbox ? this.#createSandbox() : this.#window; + this.#globalObjectReference = lazy.dbg.makeGlobalObjectReference( + this.#globalObject + ); + this.#realmAutomationFeaturesEnabled = false; + this.#userActivationEnabled = false; + } + + destroy() { + if (this.#realmAutomationFeaturesEnabled) { + lazy.dbg.disableAsyncStack(this.#globalObject); + lazy.dbg.disableUnlimitedStacksCapturing(this.#globalObject); + this.#realmAutomationFeaturesEnabled = false; + } + + this.#globalObjectReference = null; + this.#globalObject = null; + this.#window = null; + + super.destroy(); + } + + get browsingContext() { + return this.#window.browsingContext; + } + + get globalObjectReference() { + return this.#globalObjectReference; + } + + get isSandbox() { + return this.#isSandbox; + } + + get origin() { + return this.#window.origin; + } + + get userActivationEnabled() { + return this.#userActivationEnabled; + } + + set userActivationEnabled(enable) { + if (enable === this.#userActivationEnabled) { + return; + } + + const document = this.#window.document; + if (enable) { + document.notifyUserGestureActivation(); + } else { + document.clearUserGestureActivation(); + } + + this.#userActivationEnabled = enable; + } + + #createDebuggerObject(obj) { + return this.#globalObjectReference.makeDebuggeeValue(obj); + } + + #createSandbox() { + const win = this.#window; + const opts = { + sameZoneAs: win, + sandboxPrototype: win, + wantComponents: false, + wantXrays: true, + }; + + return new Cu.Sandbox(win, opts); + } + + #enableRealmAutomationFeatures() { + if (!this.#realmAutomationFeaturesEnabled) { + lazy.dbg.enableAsyncStack(this.#globalObject); + lazy.dbg.enableUnlimitedStacksCapturing(this.#globalObject); + this.#realmAutomationFeaturesEnabled = true; + } + } + + /** + * Clone the provided object into the scope of this Realm (either a window + * global, or a sandbox). + * + * @param {object} obj + * Any non-primitive object. + * + * @returns {object} + * The cloned object. + */ + cloneIntoRealm(obj) { + return Cu.cloneInto(obj, this.#globalObject, { cloneFunctions: true }); + } + + /** + * Evaluates a provided expression in the context of the current realm. + * + * @param {string} expression + * The expression to evaluate. + * + * @returns {object} + * - evaluationStatus {EvaluationStatus} One of "normal", "throw". + * - exceptionDetails {ExceptionDetails=} the details of the exception if + * the evaluation status was "throw". + * - result {RemoteValue=} the result of the evaluation serialized as a + * RemoteValue if the evaluation status was "normal". + */ + executeInGlobal(expression) { + this.#enableRealmAutomationFeatures(); + return this.#globalObjectReference.executeInGlobal(expression, { + url: this.#window.document.baseURI, + }); + } + + /** + * Call a function in the context of the current realm. + * + * @param {string} functionDeclaration + * The body of the function to call. + * @param {Array<object>} functionArguments + * The arguments to pass to the function call. + * @param {object} thisParameter + * The value of the `this` keyword for the function call. + * + * @returns {object} + * - evaluationStatus {EvaluationStatus} One of "normal", "throw". + * - exceptionDetails {ExceptionDetails=} the details of the exception if + * the evaluation status was "throw". + * - result {RemoteValue=} the result of the evaluation serialized as a + * RemoteValue if the evaluation status was "normal". + */ + executeInGlobalWithBindings( + functionDeclaration, + functionArguments, + thisParameter + ) { + this.#enableRealmAutomationFeatures(); + const expression = `(${functionDeclaration}).apply(__bidi_this, __bidi_args)`; + + const args = this.cloneIntoRealm([]); + for (const arg of functionArguments) { + args.push(arg); + } + + return this.#globalObjectReference.executeInGlobalWithBindings( + expression, + { + __bidi_args: this.#createDebuggerObject(args), + __bidi_this: this.#createDebuggerObject(thisParameter), + }, + { + url: this.#window.document.baseURI, + } + ); + } + + /** + * Get the realm information. + * + * @returns {object} + * - context {BrowsingContext} The browsing context, associated with the realm. + * - id {string} The realm unique identifier. + * - origin {string} The serialization of an origin. + * - sandbox {string=} The name of the sandbox. + * - type {RealmType.Window} The window realm type. + */ + getInfo() { + const baseInfo = super.getInfo(); + const info = { + ...baseInfo, + context: this.#window.browsingContext, + type: WindowRealm.type, + }; + + if (this.#isSandbox) { + info.sandbox = this.#sandboxName; + } + + return info; + } + + /** + * Log an error caused by a script evaluation. + * + * @param {string} message + * The error message. + * @param {Stack} stack + * The JavaScript stack trace. + */ + reportError(message, stack) { + const { column, line, source: sourceLine } = stack; + + const scriptErrorClass = Cc["@mozilla.org/scripterror;1"]; + const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError); + + scriptError.initWithWindowID( + message, + this.#window.document.baseURI, + sourceLine, + line, + column, + Ci.nsIScriptError.errorFlag, + "content javascript", + this.#window.windowGlobalChild.innerWindowId + ); + Services.console.logMessage(scriptError); + } +} diff --git a/remote/shared/RecommendedPreferences.sys.mjs b/remote/shared/RecommendedPreferences.sys.mjs new file mode 100644 index 0000000000..d0a7739e52 --- /dev/null +++ b/remote/shared/RecommendedPreferences.sys.mjs @@ -0,0 +1,440 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "useRecommendedPrefs", + "remote.prefs.recommended", + false +); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +// Ensure we are in the parent process. +if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + throw new Error( + "RecommendedPreferences should only be loaded in the parent process" + ); +} + +// ALL CHANGES TO THIS LIST MUST HAVE REVIEW FROM A WEBDRIVER PEER! +// +// Preferences are set for automation on startup, unless +// remote.prefs.recommended has been set to false. +// +// Note: Clients do not always use the latest version of the application. As +// such backward compatibility has to be ensured at least for the last three +// releases. + +// INSTRUCTIONS TO ADD A NEW PREFERENCE +// +// Preferences for remote control and automation can be set from several entry +// points: +// - remote/shared/RecommendedPreferences.sys.mjs +// - remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts +// - testing/geckodriver/src/prefs.rs +// - testing/marionette/client/marionette_driver/geckoinstance.py +// +// The preferences in `firefox.ts`, `prefs.rs` and `geckoinstance.py` +// will be applied before the application starts, and should typically be used +// for preferences which cannot be updated during the lifetime of the application. +// +// The preferences in `RecommendedPreferences.sys.mjs` are applied after +// the application has started, which means that the application must apply this +// change dynamically and behave correctly. Note that you can also define +// protocol specific preferences (CDP, WebDriver, ...) which are merged with the +// COMMON_PREFERENCES from `RecommendedPreferences.sys.mjs`. +// +// Additionally, users relying on the Marionette Python client (ie. using +// geckoinstance.py) set `remote.prefs.recommended = false`. This means that +// preferences from `RecommendedPreferences.sys.mjs` are not applied and have to +// be added to the list of preferences in that Python file. Note that there are +// several lists of preferences, either common or specific to a given application +// (Firefox Desktop, Fennec, Thunderbird). +// +// Depending on how users interact with the Remote Agent, they will use different +// combinations of preferences. So it's important to update the preferences files +// so that all users have the proper preferences. +// +// When adding a new preference, follow this guide to decide where to add it: +// - Add the preference to `geckoinstance.py` +// - If the preference has to be set before startup: +// - Add the preference to `prefs.rs` +// - Add the preference `browser-data/firefox.ts` in the puppeteer folder +// - Create a PR to upstream the change on `browser-data/firefox.ts` to puppeteer +// - Otherwise, if the preference can be set after startup: +// - Add the preference to `RecommendedPreferences.sys.mjs` +const COMMON_PREFERENCES = new Map([ + // Make sure Shield doesn't hit the network. + ["app.normandy.api_url", ""], + + // Disable automatically upgrading Firefox + // + // Note: This preference should have already been set by the client when + // creating the profile. But if not and to absolutely make sure that updates + // of Firefox aren't downloaded and applied, enforce its presence. + ["app.update.disabledForTesting", true], + + // Increase the APZ content response timeout in tests to 1 minute. + // This is to accommodate the fact that test environments tends to be + // slower than production environments (with the b2g emulator being + // the slowest of them all), resulting in the production timeout value + // sometimes being exceeded and causing false-positive test failures. + // + // (bug 1176798, bug 1177018, bug 1210465) + ["apz.content_response_timeout", 60000], + + // Don't show the content blocking introduction panel. + // We use a larger number than the default 22 to have some buffer + // This can be removed once Firefox 69 and 68 ESR and are no longer supported. + ["browser.contentblocking.introCount", 99], + + // Indicate that the download panel has been shown once so that + // whichever download test runs first doesn't show the popup + // inconsistently. + ["browser.download.panel.shown", true], + + // Make sure Topsites doesn't hit the network to retrieve sponsored tiles. + ["browser.newtabpage.activity-stream.showSponsoredTopSites", false], + + // Always display a blank page + ["browser.newtabpage.enabled", false], + + // Background thumbnails in particular cause grief, and disabling + // thumbnails in general cannot hurt + ["browser.pagethumbnails.capturing_disabled", true], + + // Disable geolocation ping(#1) + ["browser.region.network.url", ""], + + // Disable safebrowsing components. + // + // These should also be set in the profile prior to starting Firefox, + // as it is picked up at runtime. + ["browser.safebrowsing.blockedURIs.enabled", false], + ["browser.safebrowsing.downloads.enabled", false], + ["browser.safebrowsing.malware.enabled", false], + ["browser.safebrowsing.phishing.enabled", false], + + // Disable updates to search engines. + // + // Should be set in profile. + ["browser.search.update", false], + + // Do not restore the last open set of tabs if the browser has crashed + ["browser.sessionstore.resume_from_crash", false], + + // Don't check for the default web browser during startup. + // + // These should also be set in the profile prior to starting Firefox, + // as it is picked up at runtime. + ["browser.shell.checkDefaultBrowser", false], + + // Disable session restore infobar + ["browser.startup.couldRestoreSession.count", -1], + + // Do not redirect user when a milstone upgrade of Firefox is detected + ["browser.startup.homepage_override.mstone", "ignore"], + + // Don't unload tabs when available memory is running low + ["browser.tabs.unloadOnLowMemory", false], + + // Do not warn when closing all open tabs + ["browser.tabs.warnOnClose", false], + + // Do not warn when closing all other open tabs + ["browser.tabs.warnOnCloseOtherTabs", false], + + // Do not warn when multiple tabs will be opened + ["browser.tabs.warnOnOpen", false], + + // Don't show the Bookmarks Toolbar on any tab (the above pref that + // disables the New Tab Page ends up showing the toolbar on about:blank). + ["browser.toolbars.bookmarks.visibility", "never"], + + // Make sure Topsites doesn't hit the network to retrieve tiles from Contile. + ["browser.topsites.contile.enabled", false], + + // Disable first run splash page on Windows 10 + ["browser.usedOnWindows10.introURL", ""], + + // Turn off Merino suggestions in the location bar so as not to trigger + // network connections. + ["browser.urlbar.merino.endpointURL", ""], + + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + ["browser.urlbar.suggest.searches", false], + + // Do not warn on quitting Firefox + ["browser.warnOnQuit", false], + + // Do not show datareporting policy notifications which can + // interfere with tests + [ + "datareporting.healthreport.documentServerURI", + "http://%(server)s/dummy/healthreport/", + ], + ["datareporting.healthreport.logging.consoleEnabled", false], + ["datareporting.healthreport.service.enabled", false], + ["datareporting.healthreport.service.firstRun", false], + ["datareporting.healthreport.uploadEnabled", false], + ["datareporting.policy.dataSubmissionEnabled", false], + ["datareporting.policy.dataSubmissionPolicyAccepted", false], + ["datareporting.policy.dataSubmissionPolicyBypassNotification", true], + + // Disable popup-blocker + ["dom.disable_open_during_load", false], + + // Enabling the support for File object creation in the content process + ["dom.file.createInChild", true], + + // Disable delayed user input event handling + ["dom.input_events.security.minNumTicks", 0], + ["dom.input_events.security.minTimeElapsedInMS", 0], + + // Disable the ProcessHangMonitor + ["dom.ipc.reportProcessHangs", false], + + // Disable slow script dialogues + ["dom.max_chrome_script_run_time", 0], + ["dom.max_script_run_time", 0], + + // Disable location change rate limitation + ["dom.navigation.locationChangeRateLimit.count", 0], + + // DOM Push + ["dom.push.connection.enabled", false], + + // Screen Orientation API + ["dom.screenorientation.allow-lock", true], + + // Disable dialog abuse if alerts are triggered too quickly. + ["dom.successive_dialog_time_limit", 0], + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + // + // Should be set in profile. + ["extensions.autoDisableScopes", 0], + ["extensions.enabledScopes", 5], + + // Disable metadata caching for installed add-ons by default + ["extensions.getAddons.cache.enabled", false], + + // Disable installing any distribution extensions or add-ons. + // Should be set in profile. + ["extensions.installDistroAddons", false], + + // Turn off extension updates so they do not bother tests + ["extensions.update.enabled", false], + ["extensions.update.notifyUser", false], + + // Make sure opening about:addons will not hit the network + ["extensions.getAddons.discovery.api_url", "data:, "], + + // Redirect various extension update URLs + [ + "extensions.blocklist.detailsURL", + "http://%(server)s/extensions-dummy/blocklistDetailsURL", + ], + [ + "extensions.blocklist.itemURL", + "http://%(server)s/extensions-dummy/blocklistItemURL", + ], + ["extensions.hotfix.url", "http://%(server)s/extensions-dummy/hotfixURL"], + [ + "extensions.systemAddon.update.url", + "http://%(server)s/dummy-system-addons.xml", + ], + [ + "extensions.update.background.url", + "http://%(server)s/extensions-dummy/updateBackgroundURL", + ], + ["extensions.update.url", "http://%(server)s/extensions-dummy/updateURL"], + + // Make sure opening about: addons won't hit the network + ["extensions.getAddons.discovery.api_url", "data:, "], + [ + "extensions.getAddons.get.url", + "http://%(server)s/extensions-dummy/repositoryGetURL", + ], + [ + "extensions.getAddons.search.browseURL", + "http://%(server)s/extensions-dummy/repositoryBrowseURL", + ], + + // Allow the application to have focus even it runs in the background + ["focusmanager.testmode", true], + + // Disable useragent updates + ["general.useragent.updates.enabled", false], + + // Disable geolocation ping(#2) + ["geo.provider.network.url", ""], + + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + ["geo.provider.testing", true], + + // Do not scan Wifi + ["geo.wifi.scan", false], + + // Disable Firefox accounts ping + ["identity.fxaccounts.auth.uri", "https://{server}/dummy/fxa"], + + // Disable connectivity service pings + ["network.connectivity-service.enabled", false], + + // Do not prompt with long usernames or passwords in URLs + ["network.http.phishy-userpass-length", 255], + + // Do not prompt for temporary redirects + ["network.http.prompt-temp-redirect", false], + + // Do not automatically switch between offline and online + ["network.manage-offline-status", false], + + // Make sure SNTP requests do not hit the network + ["network.sntp.pools", "%(server)s"], + + // Privacy and Tracking Protection + ["privacy.trackingprotection.enabled", false], + + // Don't do network connections for mitm priming + ["security.certerrors.mitm.priming.enabled", false], + + // Local documents have access to all other local documents, + // including directory listings + ["security.fileuri.strict_origin_policy", false], + + // Tests do not wait for the notification button security delay + ["security.notification_enable_delay", 0], + + // Do not download intermediate certificates + ["security.remote_settings.intermediates.enabled", false], + + // Ensure remote settings do not hit the network + ["services.settings.server", "data:,#remote-settings-dummy/v1"], + + // Do not automatically fill sign-in forms with known usernames and + // passwords + ["signon.autofillForms", false], + + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + ["signon.rememberSignons", false], + + // Disable first-run welcome page + ["startup.homepage_welcome_url", "about:blank"], + ["startup.homepage_welcome_url.additional", ""], + + // Prevent starting into safe mode after application crashes + ["toolkit.startup.max_resumed_crashes", -1], + + // Disable all telemetry pings + ["toolkit.telemetry.server", "https://%(server)s/telemetry-dummy/"], + + // Disable window occlusion on Windows, which can prevent webdriver commands + // such as WebDriver:FindElements from working properly (Bug 1802473). + ["widget.windows.window_occlusion_tracking.enabled", false], +]); + +export const RecommendedPreferences = { + alteredPrefs: new Set(), + + isInitialized: false, + + /** + * Apply the provided map of preferences. + * + * Note, that they will be automatically reset on application shutdown. + * + * @param {Map<string, object>=} preferences + * Map of preference name to preference value. + */ + applyPreferences(preferences) { + if (!lazy.useRecommendedPrefs) { + // If remote.prefs.recommended is set to false, do not set any preference + // here. Needed for our Firefox CI. + return; + } + + // Only apply common recommended preferences on first call to + // applyPreferences. + if (!this.isInitialized) { + // Merge common preferences and optionally provided preferences in a + // single map. Hereby the extra preferences have higher priority. + if (preferences) { + preferences = new Map([...COMMON_PREFERENCES, ...preferences]); + } else { + preferences = COMMON_PREFERENCES; + } + + Services.obs.addObserver(this, "quit-application"); + this.isInitialized = true; + } + + for (const [k, v] of preferences) { + if (!Services.prefs.prefHasUserValue(k)) { + lazy.logger.debug(`Setting recommended pref ${k} to ${v}`); + + switch (typeof v) { + case "string": + Services.prefs.setStringPref(k, v); + break; + case "boolean": + Services.prefs.setBoolPref(k, v); + break; + case "number": + Services.prefs.setIntPref(k, v); + break; + default: + throw new TypeError(`Invalid preference type: ${typeof v}`); + } + + // Keep track all the altered preferences to restore them on + // quit-application. + this.alteredPrefs.add(k); + } + } + }, + + observe(subject, topic) { + if (topic === "quit-application") { + Services.obs.removeObserver(this, "quit-application"); + this.restoreAllPreferences(); + } + }, + + /** + * Restore all the altered preferences. + */ + restoreAllPreferences() { + this.restorePreferences(this.alteredPrefs); + this.isInitialized = false; + }, + + /** + * Restore provided preferences. + * + * @param {Map} preferences + * Map of preferences that should be restored. + */ + restorePreferences(preferences) { + for (const k of preferences.keys()) { + lazy.logger.debug(`Resetting recommended pref ${k}`); + Services.prefs.clearUserPref(k); + this.alteredPrefs.delete(k); + } + }, +}; diff --git a/remote/shared/RemoteError.sys.mjs b/remote/shared/RemoteError.sys.mjs new file mode 100644 index 0000000000..2e326d5d18 --- /dev/null +++ b/remote/shared/RemoteError.sys.mjs @@ -0,0 +1,19 @@ +/* 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/. */ + +/** + * Base class for all remote protocol errors. + */ +export class RemoteError extends Error { + get isRemoteError() { + return true; + } + + /** + * Convert to a serializable object. Should be implemented by subclasses. + */ + toJSON() { + throw new Error("Not implemented"); + } +} diff --git a/remote/shared/Stack.sys.mjs b/remote/shared/Stack.sys.mjs new file mode 100644 index 0000000000..d0c7f9407d --- /dev/null +++ b/remote/shared/Stack.sys.mjs @@ -0,0 +1,73 @@ +/* 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/. */ + +/** + * An object that contains details of a stack frame. + * + * @typedef {object} StackFrame + * @see nsIStackFrame + * + * @property {string=} asyncCause + * Type of asynchronous call by which this frame was invoked. + * @property {number} columnNumber + * The column number for this stack frame. + * @property {string} filename + * The source URL for this stack frame. + * @property {string} function + * SpiderMonkey’s inferred name for this stack frame’s function, or null. + * @property {number} lineNumber + * The line number for this stack frame (starts with 1). + * @property {number} sourceId + * The process-unique internal integer ID of this source. + */ + +/** + * Return a list of stack frames from the given stack. + * + * Convert stack objects to the JSON attributes expected by consumers. + * + * @param {object} stack + * The native stack object to process. + * + * @returns {Array<StackFrame>=} + */ +export function getFramesFromStack(stack) { + if (!stack || (Cu && Cu.isDeadWrapper(stack))) { + // If the global from which this error came from has been nuked, + // stack is going to be a dead wrapper. + return null; + } + + const frames = []; + while (stack) { + frames.push({ + asyncCause: stack.asyncCause, + columnNumber: stack.column, + filename: stack.source, + functionName: stack.functionDisplayName || "", + lineNumber: stack.line, + sourceId: stack.sourceId, + }); + + stack = stack.parent || stack.asyncParent; + } + + return frames; +} + +/** + * Check if a frame is from chrome scope. + * + * @param {object} frame + * The frame to check + * + * @returns {boolean} + * True, if frame is from chrome scope + */ +export function isChromeFrame(frame) { + return ( + frame.filename.startsWith("chrome://") || + frame.filename.startsWith("resource://") + ); +} diff --git a/remote/shared/Sync.sys.mjs b/remote/shared/Sync.sys.mjs new file mode 100644 index 0000000000..7b14f8b2c8 --- /dev/null +++ b/remote/shared/Sync.sys.mjs @@ -0,0 +1,335 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer; + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT) +); + +/** + * Throttle until the `window` has performed an animation frame. + * + * @param {ChromeWindow} win + * Window to request the animation frame from. + * + * @returns {Promise} + */ +export function AnimationFramePromise(win) { + const animationFramePromise = new Promise(resolve => { + win.requestAnimationFrame(resolve); + }); + + // Abort if the underlying window gets closed + const windowClosedPromise = new PollPromise(resolve => { + if (win.closed) { + resolve(); + } + }); + + return Promise.race([animationFramePromise, windowClosedPromise]); +} + +/** + * Create a helper object to defer a promise. + * + * @returns {object} + * An object that returns the following properties: + * - fulfilled Flag that indicates that the promise got resolved + * - pending Flag that indicates a not yet fulfilled/rejected promise + * - promise The actual promise + * - reject Callback to reject the promise + * - rejected Flag that indicates that the promise got rejected + * - resolve Callback to resolve the promise + */ +export function Deferred() { + const deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.fulfilled = false; + deferred.pending = true; + deferred.rejected = false; + + deferred.resolve = (...args) => { + deferred.fulfilled = true; + deferred.pending = false; + resolve(...args); + }; + + deferred.reject = (...args) => { + deferred.pending = false; + deferred.rejected = true; + reject(...args); + }; + }); + + return deferred; +} + +/** + * Wait for an event to be fired on a specified element. + * + * The returned promise is guaranteed to not resolve before the + * next event tick after the event listener is called, so that all + * other event listeners for the element are executed before the + * handler is executed. For example: + * + * const promise = new EventPromise(element, "myEvent"); + * // same event tick here + * await promise; + * // next event tick here + * + * @param {Element} subject + * The element that should receive the event. + * @param {string} eventName + * Case-sensitive string representing the event name to listen for. + * @param {object=} options + * @param {boolean=} options.capture + * Indicates the event will be despatched to this subject, + * before it bubbles down to any EventTarget beneath it in the + * DOM tree. Defaults to false. + * @param {Function=} options.checkFn + * Called with the Event object as argument, should return true if the + * event is the expected one, or false if it should be ignored and + * listening should continue. If not specified, the first event with + * the specified name resolves the returned promise. Defaults to null. + * @param {number=} options.timeout + * Timeout duration in milliseconds, if provided. + * If specified, then the returned promise will be rejected with + * TimeoutError, if not already resolved, after this duration has elapsed. + * If not specified, then no timeout is used. Defaults to null. + * @param {boolean=} options.mozSystemGroup + * Determines whether to add listener to the system group. Defaults to + * false. + * @param {boolean=} options.wantUntrusted + * Receive synthetic events despatched by web content. Defaults to false. + * + * @returns {Promise<Event>} + * Either fulfilled with the first described event, satisfying + * options.checkFn if specified, or rejected with TimeoutError after + * options.timeout milliseconds if specified. + * + * @throws {TypeError} + * @throws {RangeError} + */ +export function EventPromise(subject, eventName, options = {}) { + const { + capture = false, + checkFn = null, + timeout = null, + mozSystemGroup = false, + wantUntrusted = false, + } = options; + if ( + !subject || + !("addEventListener" in subject) || + typeof eventName != "string" || + typeof capture != "boolean" || + (checkFn && typeof checkFn != "function") || + (timeout !== null && typeof timeout != "number") || + typeof mozSystemGroup != "boolean" || + typeof wantUntrusted != "boolean" + ) { + throw new TypeError(); + } + if (timeout < 0) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let timer; + + function cleanUp() { + subject.removeEventListener(eventName, listener, capture); + timer?.cancel(); + } + + function listener(event) { + lazy.logger.trace(`Received DOM event ${event.type} for ${event.target}`); + try { + if (checkFn && !checkFn(event)) { + return; + } + } catch (e) { + // Treat an exception in the callback as a falsy value + lazy.logger.warn(`Event check failed: ${e.message}`); + } + + cleanUp(); + executeSoon(() => resolve(event)); + } + + subject.addEventListener(eventName, listener, { + capture, + mozSystemGroup, + wantUntrusted, + }); + + if (timeout !== null) { + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + cleanUp(); + reject( + new lazy.error.TimeoutError( + `EventPromise timed out after ${timeout} ms` + ) + ); + }, + timeout, + TYPE_ONE_SHOT + ); + } + }); +} + +/** + * Wait for the next tick in the event loop to execute a callback. + * + * @param {Function} fn + * Function to be executed. + */ +export function executeSoon(fn) { + if (typeof fn != "function") { + throw new TypeError(); + } + + Services.tm.dispatchToMainThread(fn); +} + +/** + * Runs a Promise-like function off the main thread until it is resolved + * through ``resolve`` or ``rejected`` callbacks. The function is + * guaranteed to be run at least once, irregardless of the timeout. + * + * The ``func`` is evaluated every ``interval`` for as long as its + * runtime duration does not exceed ``interval``. Evaluations occur + * sequentially, meaning that evaluations of ``func`` are queued if + * the runtime evaluation duration of ``func`` is greater than ``interval``. + * + * ``func`` is given two arguments, ``resolve`` and ``reject``, + * of which one must be called for the evaluation to complete. + * Calling ``resolve`` with an argument indicates that the expected + * wait condition was met and will return the passed value to the + * caller. Conversely, calling ``reject`` will evaluate ``func`` + * again until the ``timeout`` duration has elapsed or ``func`` throws. + * The passed value to ``reject`` will also be returned to the caller + * once the wait has expired. + * + * Usage:: + * + * let els = new PollPromise((resolve, reject) => { + * let res = document.querySelectorAll("p"); + * if (res.length > 0) { + * resolve(Array.from(res)); + * } else { + * reject([]); + * } + * }, {timeout: 1000}); + * + * @param {Condition} func + * Function to run off the main thread. + * @param {object=} options + * @param {string=} options.errorMessage + * Message to use to send a warning if ``timeout`` is over. + * Defaults to `PollPromise timed out`. + * @param {number=} options.timeout + * Desired timeout if wanted. If 0 or less than the runtime evaluation + * time of ``func``, ``func`` is guaranteed to run at least once. + * Defaults to using no timeout. + * @param {number=} options.interval + * Duration between each poll of ``func`` in milliseconds. + * Defaults to 10 milliseconds. + * + * @returns {Promise.<*>} + * Yields the value passed to ``func``'s + * ``resolve`` or ``reject`` callbacks. + * + * @throws {*} + * If ``func`` throws, its error is propagated. + * @throws {TypeError} + * If `timeout` or `interval`` are not numbers. + * @throws {RangeError} + * If `timeout` or `interval` are not unsigned integers. + */ +export function PollPromise(func, options = {}) { + const { + errorMessage = "PollPromise timed out", + interval = 10, + timeout = null, + } = options; + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let didTimeOut = false; + + if (typeof func != "function") { + throw new TypeError(); + } + if (timeout != null && typeof timeout != "number") { + throw new TypeError(); + } + if (typeof interval != "number") { + throw new TypeError(); + } + if ( + (timeout && (!Number.isInteger(timeout) || timeout < 0)) || + !Number.isInteger(interval) || + interval < 0 + ) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let start, end; + + if (Number.isInteger(timeout)) { + start = new Date().getTime(); + end = start + timeout; + } + + let evalFn = () => { + new Promise(func) + .then(resolve, rejected => { + if (typeof rejected != "undefined") { + throw rejected; + } + + // return if there is a timeout and set to 0, + // allowing |func| to be evaluated at least once + if ( + typeof end != "undefined" && + (start == end || new Date().getTime() >= end) + ) { + didTimeOut = true; + resolve(rejected); + } + }) + .catch(reject); + }; + + // the repeating slack timer waits |interval| + // before invoking |evalFn| + evalFn(); + + timer.init(evalFn, interval, TYPE_REPEATING_SLACK); + }).then( + res => { + if (didTimeOut) { + lazy.logger.warn(`${errorMessage} after ${timeout} ms`); + } + timer.cancel(); + return res; + }, + err => { + timer.cancel(); + throw err; + } + ); +} diff --git a/remote/shared/TabManager.sys.mjs b/remote/shared/TabManager.sys.mjs new file mode 100644 index 0000000000..c7170f8810 --- /dev/null +++ b/remote/shared/TabManager.sys.mjs @@ -0,0 +1,455 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + BrowsingContextListener: + "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs", + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + MobileTabBrowser: "chrome://remote/content/shared/MobileTabBrowser.sys.mjs", + UserContextManager: + "chrome://remote/content/shared/UserContextManager.sys.mjs", +}); + +class TabManagerClass { + #browserUniqueIds; + #contextListener; + #navigableIds; + + constructor() { + // Maps browser's permanentKey to uuid: WeakMap.<Object, string> + this.#browserUniqueIds = new WeakMap(); + + // Maps browsing contexts to uuid: WeakMap.<BrowsingContext, string>. + // It's required as a fallback, since in the case when a context was discarded + // embedderElement is gone, and we cannot retrieve + // the context id from this.#browserUniqueIds. + this.#navigableIds = new WeakMap(); + + this.#contextListener = new lazy.BrowsingContextListener(); + this.#contextListener.on("attached", this.#onContextAttached); + this.#contextListener.startListening(); + + this.browsers.forEach(browser => { + if (this.isValidCanonicalBrowsingContext(browser.browsingContext)) { + this.#navigableIds.set( + browser.browsingContext, + this.getIdForBrowsingContext(browser.browsingContext) + ); + } + }); + } + + /** + * Retrieve all the browser elements from tabs as contained in open windows. + * + * @returns {Array<XULBrowser>} + * All the found <xul:browser>s. Will return an empty array if + * no windows and tabs can be found. + */ + get browsers() { + const browsers = []; + + for (const win of this.windows) { + for (const tab of this.getTabsForWindow(win)) { + const contentBrowser = this.getBrowserForTab(tab); + if (contentBrowser !== null) { + browsers.push(contentBrowser); + } + } + } + + return browsers; + } + + get windows() { + return Services.wm.getEnumerator(null); + } + + /** + * Array of unique browser ids (UUIDs) for all content browsers of all + * windows. + * + * TODO: Similarly to getBrowserById, we should improve the performance of + * this getter in Bug 1750065. + * + * @returns {Array<string>} + * Array of UUIDs for all content browsers. + */ + get allBrowserUniqueIds() { + const browserIds = []; + + for (const win of this.windows) { + // Only return handles for browser windows + for (const tab of this.getTabsForWindow(win)) { + const contentBrowser = this.getBrowserForTab(tab); + const winId = this.getIdForBrowser(contentBrowser); + if (winId !== null) { + browserIds.push(winId); + } + } + } + + return browserIds; + } + + /** + * Get the <code><xul:browser></code> for the specified tab. + * + * @param {Tab} tab + * The tab whose browser needs to be returned. + * + * @returns {XULBrowser} + * The linked browser for the tab or null if no browser can be found. + */ + getBrowserForTab(tab) { + if (tab && "linkedBrowser" in tab) { + return tab.linkedBrowser; + } + + return null; + } + + /** + * Return the tab browser for the specified chrome window. + * + * @param {ChromeWindow} win + * Window whose <code>tabbrowser</code> needs to be accessed. + * + * @returns {Tab} + * Tab browser or null if it's not a browser window. + */ + getTabBrowser(win) { + if (lazy.AppInfo.isAndroid) { + return new lazy.MobileTabBrowser(win); + } else if (lazy.AppInfo.isFirefox) { + return win.gBrowser; + } + + return null; + } + + /** + * Create a new tab. + * + * @param {object} options + * @param {boolean=} options.focus + * Set to true if the new tab should be focused (selected). Defaults to + * false. `false` value is not properly supported on Android, additional + * focus of previously selected tab is required after initial navigation. + * @param {Tab=} options.referenceTab + * The reference tab after which the new tab will be added. If no + * reference tab is provided, the new tab will be added after all the + * other tabs. + * @param {string=} options.userContextId + * A user context id from UserContextManager. + * @param {window=} options.window + * The window where the new tab will open. Defaults to Services.wm.getMostRecentWindow + * if no window is provided. Will be ignored if referenceTab is provided. + */ + async addTab(options = {}) { + let { + focus = false, + referenceTab = null, + userContextId = null, + window = Services.wm.getMostRecentWindow(null), + } = options; + + let index; + if (referenceTab != null) { + // If a reference tab was specified, the window should be the window + // owning the reference tab. + window = this.getWindowForTab(referenceTab); + } + + if (referenceTab != null) { + index = this.getTabsForWindow(window).indexOf(referenceTab) + 1; + } + + const tabBrowser = this.getTabBrowser(window); + + const tab = await tabBrowser.addTab("about:blank", { + index, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + userContextId: lazy.UserContextManager.getInternalIdById(userContextId), + }); + + if (focus) { + await this.selectTab(tab); + } + + return tab; + } + + /** + * Retrieve the browser element corresponding to the provided unique id, + * previously generated via getIdForBrowser. + * + * TODO: To avoid creating strong references on browser elements and + * potentially leaking those elements, this method loops over all windows and + * all tabs. It should be replaced by a faster implementation in Bug 1750065. + * + * @param {string} id + * A browser unique id created by getIdForBrowser. + * @returns {XULBrowser} + * The <xul:browser> corresponding to the provided id. Will return null if + * no matching browser element is found. + */ + getBrowserById(id) { + for (const win of this.windows) { + for (const tab of this.getTabsForWindow(win)) { + const contentBrowser = this.getBrowserForTab(tab); + if (this.getIdForBrowser(contentBrowser) == id) { + return contentBrowser; + } + } + } + return null; + } + + /** + * Retrieve the browsing context corresponding to the provided unique id. + * + * @param {string} id + * A browsing context unique id (created by getIdForBrowsingContext). + * @returns {BrowsingContext=} + * The browsing context found for this id, null if none was found. + */ + getBrowsingContextById(id) { + const browser = this.getBrowserById(id); + if (browser) { + return browser.browsingContext; + } + + return BrowsingContext.get(id); + } + + /** + * Retrieve the unique id for the given xul browser element. The id is a + * dynamically generated uuid associated with the permanentKey property of the + * given browser element. This method is preferable over getIdForBrowsingContext + * in case of working with browser element of a tab, since we can not guarantee + * that browsing context is attached to it. + * + * @param {XULBrowser} browserElement + * The <xul:browser> for which we want to retrieve the id. + * @returns {string} The unique id for this browser. + */ + getIdForBrowser(browserElement) { + if (browserElement === null) { + return null; + } + + const key = browserElement.permanentKey; + if (key === undefined) { + return null; + } + + if (!this.#browserUniqueIds.has(key)) { + this.#browserUniqueIds.set(key, lazy.generateUUID()); + } + return this.#browserUniqueIds.get(key); + } + + /** + * Retrieve the id of a Browsing Context. + * + * For a top-level browsing context a custom unique id will be returned. + * + * @param {BrowsingContext=} browsingContext + * The browsing context to get the id from. + * + * @returns {string} + * The id of the browsing context. + */ + getIdForBrowsingContext(browsingContext) { + if (!browsingContext) { + return null; + } + + if (!browsingContext.parent) { + // Top-level browsing contexts have their own custom unique id. + // If a context was discarded, embedderElement is already gone, + // so use navigable id instead. + return browsingContext.embedderElement + ? this.getIdForBrowser(browsingContext.embedderElement) + : this.#navigableIds.get(browsingContext); + } + + return browsingContext.id.toString(); + } + + /** + * Get the navigable for the given browsing context. + * + * Because Gecko doesn't support the Navigable concept in content + * scope the content browser could be used to uniquely identify + * top-level browsing contexts. + * + * @param {BrowsingContext} browsingContext + * + * @returns {BrowsingContext|XULBrowser} The navigable + * + * @throws {TypeError} + * If `browsingContext` is not a CanonicalBrowsingContext instance. + */ + getNavigableForBrowsingContext(browsingContext) { + if (!this.isValidCanonicalBrowsingContext(browsingContext)) { + throw new TypeError( + `Expected browsingContext to be a CanonicalBrowsingContext, got ${browsingContext}` + ); + } + + if (browsingContext.isContent && browsingContext.parent === null) { + return browsingContext.embedderElement; + } + + return browsingContext; + } + + getTabCount() { + let count = 0; + for (const win of this.windows) { + // For browser windows count the tabs. Otherwise take the window itself. + const tabsLength = this.getTabsForWindow(win).length; + count += tabsLength ? tabsLength : 1; + } + return count; + } + + /** + * Retrieve the tab owning a Browsing Context. + * + * @param {BrowsingContext=} browsingContext + * The browsing context to get the tab from. + * + * @returns {Tab|null} + * The tab owning the Browsing Context. + */ + getTabForBrowsingContext(browsingContext) { + const browser = browsingContext?.top.embedderElement; + if (!browser) { + return null; + } + + const tabBrowser = this.getTabBrowser(browser.ownerGlobal); + return tabBrowser.getTabForBrowser(browser); + } + + /** + * Retrieve the list of tabs for a given window. + * + * @param {ChromeWindow} win + * Window whose <code>tabs</code> need to be returned. + * + * @returns {Array<Tab>} + * The list of tabs. Will return an empty list if tab browser is not available + * or tabs are undefined. + */ + getTabsForWindow(win) { + const tabBrowser = this.getTabBrowser(win); + // For web-platform reftests a faked tabbrowser is used, + // which does not actually have tabs. + if (tabBrowser && tabBrowser.tabs) { + return tabBrowser.tabs; + } + return []; + } + + getWindowForTab(tab) { + // `.linkedBrowser.ownerGlobal` works both with Firefox Desktop and Mobile. + // Other accessors (eg `.ownerGlobal` or `.browser.ownerGlobal`) fail on one + // of the platforms. + return tab.linkedBrowser.ownerGlobal; + } + + /** + * Check if the given argument is a valid canonical browsing context and was not + * discarded. + * + * @param {BrowsingContext} browsingContext + * The browsing context to check. + * + * @returns {boolean} + * True if the browsing context is valid, false otherwise. + */ + isValidCanonicalBrowsingContext(browsingContext) { + return ( + CanonicalBrowsingContext.isInstance(browsingContext) && + !browsingContext.isDiscarded + ); + } + + /** + * Remove the given tab. + * + * @param {Tab} tab + * Tab to remove. + */ + async removeTab(tab) { + if (!tab) { + return; + } + + const ownerWindow = this.getWindowForTab(tab); + const tabBrowser = this.getTabBrowser(ownerWindow); + await tabBrowser.removeTab(tab); + } + + /** + * Select the given tab. + * + * @param {Tab} tab + * Tab to select. + * + * @returns {Promise} + * Promise that resolves when the given tab has been selected. + */ + async selectTab(tab) { + if (!tab) { + return Promise.resolve(); + } + + const ownerWindow = this.getWindowForTab(tab); + const tabBrowser = this.getTabBrowser(ownerWindow); + + if (tab === tabBrowser.selectedTab) { + return Promise.resolve(); + } + + const selected = new lazy.EventPromise(ownerWindow, "TabSelect"); + tabBrowser.selectedTab = tab; + + await selected; + + // Sometimes at that point window is not focused. + if (Services.focus.activeWindow != ownerWindow) { + const activated = new lazy.EventPromise(ownerWindow, "activate"); + ownerWindow.focus(); + return activated; + } + + return Promise.resolve(); + } + + supportsTabs() { + return lazy.AppInfo.isAndroid || lazy.AppInfo.isFirefox; + } + + #onContextAttached = (eventName, data = {}) => { + const { browsingContext } = data; + if (this.isValidCanonicalBrowsingContext(browsingContext)) { + this.#navigableIds.set( + browsingContext, + this.getIdForBrowsingContext(browsingContext) + ); + } + }; +} + +// Expose a shared singleton. +export const TabManager = new TabManagerClass(); diff --git a/remote/shared/UUID.sys.mjs b/remote/shared/UUID.sys.mjs new file mode 100644 index 0000000000..b77ed7a562 --- /dev/null +++ b/remote/shared/UUID.sys.mjs @@ -0,0 +1,14 @@ +/* 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/. */ + +/** + * Creates a unique UUID without enclosing curly brackets + * Example: '86c832d2-cf1c-4001-b3e0-8628fdd41b29' + * + * @returns {string} + * The generated UUID as a string. + */ +export function generateUUID() { + return Services.uuid.generateUUID().toString().slice(1, -1); +} diff --git a/remote/shared/UserContextManager.sys.mjs b/remote/shared/UserContextManager.sys.mjs new file mode 100644 index 0000000000..679b24b2bc --- /dev/null +++ b/remote/shared/UserContextManager.sys.mjs @@ -0,0 +1,214 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + + ContextualIdentityListener: + "chrome://remote/content/shared/listeners/ContextualIdentityListener.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +const DEFAULT_CONTEXT_ID = "default"; +const DEFAULT_INTERNAL_ID = 0; + +/** + * A UserContextManager instance keeps track of all public user contexts and + * maps their internal platform. + * + * This class is exported for test purposes. Otherwise the UserContextManager + * singleton should be used. + */ +export class UserContextManagerClass { + #contextualIdentityListener; + #userContextIds; + + constructor() { + // Map from internal ids (numbers) from the ContextualIdentityService to + // opaque UUIDs (string). + this.#userContextIds = new Map(); + + // The default user context is always using 0 as internal user context id + // and should be exposed as "default" instead of a randomly generated id. + this.#userContextIds.set(DEFAULT_INTERNAL_ID, DEFAULT_CONTEXT_ID); + + // Register other (non-default) public contexts. + lazy.ContextualIdentityService.getPublicIdentities().forEach(identity => + this.#registerIdentity(identity) + ); + + this.#contextualIdentityListener = new lazy.ContextualIdentityListener(); + this.#contextualIdentityListener.on("created", this.#onIdentityCreated); + this.#contextualIdentityListener.on("deleted", this.#onIdentityDeleted); + this.#contextualIdentityListener.startListening(); + } + + destroy() { + this.#contextualIdentityListener.off("created", this.#onIdentityCreated); + this.#contextualIdentityListener.off("deleted", this.#onIdentityDeleted); + this.#contextualIdentityListener.destroy(); + + this.#userContextIds = null; + } + + /** + * Retrieve the user context id corresponding to the default user context. + * + * @returns {string} + * The default user context id. + */ + get defaultUserContextId() { + return DEFAULT_CONTEXT_ID; + } + + /** + * Creates a new user context. + * + * @param {string} prefix + * The prefix to use for the name of the user context. + * + * @returns {string} + * The user context id of the new user context. + */ + createContext(prefix = "remote") { + // Prepare the opaque id and name beforehand. + const userContextId = lazy.generateUUID(); + const name = `${prefix}-${userContextId}`; + + // Create the user context. + const identity = lazy.ContextualIdentityService.create(name); + const internalId = identity.userContextId; + + // An id has been set already by the contextual-identity-created observer. + // Override it with `userContextId` to match the container name. + this.#userContextIds.set(internalId, userContextId); + + return userContextId; + } + + /** + * Retrieve the user context id corresponding to the provided browsing context. + * + * @param {BrowsingContext} browsingContext + * The browsing context to get the user context id from. + * + * @returns {string} + * The corresponding user context id. + * + * @throws {TypeError} + * If `browsingContext` is not a CanonicalBrowsingContext instance. + */ + getIdByBrowsingContext(browsingContext) { + if (!CanonicalBrowsingContext.isInstance(browsingContext)) { + throw new TypeError( + `Expected browsingContext to be a CanonicalBrowsingContext, got ${browsingContext}` + ); + } + + return this.getIdByInternalId( + browsingContext.originAttributes.userContextId + ); + } + + /** + * Retrieve the user context id corresponding to the provided internal id. + * + * @param {number} internalId + * The internal user context id. + * + * @returns {string|null} + * The corresponding user context id or null if the user context does not + * exist. + */ + getIdByInternalId(internalId) { + if (this.#userContextIds.has(internalId)) { + return this.#userContextIds.get(internalId); + } + return null; + } + + /** + * Retrieve the internal id corresponding to the provided user + * context id. + * + * @param {string} userContextId + * The user context id. + * + * @returns {number|null} + * The internal user context id or null if the user context does not + * exist. + */ + getInternalIdById(userContextId) { + for (const [internalId, id] of this.#userContextIds) { + if (userContextId == id) { + return internalId; + } + } + return null; + } + + /** + * Returns an array of all known user context ids. + * + * @returns {Array<string>} + * The array of user context ids. + */ + getUserContextIds() { + return Array.from(this.#userContextIds.values()); + } + + /** + * Checks if the provided user context id is known by this UserContextManager. + * + * @param {string} userContextId + * The id of the user context to check. + */ + hasUserContextId(userContextId) { + return this.getUserContextIds().includes(userContextId); + } + + /** + * Removes a user context and closes all related container tabs. + * + * @param {string} userContextId + * The id of the user context to remove. + * @param {object=} options + * @param {boolean=} options.closeContextTabs + * Pass true if the tabs owned by the user context should also be closed. + * Defaults to false. + */ + removeUserContext(userContextId, options = {}) { + const { closeContextTabs = false } = options; + + if (!this.hasUserContextId(userContextId)) { + return; + } + + const internalId = this.getInternalIdById(userContextId); + if (closeContextTabs) { + lazy.ContextualIdentityService.closeContainerTabs(internalId); + } + lazy.ContextualIdentityService.remove(internalId); + } + + #onIdentityCreated = (eventName, data) => { + this.#registerIdentity(data.identity); + }; + + #onIdentityDeleted = (eventName, data) => { + this.#userContextIds.delete(data.identity.userContextId); + }; + + #registerIdentity(identity) { + // Note: the id for identities created via UserContextManagerClass.createContext + // are overridden in createContext. + this.#userContextIds.set(identity.userContextId, lazy.generateUUID()); + } +} + +// Expose a shared singleton. +export const UserContextManager = new UserContextManagerClass(); diff --git a/remote/shared/WebSocketConnection.sys.mjs b/remote/shared/WebSocketConnection.sys.mjs new file mode 100644 index 0000000000..c9ef050dc5 --- /dev/null +++ b/remote/shared/WebSocketConnection.sys.mjs @@ -0,0 +1,171 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", + WebSocketTransport: + "chrome://remote/content/server/WebSocketTransport.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "truncateLog", + "remote.log.truncate", + false +); + +const MAX_LOG_LENGTH = 2500; + +export class WebSocketConnection { + /** + * @param {WebSocket} webSocket + * The WebSocket server connection to wrap. + * @param {Connection} httpdConnection + * Reference to the httpd.js's connection needed for clean-up. + */ + constructor(webSocket, httpdConnection) { + this.id = lazy.generateUUID(); + + this.httpdConnection = httpdConnection; + + this.transport = new lazy.WebSocketTransport(webSocket); + this.transport.hooks = this; + this.transport.ready(); + + lazy.logger.debug(`${this.constructor.name} ${this.id} accepted`); + } + + #log(direction, data) { + if (lazy.Log.isDebugLevelOrMore) { + function replacer(key, value) { + if (typeof value === "string") { + return lazy.truncate`${value}`; + } + return value; + } + + let payload = JSON.stringify( + data, + replacer, + lazy.Log.verbose ? "\t" : null + ); + + if (lazy.truncateLog && payload.length > MAX_LOG_LENGTH) { + // Even if we truncate individual values, the resulting message might be + // huge if we are serializing big objects with many properties or items. + // Truncate the overall message to avoid issues in logs. + const truncated = payload.substring(0, MAX_LOG_LENGTH); + payload = `${truncated} [... truncated after ${MAX_LOG_LENGTH} characters]`; + } + + lazy.logger.debug( + `${this.constructor.name} ${this.id} ${direction} ${payload}` + ); + } + } + + /** + * Close the WebSocket connection. + */ + close() { + this.transport.close(); + } + + /** + * Register a new Session to forward the messages to. + * + * Needs to be implemented in the sub class. + * + * @param {Session} session + * The session to register. + */ + registerSession(session) { + throw new Error("Not implemented"); + } + + /** + * Send the JSON-serializable object to the client. + * + * @param {object} data + * The object to be sent. + */ + send(data) { + this.#log("<-", data); + this.transport.send(data); + } + + /** + * Send an error back to the client. + * + * Needs to be implemented in the sub class. + */ + sendError() { + throw new Error("Not implemented"); + } + + /** + * Send an event back to the client. + * + * Needs to be implemented in the sub class. + */ + sendEvent() { + throw new Error("Not implemented"); + } + + /** + * Send the result of a call to a method back to the client. + * + * Needs to be implemented in the sub class. + */ + sendResult() { + throw new Error("Not implemented"); + } + + toString() { + return `[object ${this.constructor.name} ${this.id}]`; + } + + // Transport hooks + + /** + * Called by the `transport` when the connection is closed. + */ + onConnectionClose(status) { + lazy.logger.debug(`${this.constructor.name} ${this.id} closed`); + } + + /** + * Called when the socket is closed. + */ + onSocketClose() { + // In addition to the WebSocket transport, we also have to close the + // connection used internally within httpd.js. Otherwise the server doesn't + // shut down correctly, and keeps these Connection instances alive. + this.httpdConnection.close(); + } + + /** + * Receive a packet from the WebSocket layer. + * + * This packet is sent by a WebSocket client and is meant to execute + * a particular method. + * + * Needs to be implemented in the sub class. + * + * @param {object} packet + * JSON-serializable object sent by the client. + */ + async onPacket(packet) { + this.#log("->", packet); + } +} diff --git a/remote/shared/WindowManager.sys.mjs b/remote/shared/WindowManager.sys.mjs new file mode 100644 index 0000000000..94b1ed13c1 --- /dev/null +++ b/remote/shared/WindowManager.sys.mjs @@ -0,0 +1,288 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs", + + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs", + UserContextManager: + "chrome://remote/content/shared/UserContextManager.sys.mjs", + waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +/** + * Provides helpers to interact with Window objects. + * + * @class WindowManager + */ +class WindowManager { + constructor() { + // Maps ChromeWindow to uuid: WeakMap.<Object, string> + this._chromeWindowHandles = new WeakMap(); + } + + get chromeWindowHandles() { + const chromeWindowHandles = []; + + for (const win of this.windows) { + chromeWindowHandles.push(this.getIdForWindow(win)); + } + + return chromeWindowHandles; + } + + get windows() { + return Services.wm.getEnumerator(null); + } + + /** + * Find a specific window matching the provided window handle. + * + * @param {string} handle + * The unique handle of either a chrome window or a content browser, as + * returned by :js:func:`#getIdForBrowser` or :js:func:`#getIdForWindow`. + * + * @returns {object} A window properties object, + * @see :js:func:`GeckoDriver#getWindowProperties` + */ + findWindowByHandle(handle) { + for (const win of this.windows) { + // In case the wanted window is a chrome window, we are done. + const chromeWindowId = this.getIdForWindow(win); + if (chromeWindowId == handle) { + return this.getWindowProperties(win); + } + + // Otherwise check if the chrome window has a tab browser, and that it + // contains a tab with the wanted window handle. + const tabBrowser = lazy.TabManager.getTabBrowser(win); + if (tabBrowser && tabBrowser.tabs) { + for (let i = 0; i < tabBrowser.tabs.length; ++i) { + let contentBrowser = lazy.TabManager.getBrowserForTab( + tabBrowser.tabs[i] + ); + let contentWindowId = lazy.TabManager.getIdForBrowser(contentBrowser); + + if (contentWindowId == handle) { + return this.getWindowProperties(win, { tabIndex: i }); + } + } + } + } + + return null; + } + + /** + * A set of properties describing a window and that should allow to uniquely + * identify it. The described window can either be a Chrome Window or a + * Content Window. + * + * @typedef {object} WindowProperties + * @property {Window} win - The Chrome Window containing the window. + * When describing a Chrome Window, this is the window itself. + * @property {string} id - The unique id of the containing Chrome Window. + * @property {boolean} hasTabBrowser - `true` if the Chrome Window has a + * tabBrowser. + * @property {number} tabIndex - Optional, the index of the specific tab + * within the window. + */ + + /** + * Returns a WindowProperties object, that can be used with :js:func:`GeckoDriver#setWindowHandle`. + * + * @param {Window} win + * The Chrome Window for which we want to create a properties object. + * @param {object} options + * @param {number} options.tabIndex + * Tab index of a specific Content Window in the specified Chrome Window. + * @returns {WindowProperties} A window properties object. + */ + getWindowProperties(win, options = {}) { + if (!Window.isInstance(win)) { + throw new TypeError("Invalid argument, expected a Window object"); + } + + return { + win, + id: this.getIdForWindow(win), + hasTabBrowser: !!lazy.TabManager.getTabBrowser(win), + tabIndex: options.tabIndex, + }; + } + + /** + * Retrieves an id for the given chrome window. The id is a dynamically + * generated uuid associated with the window object. + * + * @param {window} win + * The window object for which we want to retrieve the id. + * @returns {string} The unique id for this chrome window. + */ + getIdForWindow(win) { + if (!this._chromeWindowHandles.has(win)) { + this._chromeWindowHandles.set(win, lazy.generateUUID()); + } + return this._chromeWindowHandles.get(win); + } + + /** + * Close the specified window. + * + * @param {window} win + * The window to close. + * @returns {Promise} + * A promise which is resolved when the current window has been closed. + */ + async closeWindow(win) { + const destroyed = lazy.waitForObserverTopic("xul-window-destroyed", { + checkFn: () => win && win.closed, + }); + + win.close(); + + return destroyed; + } + + /** + * Focus the specified window. + * + * @param {window} win + * The window to focus. + * @returns {Promise} + * A promise which is resolved when the window has been focused. + */ + async focusWindow(win) { + if (Services.focus.activeWindow != win) { + let activated = new lazy.EventPromise(win, "activate"); + let focused = new lazy.EventPromise(win, "focus", { capture: true }); + + win.focus(); + + await Promise.all([activated, focused]); + } + } + + /** + * Open a new browser window. + * + * @param {object=} options + * @param {boolean=} options.focus + * If true, the opened window will receive the focus. Defaults to false. + * @param {boolean=} options.isPrivate + * If true, the opened window will be a private window. Defaults to false. + * @param {ChromeWindow=} options.openerWindow + * Use this window as the opener of the new window. Defaults to the + * topmost window. + * @param {string=} options.userContextId + * The id of the user context which should own the initial tab of the new + * window. + * @returns {Promise} + * A promise resolving to the newly created chrome window. + */ + async openBrowserWindow(options = {}) { + let { + focus = false, + isPrivate = false, + openerWindow = null, + userContextId = null, + } = options; + + switch (lazy.AppInfo.name) { + case "Firefox": + if (openerWindow === null) { + // If no opener was provided, fallback to the topmost window. + openerWindow = Services.wm.getMostRecentBrowserWindow(); + } + + if (!openerWindow) { + throw new lazy.error.UnsupportedOperationError( + `openWindow() could not find a valid opener window` + ); + } + + // Open new browser window, and wait until it is fully loaded. + // Also wait for the window to be focused and activated to prevent a + // race condition when promptly focusing to the original window again. + const browser = await new Promise(resolveOnContentBrowserCreated => + lazy.URILoadingHelper.openTrustedLinkIn( + openerWindow, + "about:blank", + "window", + { + private: isPrivate, + resolveOnContentBrowserCreated, + userContextId: + lazy.UserContextManager.getInternalIdById(userContextId), + } + ) + ); + + // TODO: Both for WebDriver BiDi and classic, opening a new window + // should not run the focus steps. When focus is false we should avoid + // focusing the new window completely. See Bug 1766329 + + if (focus) { + // Focus the currently selected tab. + browser.focus(); + } else { + // If the new window shouldn't get focused, set the + // focus back to the opening window. + await this.focusWindow(openerWindow); + } + + return browser.ownerGlobal; + + default: + throw new lazy.error.UnsupportedOperationError( + `openWindow() not supported in ${lazy.AppInfo.name}` + ); + } + } + + /** + * Wait until the initial application window has been opened and loaded. + * + * @returns {Promise<WindowProxy>} + * A promise that resolved to the application window. + */ + waitForInitialApplicationWindowLoaded() { + return new lazy.TimedPromise( + async resolve => { + // This call includes a fallback to "mail3:pane" as well. + const win = Services.wm.getMostRecentBrowserWindow(); + + const windowLoaded = lazy.waitForObserverTopic( + "browser-delayed-startup-finished", + { + checkFn: subject => (win !== null ? subject == win : true), + } + ); + + // The current window has already been finished loading. + if (win && win.document.readyState == "complete") { + resolve(win); + return; + } + + // Wait for the next browser/mail window to open and finished loading. + const { subject } = await windowLoaded; + resolve(subject); + }, + { + errorMessage: "No applicable application window found", + } + ); + } +} + +// Expose a shared singleton. +export const windowManager = new WindowManager(); diff --git a/remote/shared/js-window-actors/NavigationListenerActor.sys.mjs b/remote/shared/js-window-actors/NavigationListenerActor.sys.mjs new file mode 100644 index 0000000000..13335177c6 --- /dev/null +++ b/remote/shared/js-window-actors/NavigationListenerActor.sys.mjs @@ -0,0 +1,80 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +let registered = false; +export function isNavigationListenerActorRegistered() { + return registered; +} + +/** + * Register the NavigationListener actor that will keep track of all ongoing + * navigations. + */ +export function registerNavigationListenerActor() { + if (registered) { + return; + } + + try { + ChromeUtils.registerWindowActor("NavigationListener", { + kind: "JSWindowActor", + parent: { + esModuleURI: + "chrome://remote/content/shared/js-window-actors/NavigationListenerParent.sys.mjs", + }, + child: { + esModuleURI: + "chrome://remote/content/shared/js-window-actors/NavigationListenerChild.sys.mjs", + events: { + DOMWindowCreated: {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }); + registered = true; + + // Ensure the navigation listener is started in existing contexts. + for (const browser of lazy.TabManager.browsers) { + if (!browser?.browsingContext) { + continue; + } + + for (const context of browser.browsingContext.getAllBrowsingContextsInSubtree()) { + if (!context.currentWindowGlobal) { + continue; + } + + context.currentWindowGlobal + .getActor("NavigationListener") + // Note that "createActor" is not explicitly referenced in the child + // actor, this is only used to trigger the creation of the actor. + .sendAsyncMessage("createActor"); + } + } + } catch (e) { + if (e.name === "NotSupportedError") { + lazy.logger.warn(`NavigationListener actor is already registered!`); + } else { + throw e; + } + } +} + +export function unregisterNavigationListenerActor() { + if (!registered) { + return; + } + ChromeUtils.unregisterWindowActor("NavigationListener"); + registered = false; +} diff --git a/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs b/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs new file mode 100644 index 0000000000..a2cd8ccf10 --- /dev/null +++ b/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs @@ -0,0 +1,167 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +export class NavigationListenerChild extends JSWindowActorChild { + #listener; + #webProgress; + + constructor() { + super(); + + this.#listener = { + onLocationChange: this.#onLocationChange, + onStateChange: this.#onStateChange, + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + this.#webProgress = null; + } + + actorCreated() { + this.#webProgress = this.manager.browsingContext.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + this.#webProgress.addProgressListener( + this.#listener, + Ci.nsIWebProgress.NOTIFY_LOCATION | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + } + + didDestroy() { + try { + this.#webProgress.removeProgressListener(this.#listener); + } catch (e) { + // Ignore potential errors if the window global was already destroyed. + } + } + + // Note: we rely on events and messages to trigger the actor creation, but + // all the logic is in the actorCreated callback. The handleEvent and + // receiveMessage methods are only there as placeholders to avoid errors. + + /** + * See note above + */ + handleEvent(event) {} + + /** + * See note above + */ + receiveMessage(message) {} + + /** + * A browsing context might be replaced before reaching the parent process, + * instead we serialize enough information to retrieve the navigable in the + * parent process. + * + * If the browsing context is top level, then the browserId can be used to + * find the browser element and the new browsing context. + * Otherwise (frames) the browsing context should not be replaced and the + * browsing context id should be enough to find the browsing context. + * + * @param {BrowsingContext} browsingContext + * The browsing context for which we want to get details. + * @returns {object} + * An object that returns the following properties: + * - browserId: browser id for this browsing context + * - browsingContextId: browsing context id + * - isTopBrowsingContext: flag that indicates if the browsing context is + * top level + * + */ + #getBrowsingContextDetails(browsingContext) { + return { + browserId: browsingContext.browserId, + browsingContextId: browsingContext.id, + isTopBrowsingContext: browsingContext.parent === null, + }; + } + + #getTargetURI(request) { + try { + return request.QueryInterface(Ci.nsIChannel).originalURI; + } catch (e) {} + + return null; + } + + #onLocationChange = (progress, request, location, stateFlags) => { + if (stateFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { + const context = progress.browsingContext; + + lazy.logger.trace( + `[${context.id}] NavigationListener onLocationChange,` + + lazy.truncate` location: ${location.spec}` + ); + + this.sendAsyncMessage("NavigationListenerChild:locationChanged", { + contextDetails: this.#getBrowsingContextDetails(context), + url: location.spec, + }); + } + }; + + #onStateChange = (progress, request, stateFlags, status) => { + const context = progress.browsingContext; + const targetURI = this.#getTargetURI(request); + + const isBindingAborted = status == Cr.NS_BINDING_ABORTED; + const isStart = !!(stateFlags & Ci.nsIWebProgressListener.STATE_START); + const isStop = !!(stateFlags & Ci.nsIWebProgressListener.STATE_STOP); + + if (lazy.Log.isTraceLevelOrMore) { + const isNetwork = !!( + stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ); + lazy.logger.trace( + `[${context.id}] NavigationListener onStateChange,` + + ` stateFlags: ${stateFlags}, status: ${status}, isStart: ${isStart},` + + ` isStop: ${isStop}, isNetwork: ${isNetwork},` + + ` isBindingAborted: ${isBindingAborted},` + + lazy.truncate` targetURI: ${targetURI?.spec}` + ); + } + + try { + if (isStart) { + this.sendAsyncMessage("NavigationListenerChild:navigationStarted", { + contextDetails: this.#getBrowsingContextDetails(context), + url: targetURI?.spec, + }); + + return; + } + + if (isStop && !isBindingAborted) { + // Skip NS_BINDING_ABORTED state changes as this can happen during a + // browsing context + process change and we should get the real stop state + // change from the correct process later. + this.sendAsyncMessage("NavigationListenerChild:navigationStopped", { + contextDetails: this.#getBrowsingContextDetails(context), + url: targetURI?.spec, + }); + } + } catch (e) { + if (e.name === "InvalidStateError") { + // We'll arrive here if we no longer have our manager, so we can + // just swallow this error. + return; + } + throw e; + } + }; +} diff --git a/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs b/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs new file mode 100644 index 0000000000..334f9953d6 --- /dev/null +++ b/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs @@ -0,0 +1,58 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + notifyLocationChanged: + "chrome://remote/content/shared/NavigationManager.sys.mjs", + notifyNavigationStarted: + "chrome://remote/content/shared/NavigationManager.sys.mjs", + notifyNavigationStopped: + "chrome://remote/content/shared/NavigationManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +export class NavigationListenerParent extends JSWindowActorParent { + async receiveMessage(message) { + try { + switch (message.name) { + case "NavigationListenerChild:locationChanged": { + lazy.notifyLocationChanged({ + contextDetails: message.data.contextDetails, + url: message.data.url, + }); + break; + } + case "NavigationListenerChild:navigationStarted": { + lazy.notifyNavigationStarted({ + contextDetails: message.data.contextDetails, + url: message.data.url, + }); + break; + } + case "NavigationListenerChild:navigationStopped": { + lazy.notifyNavigationStopped({ + contextDetails: message.data.contextDetails, + url: message.data.url, + }); + break; + } + default: + throw new Error("Unsupported message:" + message.name); + } + } catch (e) { + if (e instanceof TypeError) { + // Avoid error spam from errors due to unavailable browsing contexts. + lazy.logger.trace( + `Failed to handle a navigation listener message: ${e.message}` + ); + } else { + throw e; + } + } + } +} diff --git a/remote/shared/listeners/BrowsingContextListener.sys.mjs b/remote/shared/listeners/BrowsingContextListener.sys.mjs new file mode 100644 index 0000000000..d4e3539ca9 --- /dev/null +++ b/remote/shared/listeners/BrowsingContextListener.sys.mjs @@ -0,0 +1,122 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +const OBSERVER_TOPIC_ATTACHED = "browsing-context-attached"; +const OBSERVER_TOPIC_DISCARDED = "browsing-context-discarded"; + +const OBSERVER_TOPIC_SET_EMBEDDER = "browsing-context-did-set-embedder"; + +/** + * The BrowsingContextListener can be used to listen for notifications coming + * from browsing contexts that get attached or discarded. + * + * Example: + * ``` + * const listener = new BrowsingContextListener(); + * listener.on("attached", onAttached); + * listener.startListening(); + * + * const onAttached = (eventName, data = {}) => { + * const { browsingContext, why } = data; + * ... + * }; + * ``` + * + * @fires message + * The BrowsingContextListener emits "attached" and "discarded" events, + * with the following object as payload: + * - {BrowsingContext} browsingContext + * Browsing context the notification relates to. + * - {string} why + * Usually "attach" or "discard", but will contain "replace" if the + * browsing context gets replaced by a cross-group navigation. + */ +export class BrowsingContextListener { + #listening; + #topContextsToAttach; + + /** + * Create a new BrowsingContextListener instance. + */ + constructor() { + lazy.EventEmitter.decorate(this); + + // A map that temporarily holds attached top-level browsing contexts until + // their embedder element is set, which is required to successfully + // retrieve a unique id for the content browser by the TabManager. + this.#topContextsToAttach = new Map(); + + this.#listening = false; + } + + destroy() { + this.stopListening(); + this.#topContextsToAttach = null; + } + + observe(subject, topic, data) { + switch (topic) { + case OBSERVER_TOPIC_ATTACHED: + // Delay emitting the event for top-level browsing contexts until + // the embedder element has been set. + if (!subject.parent) { + this.#topContextsToAttach.set(subject, data); + return; + } + + this.emit("attached", { browsingContext: subject, why: data }); + break; + + case OBSERVER_TOPIC_DISCARDED: + // Remove a recently attached top-level browsing context if it's + // immediately discarded. + if (this.#topContextsToAttach.has(subject)) { + this.#topContextsToAttach.delete(subject); + } + + this.emit("discarded", { browsingContext: subject, why: data }); + break; + + case OBSERVER_TOPIC_SET_EMBEDDER: + const why = this.#topContextsToAttach.get(subject); + if (why !== undefined) { + this.emit("attached", { browsingContext: subject, why }); + this.#topContextsToAttach.delete(subject); + } + break; + } + } + + startListening() { + if (this.#listening) { + return; + } + + Services.obs.addObserver(this, OBSERVER_TOPIC_ATTACHED); + Services.obs.addObserver(this, OBSERVER_TOPIC_DISCARDED); + Services.obs.addObserver(this, OBSERVER_TOPIC_SET_EMBEDDER); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + Services.obs.removeObserver(this, OBSERVER_TOPIC_ATTACHED); + Services.obs.removeObserver(this, OBSERVER_TOPIC_DISCARDED); + Services.obs.removeObserver(this, OBSERVER_TOPIC_SET_EMBEDDER); + + this.#topContextsToAttach.clear(); + + this.#listening = false; + } +} diff --git a/remote/shared/listeners/ConsoleAPIListener.sys.mjs b/remote/shared/listeners/ConsoleAPIListener.sys.mjs new file mode 100644 index 0000000000..7f5c850945 --- /dev/null +++ b/remote/shared/listeners/ConsoleAPIListener.sys.mjs @@ -0,0 +1,124 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => { + return Cc["@mozilla.org/consoleAPI-storage;1"].getService( + Ci.nsIConsoleAPIStorage + ); +}); + +/** + * The ConsoleAPIListener can be used to listen for messages coming from console + * API usage in a given windowGlobal, eg. console.log, console.error, ... + * + * Example: + * ``` + * const listener = new ConsoleAPIListener(innerWindowId); + * listener.on("message", onConsoleAPIMessage); + * listener.startListening(); + * + * const onConsoleAPIMessage = (eventName, data = {}) => { + * const { arguments: msgArguments, level, stacktrace, timeStamp } = data; + * ... + * }; + * ``` + * + * @fires message + * The ConsoleAPIListener emits "message" events, with the following object as + * payload: + * - {Array<Object>} arguments - Arguments as passed-in when the method was called. + * - {String} level - Importance, one of `info`, `warn`, `error`, `debug`, `trace`. + * - {Array<Object>} stacktrace - List of stack frames, starting from most recent. + * - {Number} timeStamp - Timestamp when the method was called. + */ +export class ConsoleAPIListener { + #emittedMessages; + #innerWindowId; + #listening; + + /** + * Create a new ConsoleAPIListener instance. + * + * @param {number} innerWindowId + * The inner window id to filter the messages for. + */ + constructor(innerWindowId) { + lazy.EventEmitter.decorate(this); + + this.#emittedMessages = new Set(); + this.#innerWindowId = innerWindowId; + this.#listening = false; + } + + destroy() { + this.stopListening(); + this.#emittedMessages = null; + } + + startListening() { + if (this.#listening) { + return; + } + + lazy.ConsoleAPIStorage.addLogEventListener( + this.#onConsoleAPIMessage, + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + + // Emit cached messages after registering the listener, to make sure we + // don't miss any message. + this.#emitCachedMessages(); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + lazy.ConsoleAPIStorage.removeLogEventListener(this.#onConsoleAPIMessage); + this.#listening = false; + } + + #emitCachedMessages() { + const cachedMessages = lazy.ConsoleAPIStorage.getEvents( + this.#innerWindowId + ); + for (const message of cachedMessages) { + this.#onConsoleAPIMessage(message); + } + } + + #onConsoleAPIMessage = message => { + const messageObject = message.wrappedJSObject; + + // Bail if this message was already emitted, useful to filter out cached + // messages already received by the consumer. + if (this.#emittedMessages.has(messageObject)) { + return; + } + + this.#emittedMessages.add(messageObject); + + if (messageObject.innerID !== this.#innerWindowId) { + // If the message doesn't match the innerWindowId of the current context + // ignore it. + return; + } + + this.emit("message", { + arguments: messageObject.arguments, + level: messageObject.level, + stacktrace: messageObject.stacktrace, + timeStamp: messageObject.timeStamp, + }); + }; +} diff --git a/remote/shared/listeners/ConsoleListener.sys.mjs b/remote/shared/listeners/ConsoleListener.sys.mjs new file mode 100644 index 0000000000..0344cf2be2 --- /dev/null +++ b/remote/shared/listeners/ConsoleListener.sys.mjs @@ -0,0 +1,154 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + getFramesFromStack: "chrome://remote/content/shared/Stack.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * The ConsoleListener can be used to listen for console messages related to + * Javascript errors, certain warnings which all happen within a specific + * windowGlobal. Consumers can listen for the message types "error", + * "warn" and "info". + * + * Example: + * ``` + * const onJavascriptError = (eventName, data = {}) => { + * const { level, message, stacktrace, timestamp } = data; + * ... + * }; + * + * const listener = new ConsoleListener(innerWindowId); + * listener.on("error", onJavascriptError); + * listener.startListening(); + * ... + * listener.stopListening(); + * ``` + * + * @fires message + * The ConsoleListener emits "error", "warn" and "info" events, with the + * following object as payload: + * - {String} level - Importance, one of `info`, `warn`, `error`, + * `debug`, `trace`. + * - {String} message - Actual message from the console entry. + * - {Array<StackFrame>} stacktrace - List of stack frames, + * starting from most recent. + * - {Number} timeStamp - Timestamp when the method was called. + */ +export class ConsoleListener { + #emittedMessages; + #innerWindowId; + #listening; + + /** + * Create a new ConsoleListener instance. + * + * @param {number} innerWindowId + * The inner window id to filter the messages for. + */ + constructor(innerWindowId) { + lazy.EventEmitter.decorate(this); + + this.#emittedMessages = new Set(); + this.#innerWindowId = innerWindowId; + this.#listening = false; + } + + get listening() { + return this.#listening; + } + + destroy() { + this.stopListening(); + this.#emittedMessages = null; + } + + startListening() { + if (this.#listening) { + return; + } + + Services.console.registerListener(this.#onConsoleMessage); + + // Emit cached messages after registering the listener, to make sure we + // don't miss any message. + this.#emitCachedMessages(); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + Services.console.unregisterListener(this.#onConsoleMessage); + this.#listening = false; + } + + #emitCachedMessages() { + const cachedMessages = Services.console.getMessageArray() || []; + + for (const message of cachedMessages) { + this.#onConsoleMessage(message); + } + } + + #onConsoleMessage = message => { + if (!(message instanceof Ci.nsIScriptError)) { + // For now ignore basic nsIConsoleMessage instances, which are only + // relevant to Chrome code and do not have a valid window reference. + return; + } + + // Bail if this message was already emitted, useful to filter out cached + // messages already received by the consumer. + if (this.#emittedMessages.has(message)) { + return; + } + + this.#emittedMessages.add(message); + + if (message.innerWindowID !== this.#innerWindowId) { + // If the message doesn't match the innerWindowId of the current context + // ignore it. + return; + } + + const { errorFlag, warningFlag, infoFlag } = Ci.nsIScriptError; + let level; + + if ((message.flags & warningFlag) == warningFlag) { + level = "warn"; + } else if ((message.flags & infoFlag) == infoFlag) { + level = "info"; + } else if ((message.flags & errorFlag) == errorFlag) { + level = "error"; + } else { + lazy.logger.warn( + `Not able to process console message with unknown flags ${message.flags}` + ); + return; + } + + // Send event when actively listening. + this.emit(level, { + level, + message: message.errorMessage, + stacktrace: lazy.getFramesFromStack(message.stack), + timeStamp: message.timeStamp, + }); + }; + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } +} diff --git a/remote/shared/listeners/ContextualIdentityListener.sys.mjs b/remote/shared/listeners/ContextualIdentityListener.sys.mjs new file mode 100644 index 0000000000..d93b44ed77 --- /dev/null +++ b/remote/shared/listeners/ContextualIdentityListener.sys.mjs @@ -0,0 +1,85 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +const OBSERVER_TOPIC_CREATED = "contextual-identity-created"; +const OBSERVER_TOPIC_DELETED = "contextual-identity-deleted"; + +/** + * The ContextualIdentityListener can be used to listen for notifications about + * contextual identities (containers) being created or deleted. + * + * Example: + * ``` + * const listener = new ContextualIdentityListener(); + * listener.on("created", onCreated); + * listener.startListening(); + * + * const onCreated = (eventName, data = {}) => { + * const { identity } = data; + * ... + * }; + * ``` + * + * @fires message + * The ContextualIdentityListener emits "created" and "deleted" events, + * with the following object as payload: + * - {object} identity + * The contextual identity which was created or deleted. + */ +export class ContextualIdentityListener { + #listening; + + /** + * Create a new BrowsingContextListener instance. + */ + constructor() { + lazy.EventEmitter.decorate(this); + + this.#listening = false; + } + + destroy() { + this.stopListening(); + } + + observe(subject, topic, data) { + switch (topic) { + case OBSERVER_TOPIC_CREATED: + this.emit("created", { identity: subject.wrappedJSObject }); + break; + + case OBSERVER_TOPIC_DELETED: + this.emit("deleted", { identity: subject.wrappedJSObject }); + break; + } + } + + startListening() { + if (this.#listening) { + return; + } + + Services.obs.addObserver(this, OBSERVER_TOPIC_CREATED); + Services.obs.addObserver(this, OBSERVER_TOPIC_DELETED); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + Services.obs.removeObserver(this, OBSERVER_TOPIC_CREATED); + Services.obs.removeObserver(this, OBSERVER_TOPIC_DELETED); + + this.#listening = false; + } +} diff --git a/remote/shared/listeners/LoadListener.sys.mjs b/remote/shared/listeners/LoadListener.sys.mjs new file mode 100644 index 0000000000..cccfca7a90 --- /dev/null +++ b/remote/shared/listeners/LoadListener.sys.mjs @@ -0,0 +1,103 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +/** + * The LoadListener can be used to listen for load events. + * + * Example: + * ``` + * const listener = new LoadListener(); + * listener.on("DOMContentLoaded", onDOMContentLoaded); + * listener.startListening(); + * + * const onDOMContentLoaded = (eventName, data = {}) => { + * const { target } = data; + * ... + * }; + * ``` + * + * @fires message + * The LoadListener emits "DOMContentLoaded" and "load" events, + * with the following object as payload: + * - {Document} target + * The target document. + */ +export class LoadListener { + #abortController; + #window; + + /** + * Create a new LoadListener instance. + */ + constructor(win) { + lazy.EventEmitter.decorate(this); + + // Use an abort controller instead of removeEventListener because destroy + // might be called close to the window global destruction. + this.#abortController = null; + + this.#window = win; + } + + destroy() { + this.stopListening(); + } + + startListening() { + if (this.#abortController) { + return; + } + + this.#abortController = new AbortController(); + + // Events are attached to the windowRoot instead of the regular window to + // avoid issues with document.open (Bug 1822772). + this.#window.windowRoot.addEventListener( + "DOMContentLoaded", + this.#onDOMContentLoaded, + { + capture: true, + mozSystemGroup: true, + signal: this.#abortController.signal, + } + ); + + this.#window.windowRoot.addEventListener("load", this.#onLoad, { + capture: true, + mozSystemGroup: true, + signal: this.#abortController.signal, + }); + } + + stopListening() { + if (!this.#abortController) { + return; + } + + this.#abortController.abort(); + this.#abortController = null; + } + + #onDOMContentLoaded = event => { + // Check that this event was emitted for the relevant window, because events + // from inner frames can bubble to the windowRoot. + if (event.target.defaultView === this.#window) { + this.emit("DOMContentLoaded", { target: event.target }); + } + }; + + #onLoad = event => { + // Check that this event was emitted for the relevant window, because events + // from inner frames can bubble to the windowRoot. + if (event.target.defaultView === this.#window) { + this.emit("load", { target: event.target }); + } + }; +} diff --git a/remote/shared/listeners/NavigationListener.sys.mjs b/remote/shared/listeners/NavigationListener.sys.mjs new file mode 100644 index 0000000000..c911bb53f6 --- /dev/null +++ b/remote/shared/listeners/NavigationListener.sys.mjs @@ -0,0 +1,90 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +/** + * The NavigationListener simply wraps a NavigationManager instance and exposes + * it with a convenient listener API, more consistent with the rest of the + * remote codebase. NavigationManager is a singleton per session so it can't + * be instanciated for each and every consumer. + * + * Example: + * ``` + * const onNavigationStarted = (eventName, data = {}) => { + * const { level, message, stacktrace, timestamp } = data; + * ... + * }; + * + * const listener = new NavigationListener(this.messageHandler.navigationManager); + * listener.on("navigation-started", onNavigationStarted); + * listener.startListening(); + * ... + * listener.stopListening(); + * ``` + * + * @fires message + * The NavigationListener emits "navigation-started", "location-changed" and + * "navigation-stopped" events, with the following object as payload: + * - {string} navigationId - The UUID for the navigation. + * - {string} navigableId - The UUID for the navigable. + * - {string} url - The target url for the navigation. + */ +export class NavigationListener { + #listening; + #navigationManager; + + /** + * Create a new NavigationListener instance. + * + * @param {NavigationManager} navigationManager + * The underlying NavigationManager for this listener. + */ + constructor(navigationManager) { + lazy.EventEmitter.decorate(this); + + this.#listening = false; + this.#navigationManager = navigationManager; + } + + get listening() { + return this.#listening; + } + + destroy() { + this.stopListening(); + } + + startListening() { + if (this.#listening) { + return; + } + + this.#navigationManager.on("navigation-started", this.#forwardEvent); + this.#navigationManager.on("navigation-stopped", this.#forwardEvent); + this.#navigationManager.on("location-changed", this.#forwardEvent); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + this.#navigationManager.off("navigation-started", this.#forwardEvent); + this.#navigationManager.off("navigation-stopped", this.#forwardEvent); + this.#navigationManager.off("location-changed", this.#forwardEvent); + + this.#listening = false; + } + + #forwardEvent = (name, data) => { + this.emit(name, data); + }; +} diff --git a/remote/shared/listeners/NetworkEventRecord.sys.mjs b/remote/shared/listeners/NetworkEventRecord.sys.mjs new file mode 100644 index 0000000000..a41f3edd7d --- /dev/null +++ b/remote/shared/listeners/NetworkEventRecord.sys.mjs @@ -0,0 +1,455 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + NetworkUtils: + "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", + + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +/** + * The NetworkEventRecord implements the interface expected from network event + * owners for consumers of the DevTools NetworkObserver. + * + * The NetworkEventRecord emits the before-request-sent event on behalf of the + * NetworkListener instance which created it. + */ +export class NetworkEventRecord { + #contextId; + #fromCache; + #isMainDocumentChannel; + #networkListener; + #redirectCount; + #requestChannel; + #requestData; + #requestId; + #responseChannel; + #responseData; + #wrappedChannel; + + /** + * + * @param {object} networkEvent + * The initial network event information (see createNetworkEvent() in + * NetworkUtils.sys.mjs). + * @param {nsIChannel} channel + * The nsIChannel behind this network event. + * @param {NetworkListener} networkListener + * The NetworkListener which created this NetworkEventRecord. + */ + constructor(networkEvent, channel, networkListener) { + this.#requestChannel = channel; + this.#responseChannel = null; + + this.#fromCache = networkEvent.fromCache; + this.#isMainDocumentChannel = channel.isMainDocumentChannel; + + this.#wrappedChannel = ChannelWrapper.get(channel); + + this.#networkListener = networkListener; + + // The context ids computed by TabManager have the lifecycle of a navigable + // and can be reused for all the events emitted from this record. + this.#contextId = this.#getContextId(); + + // The wrappedChannel id remains identical across redirects, whereas + // nsIChannel.channelId is different for each and every request. + this.#requestId = this.#wrappedChannel.id.toString(); + + const { cookies, headers } = + lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel); + + // See the RequestData type definition for the full list of properties that + // should be set on this object. + this.#requestData = { + bodySize: null, + cookies, + headers, + headersSize: networkEvent.rawHeaders ? networkEvent.rawHeaders.length : 0, + method: channel.requestMethod, + request: this.#requestId, + timings: {}, + url: channel.URI.spec, + }; + + // See the ResponseData type definition for the full list of properties that + // should be set on this object. + this.#responseData = { + // encoded size (body) + bodySize: null, + content: { + // decoded size + size: null, + }, + // encoded size (headers) + headersSize: null, + url: channel.URI.spec, + }; + + // NetworkObserver creates a network event when request headers have been + // parsed. + // According to the BiDi spec, we should emit beforeRequestSent when adding + // request headers, see https://whatpr.org/fetch/1540.html#http-network-or-cache-fetch + // step 8.17 + // Bug 1802181: switch the NetworkObserver to an event-based API. + this.#emitBeforeRequestSent(); + + // If the request is already blocked, we will not receive further updates, + // emit a network.fetchError event immediately. + if (networkEvent.blockedReason) { + this.#emitFetchError(); + } + } + + /** + * Add network request POST data. + * + * Required API for a NetworkObserver event owner. + * + * @param {object} postData + * The request POST data. + */ + addRequestPostData(postData) { + // Only the postData size is needed for RemoteAgent consumers. + this.#requestData.bodySize = postData.size; + } + + /** + * Add the initial network response information. + * + * Required API for a NetworkObserver event owner. + * + * + * @param {object} options + * @param {nsIChannel} options.channel + * The channel. + * @param {boolean} options.fromCache + * @param {string} options.rawHeaders + */ + addResponseStart(options) { + const { channel, fromCache, rawHeaders = "" } = options; + this.#responseChannel = channel; + + const { headers } = + lazy.NetworkUtils.fetchResponseHeadersAndCookies(channel); + + const headersSize = rawHeaders.length; + this.#responseData = { + ...this.#responseData, + bodySize: 0, + bytesReceived: headersSize, + fromCache: this.#fromCache || !!fromCache, + headers, + headersSize, + mimeType: this.#getMimeType(), + protocol: lazy.NetworkUtils.getProtocol(channel), + status: channel.responseStatus, + statusText: channel.responseStatusText, + }; + + // This should be triggered when all headers have been received, matching + // the WebDriverBiDi response started trigger in `4.6. HTTP-network fetch` + // from the fetch specification, based on the PR visible at + // https://github.com/whatwg/fetch/pull/1540 + this.#emitResponseStarted(); + } + + /** + * Add connection security information. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {object} info + * The object containing security information. + * @param {boolean} isRacing + * True if the corresponding channel raced the cache and network requests. + */ + addSecurityInfo(info, isRacing) {} + + /** + * Add network event timings. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {number} total + * The total time for the request. + * @param {object} timings + * The har-like timings. + * @param {object} offsets + * The har-like timings, but as offset from the request start. + */ + addEventTimings(total, timings, offsets) {} + + /** + * Add response cache entry. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {object} options + * An object which contains a single responseCache property. + */ + addResponseCache(options) {} + + /** + * Add response content. + * + * Required API for a NetworkObserver event owner. + * + * @param {object} response + * An object which represents the response content. + * @param {object} responseInfo + * Additional meta data about the response. + */ + addResponseContent(response, responseInfo) { + // Update content-related sizes with the latest data from addResponseContent. + this.#responseData = { + ...this.#responseData, + bodySize: response.bodySize, + bytesReceived: response.transferredSize, + content: { + size: response.decodedBodySize, + }, + }; + + if (responseInfo.blockedReason) { + this.#emitFetchError(); + } else { + this.#emitResponseCompleted(); + } + } + + /** + * Add server timings. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {Array} serverTimings + * The server timings. + */ + addServerTimings(serverTimings) {} + + /** + * Add service worker timings. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {object} serviceWorkerTimings + * The server timings. + */ + addServiceWorkerTimings(serviceWorkerTimings) {} + + onAuthPrompt(authDetails, authCallbacks) { + this.#emitAuthRequired(authCallbacks); + } + + /** + * Convert the provided request timing to a timing relative to the beginning + * of the request. All timings are numbers representing high definition + * timestamps. + * + * @param {number} timing + * High definition timestamp for a request timing relative from the time + * origin. + * @param {number} requestTime + * High definition timestamp for the request start time relative from the + * time origin. + * @returns {number} + * High definition timestamp for the request timing relative to the start + * time of the request, or 0 if the provided timing was 0. + */ + #convertTimestamp(timing, requestTime) { + if (timing == 0) { + return 0; + } + + return timing - requestTime; + } + + #emitAuthRequired(authCallbacks) { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("auth-required", { + authCallbacks, + contextId: this.#contextId, + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + responseChannel: this.#responseChannel, + responseData: this.#responseData, + timestamp: Date.now(), + }); + } + + #emitBeforeRequestSent() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("before-request-sent", { + contextId: this.#contextId, + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + timestamp: Date.now(), + }); + } + + #emitFetchError() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("fetch-error", { + contextId: this.#contextId, + // TODO: Update with a proper error text. Bug 1873037. + errorText: ChromeUtils.getXPCOMErrorName(this.#requestChannel.status), + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + timestamp: Date.now(), + }); + } + + #emitResponseCompleted() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("response-completed", { + contextId: this.#contextId, + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + responseChannel: this.#responseChannel, + responseData: this.#responseData, + timestamp: Date.now(), + }); + } + + #emitResponseStarted() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("response-started", { + contextId: this.#contextId, + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + responseChannel: this.#responseChannel, + responseData: this.#responseData, + timestamp: Date.now(), + }); + } + + #getBrowsingContext() { + const id = lazy.NetworkUtils.getChannelBrowsingContextID( + this.#requestChannel + ); + return BrowsingContext.get(id); + } + + /** + * Retrieve the navigable id for the current browsing context associated to + * the requests' channel. Network events are recorded in the parent process + * so we always expect to be able to use TabManager.getIdForBrowsingContext. + * + * @returns {string} + * The navigable id corresponding to the given browsing context. + */ + #getContextId() { + return lazy.TabManager.getIdForBrowsingContext(this.#getBrowsingContext()); + } + + #getMimeType() { + // TODO: DevTools NetworkObserver is computing a similar value in + // addResponseContent, but uses an inconsistent implementation in + // addResponseStart. This approach can only be used as early as in + // addResponseHeaders. We should move this logic to the NetworkObserver and + // expose mimeType in addResponseStart. Bug 1809670. + let mimeType = ""; + + try { + mimeType = this.#wrappedChannel.contentType; + const contentCharset = this.#requestChannel.contentCharset; + if (contentCharset) { + mimeType += `;charset=${contentCharset}`; + } + } catch (e) { + // Ignore exceptions when reading contentType/contentCharset + } + + return mimeType; + } + + #getTimingsFromTimedChannel(timedChannel) { + const { + channelCreationTime, + redirectStartTime, + redirectEndTime, + dispatchFetchEventStartTime, + cacheReadStartTime, + domainLookupStartTime, + domainLookupEndTime, + connectStartTime, + connectEndTime, + secureConnectionStartTime, + requestStartTime, + responseStartTime, + responseEndTime, + } = timedChannel; + + // fetchStart should be the post-redirect start time, which should be the + // first non-zero timing from: dispatchFetchEventStart, cacheReadStart and + // domainLookupStart. See https://www.w3.org/TR/navigation-timing-2/#processing-model + const fetchStartTime = + dispatchFetchEventStartTime || + cacheReadStartTime || + domainLookupStartTime; + + // Bug 1805478: Per spec, the origin time should match Performance API's + // timeOrigin for the global which initiated the request. This is not + // available in the parent process, so for now we will use 0. + const timeOrigin = 0; + + return { + timeOrigin, + requestTime: this.#convertTimestamp(channelCreationTime, timeOrigin), + redirectStart: this.#convertTimestamp(redirectStartTime, timeOrigin), + redirectEnd: this.#convertTimestamp(redirectEndTime, timeOrigin), + fetchStart: this.#convertTimestamp(fetchStartTime, timeOrigin), + dnsStart: this.#convertTimestamp(domainLookupStartTime, timeOrigin), + dnsEnd: this.#convertTimestamp(domainLookupEndTime, timeOrigin), + connectStart: this.#convertTimestamp(connectStartTime, timeOrigin), + connectEnd: this.#convertTimestamp(connectEndTime, timeOrigin), + tlsStart: this.#convertTimestamp(secureConnectionStartTime, timeOrigin), + tlsEnd: this.#convertTimestamp(connectEndTime, timeOrigin), + requestStart: this.#convertTimestamp(requestStartTime, timeOrigin), + responseStart: this.#convertTimestamp(responseStartTime, timeOrigin), + responseEnd: this.#convertTimestamp(responseEndTime, timeOrigin), + }; + } + + /** + * Update the timings and the redirect count from the nsITimedChannel + * corresponding to the current channel. This should be called before emitting + * any event from this class. + */ + #updateDataFromTimedChannel() { + const timedChannel = this.#requestChannel.QueryInterface( + Ci.nsITimedChannel + ); + this.#redirectCount = timedChannel.redirectCount; + this.#requestData.timings = this.#getTimingsFromTimedChannel(timedChannel); + } +} diff --git a/remote/shared/listeners/NetworkListener.sys.mjs b/remote/shared/listeners/NetworkListener.sys.mjs new file mode 100644 index 0000000000..500d2005dc --- /dev/null +++ b/remote/shared/listeners/NetworkListener.sys.mjs @@ -0,0 +1,109 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + NetworkObserver: + "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs", + + NetworkEventRecord: + "chrome://remote/content/shared/listeners/NetworkEventRecord.sys.mjs", +}); + +/** + * The NetworkListener listens to all network activity from the parent + * process. + * + * Example: + * ``` + * const listener = new NetworkListener(); + * listener.on("before-request-sent", onBeforeRequestSent); + * listener.startListening(); + * + * const onBeforeRequestSent = (eventName, data = {}) => { + * const { cntextId, redirectCount, requestData, requestId, timestamp } = data; + * ... + * }; + * ``` + * + * @fires before-request-sent + * The NetworkListener emits "before-request-sent" events, with the + * following object as payload: + * - {number} browsingContextId - The browsing context id of the browsing + * context where this request was performed. + * - {number} redirectCount - The request's redirect count. + * - {RequestData} requestData - The request's data as expected by + * WebDriver BiDi. + * - {string} requestId - The id of the request, consistent across + * redirects. + * - {number} timestamp - Timestamp when the event was generated. + */ +export class NetworkListener { + #devtoolsNetworkObserver; + #listening; + + constructor() { + lazy.EventEmitter.decorate(this); + + this.#listening = false; + } + + destroy() { + this.stopListening(); + } + + startListening() { + if (this.#listening) { + return; + } + + this.#devtoolsNetworkObserver = new lazy.NetworkObserver({ + ignoreChannelFunction: this.#ignoreChannelFunction, + onNetworkEvent: this.#onNetworkEvent, + }); + + // Enable the auth prompt listening to support the auth-required event and + // phase. + this.#devtoolsNetworkObserver.setAuthPromptListenerEnabled(true); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + this.#devtoolsNetworkObserver.destroy(); + this.#devtoolsNetworkObserver = null; + + this.#listening = false; + } + + #ignoreChannelFunction = channel => { + // Bug 1826210: Ignore file channels which don't support the same APIs as + // regular HTTP channels. + if (channel instanceof Ci.nsIFileChannel) { + return true; + } + + // Ignore chrome-privileged or DevTools-initiated requests + if ( + channel.loadInfo?.loadingDocument === null && + (channel.loadInfo.loadingPrincipal === + Services.scriptSecurityManager.getSystemPrincipal() || + channel.loadInfo.isInDevToolsContext) + ) { + return true; + } + + return false; + }; + + #onNetworkEvent = (networkEvent, channel) => { + return new lazy.NetworkEventRecord(networkEvent, channel, this); + }; +} diff --git a/remote/shared/listeners/PromptListener.sys.mjs b/remote/shared/listeners/PromptListener.sys.mjs new file mode 100644 index 0000000000..e04c766970 --- /dev/null +++ b/remote/shared/listeners/PromptListener.sys.mjs @@ -0,0 +1,285 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + modal: "chrome://remote/content/shared/Prompt.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * The PromptListener listens to the DialogObserver events. + * + * Example: + * ``` + * const listener = new PromptListener(); + * listener.on("opened", onPromptOpened); + * listener.startListening(); + * + * const onPromptOpened = (eventName, data = {}) => { + * const { contentBrowser, prompt } = data; + * ... + * }; + * ``` + * + * @fires message + * The PromptListener emits "opened" events, + * with the following object as payload: + * - {XULBrowser} contentBrowser + * The <xul:browser> which hold the <var>prompt</var>. + * - {modal.Dialog} prompt + * Returns instance of the Dialog class. + * + * The PromptListener emits "closed" events, + * with the following object as payload: + * - {XULBrowser} contentBrowser + * The <xul:browser> which is the target of the event. + * - {object} detail + * {boolean=} detail.accepted + * Returns true if a user prompt was accepted + * and false if it was dismissed. + * {string=} detail.userText + * The user text specified in a prompt. + */ +export class PromptListener { + #curBrowserFn; + #listening; + + constructor(curBrowserFn) { + lazy.EventEmitter.decorate(this); + + // curBrowserFn is used only for Marionette (WebDriver classic). + this.#curBrowserFn = curBrowserFn; + this.#listening = false; + } + + destroy() { + this.stopListening(); + } + + /** + * Waits for the prompt to be closed. + * + * @returns {Promise} + * Promise that resolves when the prompt is closed. + */ + async dialogClosed() { + return new Promise(resolve => { + const dialogClosed = () => { + this.off("closed", dialogClosed); + resolve(); + }; + + this.on("closed", dialogClosed); + }); + } + + /** + * Handles `DOMModalDialogClosed` events. + */ + handleEvent(event) { + lazy.logger.trace(`Received event ${event.type}`); + + const chromeWin = event.target.opener + ? event.target.opener.ownerGlobal + : event.target.ownerGlobal; + const curBrowser = this.#curBrowserFn && this.#curBrowserFn(); + + // For Marionette (WebDriver classic) we only care about events which come + // the currently selected browser. + if (curBrowser && chromeWin != curBrowser.window) { + return; + } + + let contentBrowser; + if (lazy.AppInfo.isAndroid) { + const tabBrowser = lazy.TabManager.getTabBrowser(event.target); + // Since on Android we always have only one tab we can just check + // the selected tab. + const tab = tabBrowser.selectedTab; + contentBrowser = lazy.TabManager.getBrowserForTab(tab); + } else { + contentBrowser = event.target; + } + + const detail = {}; + + // At the moment the event details are present for GeckoView and on desktop + // only for Services.prompt.MODAL_TYPE_CONTENT prompts. + if (event.detail) { + const { areLeaving, value } = event.detail; + // `areLeaving` returns undefined for alerts, for confirms and prompts + // it returns true if a user prompt was accepted and false if it was dismissed. + detail.accepted = areLeaving === undefined ? true : areLeaving; + if (value) { + detail.userText = value; + } + } + + this.emit("closed", { + contentBrowser, + detail, + }); + } + + /** + * Observes the following notifications: + * `common-dialog-loaded` - when a modal dialog loaded on desktop, + * `domwindowopened` - when a new chrome window opened, + * `geckoview-prompt-show` - when a modal dialog opened on Android. + */ + observe(subject, topic) { + lazy.logger.trace(`Received observer notification ${topic}`); + + let curBrowser = this.#curBrowserFn && this.#curBrowserFn(); + switch (topic) { + case "common-dialog-loaded": + if (curBrowser) { + if ( + !this.#hasCommonDialog( + curBrowser.contentBrowser, + curBrowser.window, + subject + ) + ) { + return; + } + } else { + const chromeWin = subject.opener + ? subject.opener.ownerGlobal + : subject.ownerGlobal; + + for (const tab of lazy.TabManager.getTabsForWindow(chromeWin)) { + const contentBrowser = lazy.TabManager.getBrowserForTab(tab); + const window = lazy.TabManager.getWindowForTab(tab); + + if (this.#hasCommonDialog(contentBrowser, window, subject)) { + curBrowser = { + contentBrowser, + window, + }; + + break; + } + } + } + this.emit("opened", { + contentBrowser: curBrowser.contentBrowser, + prompt: new lazy.modal.Dialog(() => curBrowser, subject), + }); + + break; + + case "domwindowopened": + subject.addEventListener("DOMModalDialogClosed", this); + break; + + case "geckoview-prompt-show": + for (let win of Services.wm.getEnumerator(null)) { + const prompt = win.prompts().find(item => item.id == subject.id); + if (prompt) { + const tabBrowser = lazy.TabManager.getTabBrowser(win); + // Since on Android we always have only one tab we can just check + // the selected tab. + const tab = tabBrowser.selectedTab; + const contentBrowser = lazy.TabManager.getBrowserForTab(tab); + const window = lazy.TabManager.getWindowForTab(tab); + + // Do not send the event if the curBrowser is specified, + // and it's different from prompt browser. + if (curBrowser && contentBrowser !== curBrowser.contentBrowser) { + continue; + } + + this.emit("opened", { + contentBrowser, + prompt: new lazy.modal.Dialog( + () => ({ + contentBrowser, + window, + }), + prompt + ), + }); + return; + } + } + break; + } + } + + startListening() { + if (this.#listening) { + return; + } + + this.#register(); + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + this.#unregister(); + this.#listening = false; + } + + #hasCommonDialog(contentBrowser, window, prompt) { + const modalType = prompt.Dialog.args.modalType; + if ( + modalType === Services.prompt.MODAL_TYPE_TAB || + modalType === Services.prompt.MODAL_TYPE_CONTENT + ) { + // Find the container of the dialog in the parent document, and ensure + // it is a descendant of the same container as the content browser. + const container = contentBrowser.closest(".browserSidebarContainer"); + + return container.contains(prompt.docShell.chromeEventHandler); + } + + return prompt.ownerGlobal == window || prompt.opener?.ownerGlobal == window; + } + + #register() { + Services.obs.addObserver(this, "common-dialog-loaded"); + Services.obs.addObserver(this, "domwindowopened"); + Services.obs.addObserver(this, "geckoview-prompt-show"); + + // Register event listener and save already open prompts for all already open windows. + for (const win of Services.wm.getEnumerator(null)) { + win.addEventListener("DOMModalDialogClosed", this); + } + } + + #unregister() { + const removeObserver = observerName => { + try { + Services.obs.removeObserver(this, observerName); + } catch (e) { + lazy.logger.debug(`Failed to remove observer "${observerName}"`); + } + }; + + for (const observerName of [ + "common-dialog-loaded", + "domwindowopened", + "geckoview-prompt-show", + ]) { + removeObserver(observerName); + } + + // Unregister event listener for all open windows + for (const win of Services.wm.getEnumerator(null)) { + win.removeEventListener("DOMModalDialogClosed", this); + } + } +} diff --git a/remote/shared/listeners/test/browser/browser.toml b/remote/shared/listeners/test/browser/browser.toml new file mode 100644 index 0000000000..d462bf1e82 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser.toml @@ -0,0 +1,21 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = ["head.js"] +prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"] + +["browser_BrowsingContextListener.js"] + +["browser_ConsoleAPIListener.js"] + +["browser_ConsoleAPIListener_cached_messages.js"] + +["browser_ConsoleListener.js"] + +["browser_ConsoleListener_cached_messages.js"] + +["browser_ContextualIdentityListener.js"] + +["browser_NetworkListener.js"] + +["browser_PromptListener.js"] diff --git a/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js new file mode 100644 index 0000000000..9a08df7857 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js @@ -0,0 +1,117 @@ +/* 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/. */ + +const { BrowsingContextListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs" +); + +add_task(async function test_attachedOnNewTab() { + const listener = new BrowsingContextListener(); + const attached = listener.once("attached"); + + listener.startListening(); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + const { browsingContext, why } = await attached; + + is( + browsingContext.id, + tab.linkedBrowser.browsingContext.id, + "Received expected browsing context" + ); + is(why, "attach", "Browsing context has been attached"); + + listener.stopListening(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_attachedValidEmbedderElement() { + const listener = new BrowsingContextListener(); + + let hasEmbedderElement = false; + listener.on( + "attached", + (evtName, { browsingContext }) => { + hasEmbedderElement = !!browsingContext.embedderElement; + }, + { once: true } + ); + + listener.startListening(); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + ok( + hasEmbedderElement, + "Attached browsing context has a valid embedder element" + ); + + listener.stopListening(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_discardedOnCloseTab() { + const listener = new BrowsingContextListener(); + const discarded = listener.once("discarded"); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const browsingContext = tab.linkedBrowser.browsingContext; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + listener.startListening(); + gBrowser.removeTab(tab); + const { browsingContext: discardedBrowsingContext, why } = await discarded; + + is( + discardedBrowsingContext.id, + browsingContext.id, + "Received expected browsing context" + ); + is(why, "discard", "Browsing context has been discarded"); + + listener.stopListening(); +}); + +add_task(async function test_replaceTopLevelOnNavigation() { + const listener = new BrowsingContextListener(); + const attached = listener.once("attached"); + const discarded = listener.once("discarded"); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const browsingContext = tab.linkedBrowser.browsingContext; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + listener.startListening(); + + await loadURL(tab.linkedBrowser, "about:mozilla"); + + const discardEvent = await discarded; + const attachEvent = await attached; + + is( + discardEvent.browsingContext.id, + browsingContext.id, + "Received expected browsing context for discarded" + ); + is(discardEvent.why, "replace", "Browsing context has been replaced"); + + is( + attachEvent.browsingContext.id, + tab.linkedBrowser.browsingContext.id, + "Received expected browsing context for attached" + ); + is(discardEvent.why, "replace", "Browsing context has been replaced"); + + isnot( + discardEvent.browsingContext, + attachEvent.browsingContext, + "Got different browsing contexts" + ); + + listener.stopListening(); + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js new file mode 100644 index 0000000000..ccff78c7a0 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js @@ -0,0 +1,162 @@ +/* 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/. */ + +const TESTS = [ + { method: "log", args: ["log1"] }, + { method: "log", args: ["log2", "log3"] }, + { method: "log", args: [[1, 2, 3], { someProperty: "someValue" }] }, + { method: "warn", args: ["warn1"] }, + { method: "error", args: ["error1"] }, + { method: "info", args: ["info1"] }, + { method: "debug", args: ["debug1"] }, + { method: "trace", args: ["trace1"] }, + { method: "assert", args: [false, "assert1"] }, +]; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_method_and_arguments() { + for (const { method, args } of TESTS) { + // Use a dedicated tab for each test to avoid cached messages. + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + info(`Test ConsoleApiListener for ${JSON.stringify({ method, args })}`); + + const listenerId = await listenToConsoleAPIMessage(); + await useConsoleInContent(method, args); + const { + arguments: msgArguments, + level, + timeStamp, + stacktrace, + } = await getConsoleAPIMessage(listenerId); + + if (method == "assert") { + // console.assert() consumes first argument. + args.shift(); + } + + is( + msgArguments.length, + args.length, + "Message event has the expected number of arguments" + ); + for (let i = 0; i < args.length; i++) { + Assert.deepEqual( + msgArguments[i], + args[i], + `Message event has the expected argument at index ${i}` + ); + } + is(level, method, "Message event has the expected level"); + ok(Number.isInteger(timeStamp), "Message event has a valid timestamp"); + + if (["assert", "error", "warn", "trace"].includes(method)) { + // Check stacktrace if method is allowed to contain one. + if (method === "warn") { + todo( + Array.isArray(stacktrace), + "stacktrace is of expected type Array (Bug 1744705)" + ); + } else { + ok(Array.isArray(stacktrace), "stacktrace is of expected type Array"); + Assert.greaterOrEqual( + stacktrace.length, + 1, + "stack trace contains at least one frame" + ); + } + } else { + is(typeof stacktrace, "undefined", "stack trace is is not present"); + } + + gBrowser.removeTab(gBrowser.selectedTab); + } +}); + +add_task(async function test_stacktrace() { + const script = ` + function foo() { console.error("cheese"); } + function bar() { foo(); } + bar(); + `; + + const listenerId = await listenToConsoleAPIMessage(); + await createScriptNode(script); + const { stacktrace } = await getConsoleAPIMessage(listenerId); + + ok(Array.isArray(stacktrace), "stacktrace is of expected type Array"); + + // First 3 frames are from the test script. + Assert.greaterOrEqual( + stacktrace.length, + 3, + "stack trace contains at least 3 frames" + ); + checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 30); + checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22); + checkStackFrame(stacktrace[2], "about:blank", "", 4, 5); +}); + +function useConsoleInContent(method, args) { + info(`Call console API: console.${method}("${args.join('", "')}");`); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [method, args], + (_method, _args) => { + content.console[_method].apply(content.console, _args); + } + ); +} + +function listenToConsoleAPIMessage() { + info("Listen to a console api message in content"); + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + const consoleAPIListener = new ConsoleAPIListener(innerWindowId); + const onMessage = consoleAPIListener.once("message"); + consoleAPIListener.startListening(); + + const listenerId = Math.random(); + content[listenerId] = { consoleAPIListener, onMessage }; + return listenerId; + }); +} + +function getConsoleAPIMessage(listenerId) { + info("Retrieve the message event captured for listener: " + listenerId); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [listenerId], + async _listenerId => { + const { consoleAPIListener, onMessage } = content[_listenerId]; + const message = await onMessage; + + consoleAPIListener.destroy(); + + return message; + } + ); +} + +function checkStackFrame( + frame, + filename, + functionName, + lineNumber, + columnNumber +) { + is(frame.filename, filename, "Received expected filename for frame"); + is( + frame.functionName, + functionName, + "Received expected function name for frame" + ); + is(frame.lineNumber, lineNumber, "Received expected line for frame"); + is(frame.columnNumber, columnNumber, "Received expected column for frame"); +} diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js new file mode 100644 index 0000000000..dae35a0b9a --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js @@ -0,0 +1,100 @@ +/* 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/. */ + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_cached_messages() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + + info("Log two messages before starting the ConsoleAPIListener"); + content.console.log("message_1"); + content.console.log("message_2"); + + const listener = new ConsoleAPIListener(innerWindowId); + const messages = []; + + // We will keep the onMessage callback attached to the ConsoleAPIListener + // during the whole test to catch all the emitted events. + const onMessage = (evtName, message) => messages.push(message.arguments[0]); + + listener.on("message", onMessage); + listener.startListening(); + + info("Wait until the 2 cached messages have been emitted"); + await ContentTaskUtils.waitForCondition(() => messages.length == 2); + is(messages[0], "message_1"); + is(messages[1], "message_2"); + + info("Stop listening and log another message"); + listener.stopListening(); + content.backup = { listener, messages, onMessage }; + }); + + // Force a GC to check that old cached messages which have been garbage + // collected are not re-displayed. + await doGC(); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const { listener, messages, onMessage } = content.backup; + content.console.log("message_3"); + + info("Start listening again and check the previous message is emitted"); + listener.startListening(); + await ContentTaskUtils.waitForCondition(() => messages.length == 3); + is(messages[2], "message_3"); + + info("Log another message and wait until it is emitted"); + content.console.log("message_4"); + await ContentTaskUtils.waitForCondition(() => messages.length == 4); + is(messages[3], "message_4"); + + listener.off("message", onMessage); + listener.destroy(); + + is(messages.length, 4, "Received 4 messages in total"); + }); + + info("Reload the current tab and check only new messages are emitted"); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + + info("Log a message before creating the ConsoleAPIListener"); + content.console.log("new_message_1"); + + const listener = new ConsoleAPIListener(innerWindowId); + const newMessages = []; + const onMessage = (evtName, message) => + newMessages.push(message.arguments[0]); + listener.on("message", onMessage); + + info("Start listening and wait for the cached message"); + listener.startListening(); + await ContentTaskUtils.waitForCondition(() => newMessages.length == 1); + is(newMessages[0], "new_message_1"); + + info("Log another message and wait until it is emitted"); + content.console.log("new_message_2"); + await ContentTaskUtils.waitForCondition(() => newMessages.length == 2); + is(newMessages[1], "new_message_2"); + + listener.off("message", onMessage); + listener.destroy(); + + is(newMessages.length, 2, "Received 2 messages in total"); + }); + + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener.js b/remote/shared/listeners/test/browser/browser_ConsoleListener.js new file mode 100644 index 0000000000..41936a6c0d --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleListener.js @@ -0,0 +1,148 @@ +/* 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/. */ + +add_task(async function test_message_properties() { + const listenerId = await listenToConsoleMessage("error"); + await logConsoleMessage({ message: "foo" }); + const { level, message, timeStamp, stack } = await getConsoleMessage( + listenerId + ); + + is(level, "error", "Received expected log level"); + is(message, "foo", "Received expected log message"); + // Services.console.logMessage() doesn't include a stack. + is(stack, undefined, "No stack present"); + is(typeof timeStamp, "number", "timestamp is of expected type number"); + + // Clear the console to avoid side effects with other tests in this file. + await clearConsole(); +}); + +add_task(async function test_level() { + for (const level of ["error", "info", "warn"]) { + const listenerId = await listenToConsoleMessage(level); + await logConsoleMessage({ message: "foo", level }); + const message = await getConsoleMessage(listenerId); + + is(message.level, level, "Received expected log level"); + } + + // Clear the console to avoid side effects with other tests in this file. + await clearConsole(); +}); + +add_task(async function test_stacktrace() { + const script = ` + function foo() { throw new Error("cheese"); } + function bar() { foo(); } + bar(); + `; + + const listenerId = await listenToConsoleMessage("error"); + await createScriptNode(script); + const { message, level, stacktrace } = await getConsoleMessage(listenerId); + is(level, "error", "Received expected log level"); + is(message, "Error: cheese", "Received expected log message"); + ok(Array.isArray(stacktrace), "frames is of expected type Array"); + Assert.greaterOrEqual(stacktrace.length, 4, "Got at least 4 stack frames"); + + // First 3 stack frames are from the injected script and one more frame comes + // from head.js (chrome scope) where we inject the script. + checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 28); + checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22); + checkStackFrame(stacktrace[2], "about:blank", "", 4, 5); + checkStackFrame( + stacktrace[3], + "chrome://mochitests/content/browser/remote/shared/listeners/test/browser/head.js", + "", + 34, + 29 + ); + + // Clear the console to avoid side effects with other tests in this file. + await clearConsole(); +}); + +function logConsoleMessage(options = {}) { + info(`Log console message ${options.message}`); + return SpecialPowers.spawn(gBrowser.selectedBrowser, [options], _options => { + const { level = "error" } = _options; + + const levelToFlags = { + error: Ci.nsIScriptError.errorFlag, + info: Ci.nsIScriptError.infoFlag, + warn: Ci.nsIScriptError.warningFlag, + }; + + const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.initWithWindowID( + _options.message, + _options.sourceName || "sourceName", + null, + _options.lineNumber || 0, + _options.columnNumber || 0, + levelToFlags[level], + _options.category || "javascript", + content.windowGlobalChild.innerWindowId + ); + + Services.console.logMessage(scriptError); + }); +} + +function listenToConsoleMessage(level) { + info("Listen to a console message in content"); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [level], + async _level => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs" + ); + const consoleListener = new ConsoleListener(innerWindowId); + const onMessage = consoleListener.once(_level); + consoleListener.startListening(); + + const listenerId = Math.random(); + content[listenerId] = { consoleListener, onMessage }; + return listenerId; + } + ); +} + +function getConsoleMessage(listenerId) { + info("Retrieve the message event captured for listener: " + listenerId); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [listenerId], + async _listenerId => { + const { consoleListener, onMessage } = content[_listenerId]; + const message = await onMessage; + + consoleListener.destroy(); + + return message; + } + ); +} + +function checkStackFrame( + frame, + filename, + functionName, + lineNumber, + columnNumber +) { + is(frame.filename, filename, "Received expected filename for frame"); + is( + frame.functionName, + functionName, + "Received expected function name for frame" + ); + is(frame.lineNumber, lineNumber, "Received expected line for frame"); + is(frame.columnNumber, columnNumber, "Received expected column for frame"); +} diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js new file mode 100644 index 0000000000..1020aee661 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js @@ -0,0 +1,82 @@ +/* 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/. */ + +const TEST_PAGE = + "https://example.com/document-builder.sjs?html=<meta charset=utf8></meta>"; + +add_task(async function test_cached_javascript_errors() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + await createScriptNode(`(() => {throw "error1"})()`); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs" + ); + + const listener = new ConsoleListener(innerWindowId); + + const errors = []; + // Do not push the whole error object in the array. It would create a strong + // reference preventing from reproducing GC-related bugs. + const onError = (evtName, error) => errors.push(error.message); + listener.on("error", onError); + + const waitForMessage = listener.once("error"); + listener.startListening(); + const error = await waitForMessage; + is(error.message, "uncaught exception: error1"); + is(errors.length, 1); + + listener.stopListening(); + content.backup = { listener, errors, onError }; + }); + + // Force a GC to check that old cached messages which have been garbage + // collected are not re-displayed. + await doGC(); + await createScriptNode(`(() => {throw "error2"})()`); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const { listener, errors, onError } = content.backup; + + const waitForMessage = listener.once("error"); + listener.startListening(); + const { message } = await waitForMessage; + is(message, "uncaught exception: error2"); + is(errors.length, 2); + + listener.off("error", onError); + listener.destroy(); + }); + + info("Reload the current tab and check only new messages are emitted"); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + await createScriptNode(`(() => {throw "error3"})()`); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs" + ); + + const listener = new ConsoleListener(innerWindowId); + + const errors = []; + const onError = (evtName, error) => errors.push(error.message); + listener.on("error", onError); + + const waitForMessage = listener.once("error"); + listener.startListening(); + const error = await waitForMessage; + is(error.message, "uncaught exception: error3"); + is(errors.length, 1); + + listener.off("error", onError); + listener.destroy(); + }); + + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js b/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js new file mode 100644 index 0000000000..df783a5688 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js @@ -0,0 +1,38 @@ +/* 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/. */ + +const { ContextualIdentityListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ContextualIdentityListener.sys.mjs" +); + +add_task(async function test_createdOnNewContextualIdentity() { + const listener = new ContextualIdentityListener(); + const created = listener.once("created"); + + listener.startListening(); + + ContextualIdentityService.create("test_name"); + + const { identity } = await created; + is(identity.name, "test_name", "Received expected identity"); + + listener.stopListening(); + + ContextualIdentityService.remove(identity.userContextId); +}); + +add_task(async function test_deletedOnRemovedContextualIdentity() { + const listener = new ContextualIdentityListener(); + const deleted = listener.once("deleted"); + + listener.startListening(); + + const testIdentity = ContextualIdentityService.create("test_name"); + ContextualIdentityService.remove(testIdentity.userContextId); + + const { identity } = await deleted; + is(identity.name, "test_name", "Received expected identity"); + + listener.stopListening(); +}); diff --git a/remote/shared/listeners/test/browser/browser_NetworkListener.js b/remote/shared/listeners/test/browser/browser_NetworkListener.js new file mode 100644 index 0000000000..78865f6b80 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_NetworkListener.js @@ -0,0 +1,100 @@ +/* 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/. */ + +const { NetworkListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +add_task(async function test_beforeRequestSent() { + const listener = new NetworkListener(); + const events = []; + const onEvent = (name, data) => events.push(data); + listener.on("before-request-sent", onEvent); + + const tab1 = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab1.linkedBrowser); + const contextId1 = TabManager.getIdForBrowser(tab1.linkedBrowser); + + const tab2 = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab2" + ); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); + const contextId2 = TabManager.getIdForBrowser(tab2.linkedBrowser); + + listener.startListening(); + + await fetch(tab1.linkedBrowser, "https://example.com/?1"); + ok(events.length == 1, "One event was received"); + assertNetworkEvent(events[0], contextId1, "https://example.com/?1"); + + info("Check that events are no longer emitted after calling stopListening"); + listener.stopListening(); + await fetch(tab1.linkedBrowser, "https://example.com/?2"); + ok(events.length == 1, "No new event was received"); + + listener.startListening(); + await fetch(tab1.linkedBrowser, "https://example.com/?3"); + ok(events.length == 2, "A new event was received"); + assertNetworkEvent(events[1], contextId1, "https://example.com/?3"); + + info("Check network event from the new tab"); + await fetch(tab2.linkedBrowser, "https://example.com/?4"); + ok(events.length == 3, "A new event was received"); + assertNetworkEvent(events[2], contextId2, "https://example.com/?4"); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + listener.off("before-request-sent", onEvent); + listener.destroy(); +}); + +add_task(async function test_beforeRequestSent_newTab() { + const listener = new NetworkListener(); + const onBeforeRequestSent = listener.once("before-request-sent"); + listener.startListening(); + + info("Check network event related to loading a new tab"); + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const contextId = TabManager.getIdForBrowser(tab.linkedBrowser); + const event = await onBeforeRequestSent; + + assertNetworkEvent( + event, + contextId, + "https://example.com/document-builder.sjs?html=tab" + ); + gBrowser.removeTab(tab); +}); + +add_task(async function test_fetchError() { + const listener = new NetworkListener(); + const onFetchError = listener.once("fetch-error"); + listener.startListening(); + + info("Check fetchError event when loading a new tab"); + const tab = BrowserTestUtils.addTab(gBrowser, "https://not_a_valid_url/"); + BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const contextId = TabManager.getIdForBrowser(tab.linkedBrowser); + const event = await onFetchError; + + assertNetworkEvent(event, contextId, "https://not_a_valid_url/"); + is(event.errorText, "NS_ERROR_UNKNOWN_HOST"); + gBrowser.removeTab(tab); +}); + +function assertNetworkEvent(event, expectedContextId, expectedUrl) { + is(event.contextId, expectedContextId, "Event has the expected context id"); + is(event.requestData.url, expectedUrl, "Event has the expected url"); +} diff --git a/remote/shared/listeners/test/browser/browser_PromptListener.js b/remote/shared/listeners/test/browser/browser_PromptListener.js new file mode 100644 index 0000000000..0d3f23db3f --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_PromptListener.js @@ -0,0 +1,173 @@ +/* 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/. */ + +const { PromptListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/PromptListener.sys.mjs" +); + +add_task(async function test_without_curBrowser() { + const listener = new PromptListener(); + const opened = listener.once("opened"); + const closed = listener.once("closed"); + + listener.startListening(); + + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.confirm('test'))`); + const dialogWin = await dialogPromise; + + const openedEvent = await opened; + + is(openedEvent.prompt.window, dialogWin, "Received expected prompt"); + + dialogWin.document.querySelector("dialog").acceptDialog(); + + const closedEvent = await closed; + + is(closedEvent.detail.accepted, true, "Received correct event details"); + + listener.destroy(); +}); + +add_task(async function test_with_curBrowser() { + const listener = new PromptListener(() => ({ + contentBrowser: gBrowser.selectedBrowser, + window, + })); + const opened = listener.once("opened"); + const closed = listener.once("closed"); + + listener.startListening(); + + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.confirm('test'))`); + const dialogWin = await dialogPromise; + + const openedEvent = await opened; + + is(openedEvent.prompt.window, dialogWin, "Received expected prompt"); + + dialogWin.document.querySelector("dialog").acceptDialog(); + + const closedEvent = await closed; + + is(closedEvent.detail.accepted, true, "Received correct event details"); + + listener.destroy(); +}); + +add_task(async function test_close_event_details() { + const listener = new PromptListener(); + let closed = listener.once("closed"); + + listener.startListening(); + + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.prompt('Enter your name:'))`); + let dialogWin = await dialogPromise; + + dialogWin.document.getElementById("loginTextbox").value = "Test"; + dialogWin.document.querySelector("dialog").acceptDialog(); + + let closedEvent = await closed; + + is( + closedEvent.detail.accepted, + true, + "Received correct `accepted` value in event details" + ); + is( + closedEvent.detail.userText, + "Test", + "Received correct `userText` value in event details" + ); + + closed = listener.once("closed"); + + dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.prompt('Enter your name:'))`); + dialogWin = await dialogPromise; + + dialogWin.document.getElementById("loginTextbox").value = "Test"; + dialogWin.document.querySelector("dialog").cancelDialog(); + + closedEvent = await closed; + + is( + closedEvent.detail.accepted, + false, + "Received correct `accepted` value in event details" + ); + is( + closedEvent.detail.userText, + undefined, + "Received correct `userText` value in event details" + ); + + listener.destroy(); +}); + +add_task(async function test_dialogClosed() { + const listener = new PromptListener(); + + listener.startListening(); + + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.alert('test'))`); + let dialogWin = await dialogPromise; + let closed = listener.dialogClosed(); + + dialogWin.document.querySelector("dialog").acceptDialog(); + + await closed; + + is(true, true, "Close promise got resolved"); + + dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.alert('test'))`); + dialogWin = await dialogPromise; + closed = listener.dialogClosed(); + + dialogWin.document.querySelector("dialog").cancelDialog(); + + await closed; + + is(true, true, "Close promise got resolved"); + + listener.destroy(); +}); + +add_task(async function test_events_in_another_browser() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const selectedBrowser = win.gBrowser.selectedBrowser; + const listener = new PromptListener(() => ({ + contentBrowser: selectedBrowser, + window: selectedBrowser.ownerGlobal, + })); + const events = []; + const onEvent = (name, data) => events.push(data); + listener.on("opened", onEvent); + listener.on("closed", onEvent); + + listener.startListening(); + + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.confirm('test'))`); + const dialogWin = await dialogPromise; + + ok(events.length === 0, "No event was received"); + + dialogWin.document.querySelector("dialog").acceptDialog(); + + // Wait a bit to make sure that the event didn't come. + await new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, 500); + }); + + ok(events.length === 0, "No event was received"); + + listener.destroy(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/remote/shared/listeners/test/browser/head.js b/remote/shared/listeners/test/browser/head.js new file mode 100644 index 0000000000..1691a6f59b --- /dev/null +++ b/remote/shared/listeners/test/browser/head.js @@ -0,0 +1,89 @@ +/* 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"; + +async function clearConsole() { + for (const tab of gBrowser.tabs) { + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + Services.console.reset(); + }); + } + Services.console.reset(); +} + +/** + * Execute the provided script content by generating a dynamic script tag and + * inserting it in the page for the current selected browser. + * + * @param {string} script + * The script to execute. + * @returns {Promise} + * A promise that resolves when the script node was added and removed from + * the content page. + */ +function createScriptNode(script) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [script], + function (_script) { + var script = content.document.createElement("script"); + script.append(content.document.createTextNode(_script)); + content.document.body.append(script); + } + ); +} + +registerCleanupFunction(async () => { + await clearConsole(); +}); + +async function doGC() { + // Run GC and CC a few times to make sure that as much as possible is freed. + const numCycles = 3; + for (let i = 0; i < numCycles; i++) { + Cu.forceGC(); + Cu.forceCC(); + await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve)); + } + + const MemoryReporter = Cc[ + "@mozilla.org/memory-reporter-manager;1" + ].getService(Ci.nsIMemoryReporterManager); + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); +} + +/** + * Load the provided url in an existing browser. + * Returns a promise which will resolve when the page is loaded. + * + * @param {Browser} browser + * The browser element where the URL should be loaded. + * @param {string} url + * The URL to load. + */ +async function loadURL(browser, url) { + const loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, url); + return loaded; +} + +/** + * Create a fetch request to `url` from the content page loaded in the provided + * `browser`. + * + * + * @param {Browser} browser + * The browser element where the fetch should be performed. + * @param {string} url + * The URL to fetch. + */ +function fetch(browser, url) { + return SpecialPowers.spawn(browser, [url], async _url => { + const response = await content.fetch(_url); + // Wait for response.text() to resolve as well to make sure the response + // has completed before returning. + await response.text(); + }); +} diff --git a/remote/shared/messagehandler/Errors.sys.mjs b/remote/shared/messagehandler/Errors.sys.mjs new file mode 100644 index 0000000000..69c65acd09 --- /dev/null +++ b/remote/shared/messagehandler/Errors.sys.mjs @@ -0,0 +1,90 @@ +/* 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/. */ + +import { RemoteError } from "chrome://remote/content/shared/RemoteError.sys.mjs"; + +class MessageHandlerError extends RemoteError { + /** + * @param {(string|Error)=} x + * Optional string describing error situation or Error instance + * to propagate. + */ + constructor(x) { + super(x); + this.name = this.constructor.name; + this.status = "message handler error"; + + // Error's ctor does not preserve x' stack + if (typeof x?.stack !== "undefined") { + this.stack = x.stack; + } + } + + get isMessageHandlerError() { + return true; + } + + /** + * @returns {Object<string, string>} + * JSON serialisation of error prototype. + */ + toJSON() { + return { + error: this.status, + message: this.message || "", + stacktrace: this.stack || "", + }; + } + + /** + * Unmarshals a JSON error representation to the appropriate MessageHandler + * error type. + * + * @param {Object<string, string>} json + * Error object. + * + * @returns {Error} + * Error prototype. + */ + static fromJSON(json) { + if (typeof json.error == "undefined") { + let s = JSON.stringify(json); + throw new TypeError("Undeserialisable error type: " + s); + } + if (!STATUSES.has(json.error)) { + throw new TypeError("Not of MessageHandlerError descent: " + json.error); + } + + let cls = STATUSES.get(json.error); + let err = new cls(); + if ("message" in json) { + err.message = json.message; + } + if ("stacktrace" in json) { + err.stack = json.stacktrace; + } + return err; + } +} + +/** + * A command could not be handled by the message handler network. + */ +class UnsupportedCommandError extends MessageHandlerError { + constructor(message) { + super(message); + this.status = "unsupported message handler command"; + } +} + +const STATUSES = new Map([ + ["message handler error", MessageHandlerError], + ["unsupported message handler command", UnsupportedCommandError], +]); + +/** @namespace */ +export const error = { + MessageHandlerError, + UnsupportedCommandError, +}; diff --git a/remote/shared/messagehandler/EventsDispatcher.sys.mjs b/remote/shared/messagehandler/EventsDispatcher.sys.mjs new file mode 100644 index 0000000000..9620febcc1 --- /dev/null +++ b/remote/shared/messagehandler/EventsDispatcher.sys.mjs @@ -0,0 +1,260 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + SessionDataCategory: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + SessionDataMethod: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Helper to listen to events which rely on SessionData. + * In order to support the EventsDispatcher, a module emitting events should + * subscribe and unsubscribe to those events based on SessionData updates + * and should use the "event" SessionData category. + */ +export class EventsDispatcher { + // The MessageHandler owning this EventsDispatcher. + #messageHandler; + + /** + * @typedef {object} EventListenerInfo + * @property {ContextDescriptor} contextDescriptor + * The ContextDescriptor to which those callbacks are associated + * @property {Set<Function>} callbacks + * The callbacks to trigger when an event matching the ContextDescriptor + * is received. + */ + + // Map from event name to map of strings (context keys) to EventListenerInfo. + #listenersByEventName; + + /** + * Create a new EventsDispatcher instance. + * + * @param {MessageHandler} messageHandler + * The MessageHandler owning this EventsDispatcher. + */ + constructor(messageHandler) { + this.#messageHandler = messageHandler; + + this.#listenersByEventName = new Map(); + } + + destroy() { + for (const event of this.#listenersByEventName.keys()) { + this.#messageHandler.off(event, this.#onMessageHandlerEvent); + } + + this.#listenersByEventName = null; + } + + /** + * Check for existing listeners for a given event name and a given context. + * + * @param {string} name + * Name of the event to check. + * @param {ContextInfo} contextInfo + * ContextInfo identifying the context to check. + * + * @returns {boolean} + * True if there is a registered listener matching the provided arguments. + */ + hasListener(name, contextInfo) { + if (!this.#listenersByEventName.has(name)) { + return false; + } + + const listeners = this.#listenersByEventName.get(name); + for (const { contextDescriptor } of listeners.values()) { + if (this.#matchesContext(contextInfo, contextDescriptor)) { + return true; + } + } + return false; + } + + /** + * Stop listening for an event relying on SessionData and relayed by the + * message handler. + * + * @param {string} event + * Name of the event to unsubscribe from. + * @param {ContextDescriptor} contextDescriptor + * Context descriptor for this event. + * @param {Function} callback + * Event listener callback. + * @returns {Promise} + * Promise which resolves when the event fully unsubscribed, including + * propagating the necessary session data. + */ + async off(event, contextDescriptor, callback) { + return this.update([{ event, contextDescriptor, callback, enable: false }]); + } + + /** + * Listen for an event relying on SessionData and relayed by the message + * handler. + * + * @param {string} event + * Name of the event to subscribe to. + * @param {ContextDescriptor} contextDescriptor + * Context descriptor for this event. + * @param {Function} callback + * Event listener callback. + * @returns {Promise} + * Promise which resolves when the event fully subscribed to, including + * propagating the necessary session data. + */ + async on(event, contextDescriptor, callback) { + return this.update([{ event, contextDescriptor, callback, enable: true }]); + } + + /** + * An object that holds information about subscription/unsubscription + * of an event. + * + * @typedef Subscription + * + * @param {string} event + * Name of the event to subscribe/unsubscribe to. + * @param {ContextDescriptor} contextDescriptor + * Context descriptor for this event. + * @param {Function} callback + * Event listener callback. + * @param {boolean} enable + * True, if we need to subscribe to an event. + * Otherwise false. + */ + + /** + * Start or stop listening to a list of events relying on SessionData + * and relayed by the message handler. + * + * @param {Array<Subscription>} subscriptions + * The list of information to subscribe/unsubscribe to. + * + * @returns {Promise} + * Promise which resolves when the events fully subscribed/unsubscribed to, + * including propagating the necessary session data. + */ + async update(subscriptions) { + const sessionDataItemUpdates = []; + subscriptions.forEach(({ event, contextDescriptor, callback, enable }) => { + if (enable) { + // Setup listeners. + if (!this.#listenersByEventName.has(event)) { + this.#listenersByEventName.set(event, new Map()); + this.#messageHandler.on(event, this.#onMessageHandlerEvent); + } + + const key = this.#getContextKey(contextDescriptor); + const listeners = this.#listenersByEventName.get(event); + if (listeners.has(key)) { + const { callbacks } = listeners.get(key); + callbacks.add(callback); + } else { + const callbacks = new Set([callback]); + listeners.set(key, { callbacks, contextDescriptor }); + + sessionDataItemUpdates.push({ + ...this.#getSessionDataItem(event, contextDescriptor), + method: lazy.SessionDataMethod.Add, + }); + } + } else { + // Remove listeners. + const listeners = this.#listenersByEventName.get(event); + if (!listeners) { + return; + } + + const key = this.#getContextKey(contextDescriptor); + if (!listeners.has(key)) { + return; + } + + const { callbacks } = listeners.get(key); + if (callbacks.has(callback)) { + callbacks.delete(callback); + if (callbacks.size === 0) { + listeners.delete(key); + if (listeners.size === 0) { + this.#messageHandler.off(event, this.#onMessageHandlerEvent); + this.#listenersByEventName.delete(event); + } + + sessionDataItemUpdates.push({ + ...this.#getSessionDataItem(event, contextDescriptor), + method: lazy.SessionDataMethod.Remove, + }); + } + } + } + }); + + // Update all sessionData at once. + await this.#messageHandler.updateSessionData(sessionDataItemUpdates); + } + + #getContextKey(contextDescriptor) { + const { id, type } = contextDescriptor; + return `${type}-${id}`; + } + + #getSessionDataItem(event, contextDescriptor) { + const [moduleName] = event.split("."); + return { + moduleName, + category: lazy.SessionDataCategory.Event, + contextDescriptor, + values: [event], + }; + } + + #matchesContext(contextInfo, contextDescriptor) { + if (contextDescriptor.type === lazy.ContextDescriptorType.All) { + return true; + } + + if ( + contextDescriptor.type === lazy.ContextDescriptorType.TopBrowsingContext + ) { + const eventBrowsingContext = lazy.TabManager.getBrowsingContextById( + contextInfo.contextId + ); + return eventBrowsingContext?.browserId === contextDescriptor.id; + } + + return false; + } + + #onMessageHandlerEvent = (name, event, contextInfo) => { + const listeners = this.#listenersByEventName.get(name); + for (const { callbacks, contextDescriptor } of listeners.values()) { + if (!this.#matchesContext(contextInfo, contextDescriptor)) { + continue; + } + + for (const callback of callbacks) { + try { + callback(name, event); + } catch (e) { + lazy.logger.debug( + `Error while executing callback for ${name}: ${e.message}` + ); + } + } + } + }; +} diff --git a/remote/shared/messagehandler/MessageHandler.sys.mjs b/remote/shared/messagehandler/MessageHandler.sys.mjs new file mode 100644 index 0000000000..18ec6b820c --- /dev/null +++ b/remote/shared/messagehandler/MessageHandler.sys.mjs @@ -0,0 +1,355 @@ +/* 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/. */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs", + EventsDispatcher: + "chrome://remote/content/shared/messagehandler/EventsDispatcher.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + ModuleCache: + "chrome://remote/content/shared/messagehandler/ModuleCache.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * A ContextDescriptor object provides information to decide if a broadcast or + * a session data item should be applied to a specific MessageHandler context. + * + * @typedef {object} ContextDescriptor + * @property {ContextDescriptorType} type + * The type of context + * @property {string=} id + * Unique id of a given context for the provided type. + * For ContextDescriptorType.All, id can be ommitted. + * For ContextDescriptorType.TopBrowsingContext, the id should be the + * browserId corresponding to a top-level browsing context. + */ + +/** + * Enum of ContextDescriptor types. + * + * @enum {string} + */ +export const ContextDescriptorType = { + All: "All", + TopBrowsingContext: "TopBrowsingContext", +}; + +/** + * A ContextInfo identifies a given context that can be linked to a MessageHandler + * instance. It should be used to identify events coming from this context. + * + * It can either be provided by the MessageHandler itself, when the event is + * emitted from the context it relates to. + * + * Or it can be assembled manually, for instance when emitting an event which + * relates to a window global from the root layer (eg browsingContext.contextCreated). + * + * @typedef {object} ContextInfo + * @property {string} contextId + * Unique id of the MessageHandler corresponding to this context. + * @property {string} type + * One of MessageHandler.type. + */ + +/** + * MessageHandler instances are dedicated to handle both Commands and Events + * to enable automation and introspection for remote control protocols. + * + * MessageHandler instances are designed to form a network, where each instance + * should allow to inspect a specific context (eg. a BrowsingContext, a Worker, + * etc). Those instances might live in different processes and threads but + * should be linked together by the usage of a single sessionId, shared by all + * the instances of a single MessageHandler network. + * + * MessageHandler instances will be dynamically spawned depending on which + * Command or which Event needs to be processed and should therefore not be + * explicitly created by consumers, nor used directly. + * + * The only exception is the ROOT MessageHandler. This MessageHandler will be + * the entry point to send commands to the rest of the network. It will also + * emit all the relevant events captured by the network. + * + * However, even to create this ROOT MessageHandler, consumers should use the + * RootMessageHandlerRegistry. This singleton will ensure that MessageHandler + * instances are properly registered and can be retrieved based on a given + * session id as well as some other context information. + */ +export class MessageHandler extends EventEmitter { + #context; + #contextId; + #eventsDispatcher; + #moduleCache; + #registry; + #sessionId; + + /** + * Create a new MessageHandler instance. + * + * @param {string} sessionId + * ID of the session the handler is used for. + * @param {object} context + * The context linked to this MessageHandler instance. + * @param {MessageHandlerRegistry} registry + * The MessageHandlerRegistry which owns this MessageHandler instance. + */ + constructor(sessionId, context, registry) { + super(); + + this.#moduleCache = new lazy.ModuleCache(this); + + this.#sessionId = sessionId; + this.#context = context; + this.#contextId = this.constructor.getIdFromContext(context); + this.#eventsDispatcher = new lazy.EventsDispatcher(this); + this.#registry = registry; + } + + get context() { + return this.#context; + } + + get contextId() { + return this.#contextId; + } + + get eventsDispatcher() { + return this.#eventsDispatcher; + } + + get moduleCache() { + return this.#moduleCache; + } + + get name() { + return [this.sessionId, this.constructor.type, this.contextId].join("-"); + } + + get registry() { + return this.#registry; + } + + get sessionId() { + return this.#sessionId; + } + + destroy() { + lazy.logger.trace( + `MessageHandler ${this.constructor.type} for session ${this.sessionId} is being destroyed` + ); + this.#eventsDispatcher.destroy(); + this.#moduleCache.destroy(); + + // At least the MessageHandlerRegistry will be expecting this event in order + // to remove the instance from the registry when destroyed. + this.emit("message-handler-destroyed", this); + } + + /** + * Emit a message handler event. + * + * Such events should bubble up to the root of a MessageHandler network. + * + * @param {string} name + * Name of the event. Protocol level events should be of the + * form [module name].[event name]. + * @param {object} data + * The event's data. + * @param {ContextInfo=} contextInfo + * The event's context info, used to identify the origin of the event. + * If not provided, the context info of the current MessageHandler will be + * used. + */ + emitEvent(name, data, contextInfo) { + // If no contextInfo field is provided on the event, extract it from the + // MessageHandler instance. + contextInfo = contextInfo || this.#getContextInfo(); + + // Events are emitted both under their own name for consumers listening to + // a specific and as `message-handler-event` for consumers which need to + // catch all events. + this.emit(name, data, contextInfo); + this.emit("message-handler-event", { + name, + contextInfo, + data, + sessionId: this.sessionId, + }); + } + + /** + * @typedef {object} CommandDestination + * @property {string} type + * One of MessageHandler.type. + * @property {string=} id + * Unique context identifier. The format depends on the type. + * For WINDOW_GLOBAL destinations, this is a browsing context id. + * Optional, should only be provided if `contextDescriptor` is missing. + * @property {ContextDescriptor=} contextDescriptor + * Descriptor used to match several contexts, which will all receive the + * command. + * Optional, should only be provided if `id` is missing. + */ + + /** + * @typedef {object} Command + * @property {string} commandName + * The name of the command to execute. + * @property {string} moduleName + * The name of the module. + * @property {object} params + * Optional command parameters. + * @property {CommandDestination} destination + * The destination describing a debuggable context. + * @property {boolean=} retryOnAbort + * Optional. When true, commands will be retried upon AbortError, which + * can occur when the underlying JSWindowActor pair is destroyed. + * Defaults to `false`. + */ + + /** + * Retrieve all module classes matching the moduleName and destination. + * See `getAllModuleClasses` (ModuleCache.jsm) for more details. + * + * @param {string} moduleName + * The name of the module. + * @param {Destination} destination + * The destination. + * @returns {Array.<class<Module>|null>} + * An array of Module classes. + */ + getAllModuleClasses(moduleName, destination) { + return this.#moduleCache.getAllModuleClasses(moduleName, destination); + } + + /** + * Handle a command, either in one of the modules owned by this MessageHandler + * or in a another MessageHandler after forwarding the command. + * + * @param {Command} command + * The command that should be either handled in this layer or forwarded to + * the next layer leading to the destination. + * @returns {Promise} A Promise that will resolve with the return value of the + * command once it has been executed. + */ + handleCommand(command) { + const { moduleName, commandName, params, destination } = command; + lazy.logger.trace( + `Received command ${moduleName}.${commandName} for destination ${destination.type}` + ); + + if (!this.supportsCommand(moduleName, commandName, destination)) { + throw new lazy.error.UnsupportedCommandError( + `${moduleName}.${commandName} not supported for destination ${destination?.type}` + ); + } + + const module = this.#moduleCache.getModuleInstance(moduleName, destination); + if (module && module.supportsMethod(commandName)) { + return module[commandName](params, destination); + } + + return this.forwardCommand(command); + } + + toString() { + return `[object ${this.constructor.name} ${this.name}]`; + } + + /** + * Execute the required initialization steps, inlcluding apply the initial session data items + * provided to this MessageHandler on startup. Implementation is specific to each MessageHandler class. + * + * By default the implementation is a no-op. + * + * @param {Array<SessionDataItem>} sessionDataItems + * Initial session data items for this MessageHandler. + */ + async initialize(sessionDataItems) {} + + /** + * Returns the module path corresponding to this MessageHandler class. + * + * Needs to be implemented in the sub class. + */ + static get modulePath() { + throw new Error("Not implemented"); + } + + /** + * Returns the type corresponding to this MessageHandler class. + * + * Needs to be implemented in the sub class. + */ + static get type() { + throw new Error("Not implemented"); + } + + /** + * Returns the id corresponding to a context compatible with this + * MessageHandler class. + * + * Needs to be implemented in the sub class. + */ + static getIdFromContext(context) { + throw new Error("Not implemented"); + } + + /** + * Forward a command to other MessageHandlers. + * + * Needs to be implemented in the sub class. + */ + forwardCommand(command) { + throw new Error("Not implemented"); + } + + /** + * Check if contextDescriptor matches the context linked + * to this MessageHandler instance. + * + * Needs to be implemented in the sub class. + */ + matchesContext(contextDescriptor) { + throw new Error("Not implemented"); + } + + /** + * Check if the given command is supported in the module + * for the destination + * + * @param {string} moduleName + * The name of the module. + * @param {string} commandName + * The name of the command. + * @param {Destination} destination + * The destination. + * @returns {boolean} + * True if the command is supported. + */ + supportsCommand(moduleName, commandName, destination) { + return this.getAllModuleClasses(moduleName, destination).some(cls => + cls.supportsMethod(commandName) + ); + } + + /** + * Return the context information for this MessageHandler instance, which + * can be used to identify the origin of an event. + * + * @returns {ContextInfo} + * The context information for this MessageHandler. + */ + #getContextInfo() { + return { + contextId: this.contextId, + type: this.constructor.type, + }; + } +} diff --git a/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs new file mode 100644 index 0000000000..6a09173f50 --- /dev/null +++ b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs @@ -0,0 +1,236 @@ +/* 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/. */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + readSessionData: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Map of MessageHandler type to MessageHandler subclass. + */ +ChromeUtils.defineLazyGetter( + lazy, + "MessageHandlerClasses", + () => + new Map([ + [lazy.RootMessageHandler.type, lazy.RootMessageHandler], + [lazy.WindowGlobalMessageHandler.type, lazy.WindowGlobalMessageHandler], + ]) +); + +/** + * Get the MessageHandler subclass corresponding to the provided type. + + * @param {string} type + * MessageHandler type, one of MessageHandler.type. + * @returns {Class} + * A MessageHandler subclass + * @throws {Error} + * Throws if no MessageHandler subclass is found for the provided type. + */ +export function getMessageHandlerClass(type) { + if (!lazy.MessageHandlerClasses.has(type)) { + throw new Error(`No MessageHandler class available for type "${type}"`); + } + return lazy.MessageHandlerClasses.get(type); +} + +/** + * The MessageHandlerRegistry allows to create and retrieve MessageHandler + * instances for different session ids. + * + * A MessageHandlerRegistry instance is bound to a specific MessageHandler type + * and context. All MessageHandler instances created by the same registry will + * use the type and context of the registry, but each will be associated to a + * different session id. + * + * The registry is useful to retrieve the appropriate MessageHandler instance + * after crossing a technical boundary (eg process, thread...). + */ +export class MessageHandlerRegistry extends EventEmitter { + /* + * @param {String} type + * MessageHandler type, one of MessageHandler.type. + * @param {Object} context + * The context object, which depends on the type. + */ + constructor(type, context) { + super(); + + this._messageHandlerClass = getMessageHandlerClass(type); + this._context = context; + this._type = type; + + /** + * Map of session id to MessageHandler instance + */ + this._messageHandlersMap = new Map(); + + this._onMessageHandlerDestroyed = + this._onMessageHandlerDestroyed.bind(this); + this._onMessageHandlerEvent = this._onMessageHandlerEvent.bind(this); + } + + /** + * Create all message handlers for the current context, based on the content + * of the session data. + * This should typically be called when the context is ready to be used and + * to receive/send commands. + */ + createAllMessageHandlers() { + const data = lazy.readSessionData(); + for (const [sessionId, sessionDataItems] of data) { + // Create a message handler for this context for each active message + // handler session. + // TODO: In the future, to support debugging use cases we might want to + // only create a message handler if there is relevant data. + // For automation scenarios, this is less critical. + this._createMessageHandler(sessionId, sessionDataItems); + } + } + + destroy() { + this._messageHandlersMap.forEach(messageHandler => { + messageHandler.destroy(); + }); + } + + /** + * Retrieve all MessageHandler instances held in this registry, for all + * session IDs. + * + * @returns {Iterable.<MessageHandler>} + * Iterator of MessageHandler instances + */ + getAllMessageHandlers() { + return this._messageHandlersMap.values(); + } + + /** + * Retrieve an existing MessageHandler instance matching the provided session + * id. Returns null if no MessageHandler was found. + * + * @param {string} sessionId + * ID of the session the handler is used for. + * @returns {MessageHandler=} + * A MessageHandler instance, null if not found. + */ + getExistingMessageHandler(sessionId) { + return this._messageHandlersMap.get(sessionId); + } + + /** + * Retrieve the MessageHandler instance registered for the provided session + * id. Will create and register a MessageHander if no instance was found. + * + * @param {string} sessionId + * ID of the session the handler is used for. + * @returns {MessageHandler} + * A MessageHandler instance. + */ + getOrCreateMessageHandler(sessionId) { + let messageHandler = this.getExistingMessageHandler(sessionId); + if (!messageHandler) { + messageHandler = this._createMessageHandler(sessionId); + } + + return messageHandler; + } + + /** + * Retrieve an already registered RootMessageHandler instance matching the + * provided sessionId. + * + * @param {string} sessionId + * ID of the session the handler is used for. + * @returns {RootMessageHandler} + * A RootMessageHandler instance. + * @throws {Error} + * If no root MessageHandler can be found for the provided session id. + */ + getRootMessageHandler(sessionId) { + const rootMessageHandler = this.getExistingMessageHandler( + sessionId, + lazy.RootMessageHandler.type + ); + if (!rootMessageHandler) { + throw new Error( + `Unable to find a root MessageHandler for session id ${sessionId}` + ); + } + return rootMessageHandler; + } + + toString() { + return `[object ${this.constructor.name}]`; + } + + /** + * Create a new MessageHandler instance. + * + * @param {string} sessionId + * ID of the session the handler will be used for. + * @param {Array<SessionDataItem>=} sessionDataItems + * Optional array of session data items to be applied automatically to the + * MessageHandler. + * @returns {MessageHandler} + * A new MessageHandler instance. + */ + _createMessageHandler(sessionId, sessionDataItems) { + const messageHandler = new this._messageHandlerClass( + sessionId, + this._context, + this + ); + + messageHandler.on( + "message-handler-destroyed", + this._onMessageHandlerDestroyed + ); + messageHandler.on("message-handler-event", this._onMessageHandlerEvent); + + messageHandler.initialize(sessionDataItems); + + this._messageHandlersMap.set(sessionId, messageHandler); + + lazy.logger.trace( + `Created MessageHandler ${this._type} for session ${sessionId}` + ); + + return messageHandler; + } + + // Event handlers + + _onMessageHandlerDestroyed(eventName, messageHandler) { + messageHandler.off( + "message-handler-destroyed", + this._onMessageHandlerDestroyed + ); + messageHandler.off("message-handler-event", this._onMessageHandlerEvent); + this._messageHandlersMap.delete(messageHandler.sessionId); + + lazy.logger.trace( + `Unregistered MessageHandler ${messageHandler.constructor.type} for session ${messageHandler.sessionId}` + ); + } + + _onMessageHandlerEvent(eventName, messageHandlerEvent) { + // The registry simply re-emits MessageHandler events so that consumers + // don't have to attach listeners to individual MessageHandler instances. + this.emit("message-handler-registry-event", messageHandlerEvent); + } +} diff --git a/remote/shared/messagehandler/Module.sys.mjs b/remote/shared/messagehandler/Module.sys.mjs new file mode 100644 index 0000000000..30b26938e2 --- /dev/null +++ b/remote/shared/messagehandler/Module.sys.mjs @@ -0,0 +1,135 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "disabledExperimentalAPI", () => { + return !Services.prefs.getBoolPref("remote.experimental.enabled"); +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +export class Module { + #messageHandler; + + /** + * Create a new module instance. + * + * @param {MessageHandler} messageHandler + * The MessageHandler instance which owns this Module instance. + */ + constructor(messageHandler) { + this.#messageHandler = messageHandler; + } + + /** + * Clean-up the module instance. + */ + destroy() { + lazy.logger.warn( + `Module ${this.constructor.name} is missing a destroy method` + ); + } + + /** + * Emit a message handler event. + * + * Such events should bubble up to the root of a MessageHandler network. + * + * @param {string} name + * Name of the event. Protocol level events should be of the + * form [module name].[event name]. + * @param {object} data + * The event's data. + * @param {ContextInfo=} contextInfo + * The event's context info, see MessageHandler:emitEvent. Optional. + */ + emitEvent(name, data, contextInfo) { + this.messageHandler.emitEvent(name, data, contextInfo); + } + + /** + * Intercept an event and modify the payload. + * + * It's required to be implemented in windowglobal-in-root modules. + * + * @param {string} name + * Name of the event. + * @param {object} payload + * The event's payload. + * @returns {object} + * The modified event payload. + */ + interceptEvent(name, payload) { + throw new Error( + `Could not intercept event ${name}, interceptEvent is not implemented in windowglobal-in-root module` + ); + } + + /** + * Assert if experimental commands are enabled. + * + * @param {string} methodName + * Name of the command. + * + * @throws {UnknownCommandError} + * If experimental commands are disabled. + */ + assertExperimentalCommandsEnabled(methodName) { + // TODO: 1778987. Move it to a BiDi specific place. + if (lazy.disabledExperimentalAPI) { + throw new lazy.error.UnknownCommandError(methodName); + } + } + + /** + * Assert if experimental events are enabled. + * + * @param {string} moduleName + * Name of the module. + * + * @param {string} event + * Name of the event. + * + * @throws {InvalidArgumentError} + * If experimental events are disabled. + */ + assertExperimentalEventsEnabled(moduleName, event) { + // TODO: 1778987. Move it to a BiDi specific place. + if (lazy.disabledExperimentalAPI) { + throw new lazy.error.InvalidArgumentError( + `Module ${moduleName} does not support event ${event}` + ); + } + } + + /** + * Instance shortcut for supportsMethod to avoid reaching the constructor for + * consumers which directly deal with an instance. + */ + supportsMethod(methodName) { + return this.constructor.supportsMethod(methodName); + } + + get messageHandler() { + return this.#messageHandler; + } + + static get supportedEvents() { + return []; + } + + static supportsEvent(event) { + return this.supportedEvents.includes(event); + } + + static supportsMethod(methodName) { + return typeof this.prototype[methodName] === "function"; + } +} diff --git a/remote/shared/messagehandler/ModuleCache.sys.mjs b/remote/shared/messagehandler/ModuleCache.sys.mjs new file mode 100644 index 0000000000..6cff8dff60 --- /dev/null +++ b/remote/shared/messagehandler/ModuleCache.sys.mjs @@ -0,0 +1,263 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + getMessageHandlerClass: + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", +}); + +const protocols = { + bidi: {}, + test: {}, +}; +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(protocols.bidi, { + // Additional protocols might use a different registry for their modules, + // in which case this will no longer be a constant but will instead depend on + // the protocol owning the MessageHandler. See Bug 1722464. + modules: + "chrome://remote/content/webdriver-bidi/modules/ModuleRegistry.sys.mjs", +}); +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(protocols.test, { + modules: + "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * ModuleCache instances are dedicated to lazily create and cache the instances + * of all the modules related to a specific MessageHandler instance. + * + * ModuleCache also implements the logic to resolve the path to the file for a + * given module, which depends both on the current MessageHandler context and on + * the expected destination. + * + * In order to implement module logic in any context, separate module files + * should be created for each situation. For instance, for a given module, + * - ${MODULES_FOLDER}/root/{ModuleName}.sys.mjs contains the implementation for + * commands intended for the destination ROOT, and will be created for a ROOT + * MessageHandler only. Typically, they will run in the parent process. + * - ${MODULES_FOLDER}/windowglobal/{ModuleName}.sys.mjs contains the implementation + * for commands intended for a WINDOW_GLOBAL destination, and will be created + * for a WINDOW_GLOBAL MessageHandler only. Those will usually run in a + * content process. + * - ${MODULES_FOLDER}/windowglobal-in-root/{ModuleName}.sys.mjs also handles + * commands intended for a WINDOW_GLOBAL destination, but they will be created + * for the ROOT MessageHandler and will run in the parent process. This can be + * useful if some code has to be executed in the parent process, even though + * the final destination is a WINDOW_GLOBAL. + * - And so on, as more MessageHandler types get added, more combinations will + * follow based on the same pattern: + * - {contextName}/{ModuleName}.sys.mjs + * - or {destinationType}-in-{currentType}/{ModuleName}.sys.mjs + * + * All those implementations are optional. If a module cannot be found, based on + * the logic detailed above, the MessageHandler will assume that the command + * should simply be forwarded to the next layer of the network. + */ +export class ModuleCache { + #messageHandler; + #messageHandlerType; + #modules; + #protocol; + + /* + * @param {MessageHandler} messageHandler + * The MessageHandler instance which owns this ModuleCache instance. + */ + constructor(messageHandler) { + this.#messageHandler = messageHandler; + this.#messageHandlerType = messageHandler.constructor.type; + + // Map of absolute module paths to module instances. + this.#modules = new Map(); + + // Use the module class from the WebDriverBiDi ModuleRegistry if we + // are not using test modules. + this.#protocol = Services.prefs.getBoolPref( + "remote.messagehandler.modulecache.useBrowserTestRoot", + false + ) + ? protocols.test + : protocols.bidi; + } + + /** + * Destroy all instantiated modules. + */ + destroy() { + this.#modules.forEach(module => module?.destroy()); + } + + /** + * Retrieve all module classes matching the provided module name to reach the + * provided destination, from the current context. + * + * This corresponds to the path a command can take to reach its destination. + * A command's method must be implemented in one of the classes returned by + * getAllModuleClasses in order to be successfully handled. + * + * @param {string} moduleName + * The name of the module. + * @param {Destination} destination + * The destination. + * @returns {Array<class<Module>|null>} + * An array of Module classes. + */ + getAllModuleClasses(moduleName, destination) { + const destinationType = destination.type; + const classes = [ + this.#getModuleClass( + moduleName, + this.#messageHandlerType, + destinationType + ), + ]; + + // Bug 1733242: Extend the implementation of this method to handle workers. + // It assumes layers have at most one level of nesting, for instance + // "root -> windowglobal", but it wouldn't work for something such as + // "root -> windowglobal -> worker". + if (destinationType !== this.#messageHandlerType) { + classes.push( + this.#getModuleClass(moduleName, destinationType, destinationType) + ); + } + + return classes.filter(cls => !!cls); + } + + /** + * Get a module instance corresponding to the provided moduleName and + * destination. If no existing module can be found in the cache, ModuleCache + * will attempt to import the module file and create a new instance, which + * will then be cached and returned for subsequent calls. + * + * @param {string} moduleName + * The name of the module which should implement the command. + * @param {CommandDestination} destination + * The destination of the command for which we need to instantiate a + * module. See MessageHandler.sys.mjs for the CommandDestination typedef. + * @returns {object=} + * A module instance corresponding to the provided moduleName and + * destination, or null if it could not be instantiated. + */ + getModuleInstance(moduleName, destination) { + const key = `${moduleName}-${destination.type}`; + + if (this.#modules.has(key)) { + // If there is already a cached instance (potentially null) for the + // module name + destination type pair, return it. + return this.#modules.get(key); + } + + const ModuleClass = this.#getModuleClass( + moduleName, + this.#messageHandlerType, + destination.type + ); + + let module = null; + if (ModuleClass) { + module = new ModuleClass(this.#messageHandler); + } + + this.#modules.set(key, module); + return module; + } + + /** + * Check if the given module exists for the destination. + * + * @param {string} moduleName + * The name of the module. + * @param {Destination} destination + * The destination. + * @returns {boolean} + * True if the module exists. + */ + hasModuleClass(moduleName, destination) { + const classes = this.getAllModuleClasses(moduleName, destination); + return !!classes.length; + } + + toString() { + return `[object ${this.constructor.name} ${this.#messageHandler.name}]`; + } + + /** + * Retrieve the module class matching the provided module name and folder. + * + * @param {string} moduleName + * The name of the module to get the class for. + * @param {string} originType + * The MessageHandler type from where the command comes. + * @param {string} destinationType + * The MessageHandler type where the command should go to. + * @returns {Class=} + * The class corresponding to the module name and folder, null if no match + * was found. + * @throws {Error} + * If the provided module folder is unexpected. + */ + #getModuleClass = function (moduleName, originType, destinationType) { + if ( + destinationType === lazy.RootMessageHandler.type && + originType !== destinationType + ) { + // If we are trying to reach the root layer from a lower layer, no module + // class should attempt to handle the command in the current layer and + // the command should be forwarded unconditionally. + return null; + } + + const moduleFolder = this.#getModuleFolder(originType, destinationType); + if (!this.#protocol.modules[moduleFolder]) { + throw new Error( + `Invalid module folder "${moduleFolder}", expected one of "${Object.keys( + this.#protocol.modules + )}"` + ); + } + + let moduleClass = null; + if (this.#protocol.modules[moduleFolder][moduleName]) { + moduleClass = this.#protocol.modules[moduleFolder][moduleName]; + } + + if (moduleClass) { + lazy.logger.trace( + `Module ${moduleFolder}/${moduleName}.sys.mjs found for ${destinationType}` + ); + } else { + lazy.logger.trace( + `Module ${moduleFolder}/${moduleName}.sys.mjs not found for ${destinationType}` + ); + } + + return moduleClass; + }; + + #getModuleFolder(originType, destinationType) { + const originPath = lazy.getMessageHandlerClass(originType).modulePath; + if (originType === destinationType) { + // If the command is targeting the current type, the module is expected to + // be in eg "windowglobal/${moduleName}.sys.mjs". + return originPath; + } + + // If the command is targeting another type, the module is expected to + // be in a composed folder eg "windowglobal-in-root/${moduleName}.sys.mjs". + const destinationPath = + lazy.getMessageHandlerClass(destinationType).modulePath; + return `${destinationPath}-in-${originPath}`; + } +} diff --git a/remote/shared/messagehandler/RootMessageHandler.sys.mjs b/remote/shared/messagehandler/RootMessageHandler.sys.mjs new file mode 100644 index 0000000000..06a8cd6f18 --- /dev/null +++ b/remote/shared/messagehandler/RootMessageHandler.sys.mjs @@ -0,0 +1,237 @@ +/* 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/. */ + +import { MessageHandler } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NavigationManager: "chrome://remote/content/shared/NavigationManager.sys.mjs", + RootTransport: + "chrome://remote/content/shared/messagehandler/transports/RootTransport.sys.mjs", + SessionData: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + SessionDataMethod: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +/** + * A RootMessageHandler is the root node of a MessageHandler network. It lives + * in the parent process. It can forward commands to MessageHandlers in other + * layers (at the moment WindowGlobalMessageHandlers in content processes). + */ +export class RootMessageHandler extends MessageHandler { + #navigationManager; + #realms; + #rootTransport; + #sessionData; + + /** + * Returns the RootMessageHandler module path. + * + * @returns {string} + */ + static get modulePath() { + return "root"; + } + + /** + * Returns the RootMessageHandler type. + * + * @returns {string} + */ + static get type() { + return "ROOT"; + } + + /** + * The ROOT MessageHandler is unique for a given MessageHandler network + * (ie for a given sessionId). Reuse the type as context id here. + */ + static getIdFromContext(context) { + return RootMessageHandler.type; + } + + /** + * Create a new RootMessageHandler instance. + * + * @param {string} sessionId + * ID of the session the handler is used for. + */ + constructor(sessionId) { + super(sessionId, null); + + this.#rootTransport = new lazy.RootTransport(this); + this.#sessionData = new lazy.SessionData(this); + this.#navigationManager = new lazy.NavigationManager(); + this.#navigationManager.startMonitoring(); + + // Map with inner window ids as keys, and sets of realm ids, assosiated with + // this window as values. + this.#realms = new Map(); + // In the general case, we don't get notified that realms got destroyed, + // because there is no communication between content and parent process at this moment, + // so we have to listen to the this notification to clean up the internal + // map and trigger the events. + Services.obs.addObserver(this, "window-global-destroyed"); + } + + get navigationManager() { + return this.#navigationManager; + } + + get realms() { + return this.#realms; + } + + get sessionData() { + return this.#sessionData; + } + + destroy() { + this.#sessionData.destroy(); + this.#navigationManager.destroy(); + + Services.obs.removeObserver(this, "window-global-destroyed"); + this.#realms = null; + + super.destroy(); + } + + /** + * Add new session data items of a given module, category and + * contextDescriptor. + * + * Forwards the call to the SessionData instance owned by this + * RootMessageHandler and propagates the information via a command to existing + * MessageHandlers. + */ + addSessionDataItem(sessionData = {}) { + sessionData.method = lazy.SessionDataMethod.Add; + return this.updateSessionData([sessionData]); + } + + emitEvent(name, eventPayload, contextInfo) { + // Intercept realm created and destroyed events to update internal map. + if (name === "realm-created") { + this.#onRealmCreated(eventPayload); + } + // We receive this events in the case of moving the page to BFCache. + if (name === "windowglobal-pagehide") { + this.#cleanUpRealmsForWindow( + eventPayload.innerWindowId, + eventPayload.context + ); + } + + super.emitEvent(name, eventPayload, contextInfo); + } + + /** + * Emit a public protocol event. This event will be sent over to the client. + * + * @param {string} name + * Name of the event. Protocol level events should be of the + * form [module name].[event name]. + * @param {object} data + * The event's data. + */ + emitProtocolEvent(name, data) { + this.emit("message-handler-protocol-event", { + name, + data, + sessionId: this.sessionId, + }); + } + + /** + * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the + * RootTransport. + * + * @param {Command} command + * The command to forward. See type definition in MessageHandler.js + * @returns {Promise} + * Returns a promise that resolves with the result of the command. + */ + forwardCommand(command) { + switch (command.destination.type) { + case lazy.WindowGlobalMessageHandler.type: + return this.#rootTransport.forwardCommand(command); + default: + throw new Error( + `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".` + ); + } + } + + matchesContext() { + return true; + } + + observe(subject, topic) { + if (topic !== "window-global-destroyed") { + return; + } + + this.#cleanUpRealmsForWindow( + subject.innerWindowId, + subject.browsingContext + ); + } + + /** + * Remove session data items of a given module, category and + * contextDescriptor. + * + * Forwards the call to the SessionData instance owned by this + * RootMessageHandler and propagates the information via a command to existing + * MessageHandlers. + */ + removeSessionDataItem(sessionData = {}) { + sessionData.method = lazy.SessionDataMethod.Remove; + return this.updateSessionData([sessionData]); + } + + /** + * Update session data items of a given module, category and + * contextDescriptor. + * + * Forwards the call to the SessionData instance owned by this + * RootMessageHandler. + */ + async updateSessionData(sessionData = []) { + await this.#sessionData.updateSessionData(sessionData); + } + + #cleanUpRealmsForWindow(innerWindowId, context) { + const realms = this.#realms.get(innerWindowId); + + if (!realms) { + return; + } + + realms.forEach(realm => { + this.#realms.get(innerWindowId).delete(realm); + + this.emitEvent("realm-destroyed", { + context, + realm, + }); + }); + + this.#realms.delete(innerWindowId); + } + + #onRealmCreated = data => { + const { innerWindowId, realmInfo } = data; + + if (!this.#realms.has(innerWindowId)) { + this.#realms.set(innerWindowId, new Set()); + } + + this.#realms.get(innerWindowId).add(realmInfo.realm); + }; +} diff --git a/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs new file mode 100644 index 0000000000..09ac489182 --- /dev/null +++ b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs @@ -0,0 +1,17 @@ +/* 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/. */ + +import { MessageHandlerRegistry } from "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"; + +import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"; + +/** + * In the parent process, only one Root MessageHandlerRegistry should ever be + * created. All consumers can safely use this singleton to retrieve the Root + * registry and from there either create or retrieve Root MessageHandler + * instances for a specific session. + */ +export var RootMessageHandlerRegistry = new MessageHandlerRegistry( + RootMessageHandler.type +); diff --git a/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs new file mode 100644 index 0000000000..584c73d72f --- /dev/null +++ b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs @@ -0,0 +1,264 @@ +/* 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/. */ + +import { + ContextDescriptorType, + MessageHandler, +} from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + getMessageHandlerFrameChildActor: + "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + WindowRealm: "chrome://remote/content/shared/Realm.sys.mjs", +}); + +/** + * A WindowGlobalMessageHandler is dedicated to debugging a single window + * global. It follows the lifecycle of the corresponding window global and will + * therefore not survive any navigation. This MessageHandler cannot forward + * commands further to other MessageHandlers and represents a leaf node in a + * MessageHandler network. + */ +export class WindowGlobalMessageHandler extends MessageHandler { + #innerWindowId; + #realms; + + constructor() { + super(...arguments); + + this.#innerWindowId = this.context.window.windowGlobalChild.innerWindowId; + + // Maps sandbox names to instances of window realms. + this.#realms = new Map(); + } + + initialize(sessionDataItems) { + // Create the default realm, it is mapped to an empty string sandbox name. + this.#realms.set("", this.#createRealm()); + + // This method, even though being async, is not awaited on purpose, + // since for now the sessionDataItems are passed in response to an event in a for loop. + this.#applyInitialSessionDataItems(sessionDataItems); + + // With the session data applied the handler is now ready to be used. + this.emitEvent("window-global-handler-created", { + contextId: this.contextId, + innerWindowId: this.#innerWindowId, + }); + } + + destroy() { + for (const realm of this.#realms.values()) { + realm.destroy(); + } + this.emitEvent("windowglobal-pagehide", { + context: this.context, + innerWindowId: this.innerWindowId, + }); + this.#realms = null; + + super.destroy(); + } + + /** + * Returns the WindowGlobalMessageHandler module path. + * + * @returns {string} + */ + static get modulePath() { + return "windowglobal"; + } + + /** + * Returns the WindowGlobalMessageHandler type. + * + * @returns {string} + */ + static get type() { + return "WINDOW_GLOBAL"; + } + + /** + * For WINDOW_GLOBAL MessageHandlers, `context` is a BrowsingContext, + * and BrowsingContext.id can be used as the context id. + * + * @param {BrowsingContext} context + * WindowGlobalMessageHandler contexts are expected to be + * BrowsingContexts. + * @returns {string} + * The browsing context id. + */ + static getIdFromContext(context) { + return context.id; + } + + get innerWindowId() { + return this.#innerWindowId; + } + + get realms() { + return this.#realms; + } + + get window() { + return this.context.window; + } + + #createRealm(sandboxName = null) { + const realm = new lazy.WindowRealm(this.context.window, { + sandboxName, + }); + + this.emitEvent("realm-created", { + realmInfo: realm.getInfo(), + innerWindowId: this.innerWindowId, + }); + + return realm; + } + + #getRealmFromSandboxName(sandboxName = null) { + if (sandboxName === null || sandboxName === "") { + return this.#realms.get(""); + } + + if (this.#realms.has(sandboxName)) { + return this.#realms.get(sandboxName); + } + + const realm = this.#createRealm(sandboxName); + + this.#realms.set(sandboxName, realm); + + return realm; + } + + async #applyInitialSessionDataItems(sessionDataItems) { + if (!Array.isArray(sessionDataItems)) { + return; + } + + const destination = { + type: WindowGlobalMessageHandler.type, + }; + + // Create a Map with the structure moduleName -> category -> relevant session data items. + const structuredUpdates = new Map(); + for (const sessionDataItem of sessionDataItems) { + const { category, contextDescriptor, moduleName } = sessionDataItem; + + if (!this.matchesContext(contextDescriptor)) { + continue; + } + if (!structuredUpdates.has(moduleName)) { + // Skip session data item if the module is not present + // for the destination. + if (!this.moduleCache.hasModuleClass(moduleName, destination)) { + continue; + } + structuredUpdates.set(moduleName, new Map()); + } + + if (!structuredUpdates.get(moduleName).has(category)) { + structuredUpdates.get(moduleName).set(category, new Set()); + } + + structuredUpdates.get(moduleName).get(category).add(sessionDataItem); + } + + const sessionDataPromises = []; + + for (const [moduleName, categories] of structuredUpdates.entries()) { + for (const [category, relevantSessionData] of categories.entries()) { + sessionDataPromises.push( + this.handleCommand({ + moduleName, + commandName: "_applySessionData", + params: { + category, + sessionData: Array.from(relevantSessionData), + }, + destination, + }) + ); + } + } + + await Promise.all(sessionDataPromises); + } + + forwardCommand(command) { + switch (command.destination.type) { + case lazy.RootMessageHandler.type: + return lazy + .getMessageHandlerFrameChildActor(this) + .sendCommand(command, this.sessionId); + default: + throw new Error( + `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".` + ); + } + } + + /** + * If <var>realmId</var> is null or not provided get the realm for + * a given <var>sandboxName</var>, otherwise find the realm + * in the cache with the realm id equal given <var>realmId</var>. + * + * @param {object} options + * @param {string|null=} options.realmId + * The realm id. + * @param {string=} options.sandboxName + * The name of sandbox + * + * @returns {Realm} + * The realm object. + */ + getRealm(options = {}) { + const { realmId = null, sandboxName } = options; + if (realmId === null) { + return this.#getRealmFromSandboxName(sandboxName); + } + + const realm = Array.from(this.#realms.values()).find( + realm => realm.id === realmId + ); + + if (realm) { + return realm; + } + + throw new lazy.error.NoSuchFrameError(`Realm with id ${realmId} not found`); + } + + matchesContext(contextDescriptor) { + return ( + contextDescriptor.type === ContextDescriptorType.All || + (contextDescriptor.type === ContextDescriptorType.TopBrowsingContext && + contextDescriptor.id === this.context.browserId) + ); + } + + /** + * Send a command to the root MessageHandler. + * + * @param {Command} command + * The command to send to the root MessageHandler. + * @returns {Promise} + * A promise which resolves with the return value of the command. + */ + sendRootCommand(command) { + return this.handleCommand({ + ...command, + destination: { + type: lazy.RootMessageHandler.type, + }, + }); + } +} diff --git a/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs new file mode 100644 index 0000000000..10da617f77 --- /dev/null +++ b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs @@ -0,0 +1,392 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * @typedef {string} SessionDataCategory + */ + +/** + * Enum of session data categories. + * + * @readonly + * @enum {SessionDataCategory} + */ +export const SessionDataCategory = { + Event: "event", + PreloadScript: "preload-script", +}; + +/** + * @typedef {string} SessionDataMethod + */ + +/** + * Enum of session data methods. + * + * @readonly + * @enum {SessionDataMethod} + */ +export const SessionDataMethod = { + Add: "add", + Remove: "remove", +}; + +export const SESSION_DATA_SHARED_DATA_KEY = "MessageHandlerSessionData"; + +// This is a map from session id to session data, which will be persisted and +// propagated to all processes using Services' sharedData. +// We have to store this as a unique object under a unique shared data key +// because new MessageHandlers in other processes will need to access this data +// without any notion of a specific session. +// This is a singleton. +const sessionDataMap = new Map(); + +/** + * @typedef {object} SessionDataItem + * @property {string} moduleName + * The name of the module responsible for this data item. + * @property {SessionDataCategory} category + * The category of data. The supported categories depend on the module. + * @property {(string|number|boolean)} value + * Value of the session data item. + * @property {ContextDescriptor} contextDescriptor + * ContextDescriptor to which this session data applies. + */ + +/** + * @typedef SessionDataItemUpdate + * @property {SessionDataMethod} method + * The way sessionData is updated. + * @property {string} moduleName + * The name of the module responsible for this data item. + * @property {SessionDataCategory} category + * The category of data. The supported categories depend on the module. + * @property {Array<(string|number|boolean)>} values + * Values of the session data item update. + * @property {ContextDescriptor} contextDescriptor + * ContextDescriptor to which this session data applies. + */ + +/** + * SessionData provides APIs to read and write the session data for a specific + * ROOT message handler. It holds the session data as a property and acts as the + * source of truth for this session data. + * + * The session data of a given message handler network should contain all the + * information that might be needed to setup new contexts, for instance a list + * of subscribed events, a list of breakpoints etc. + * + * The actual session data is an array of SessionDataItems. Example below: + * ``` + * data: [ + * { + * moduleName: "log", + * category: "event", + * value: "log.entryAdded", + * contextDescriptor: { type: "all" } + * }, + * { + * moduleName: "browsingContext", + * category: "event", + * value: "browsingContext.contextCreated", + * contextDescriptor: { type: "browser-element", id: "7"} + * }, + * { + * moduleName: "browsingContext", + * category: "event", + * value: "browsingContext.contextCreated", + * contextDescriptor: { type: "browser-element", id: "12"} + * }, + * ] + * ``` + * + * The session data will be persisted using Services.ppmm.sharedData, so that + * new contexts living in different processes can also access the information + * during their startup. + * + * This class should only be used from a ROOT MessageHandler, or from modules + * owned by a ROOT MessageHandler. Other MessageHandlers should rely on + * SessionDataReader's readSessionData to get read-only access to session data. + * + */ +export class SessionData { + constructor(messageHandler) { + if (messageHandler.constructor.type != lazy.RootMessageHandler.type) { + throw new Error( + "SessionData should only be used from a ROOT MessageHandler" + ); + } + + this._messageHandler = messageHandler; + + /* + * The actual data for this session. This is an array of SessionDataItems. + */ + this._data = []; + } + + destroy() { + // Update the sessionDataMap singleton. + sessionDataMap.delete(this._messageHandler.sessionId); + + // Update sharedData and flush to force consistency. + Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap); + Services.ppmm.sharedData.flush(); + } + + /** + * Update session data items of a given module, category and + * contextDescriptor. + * + * A SessionDataItem will be added or removed for each value of each update + * in the provided array. + * + * Attempting to add a duplicate SessionDataItem or to remove an unknown + * SessionDataItem will be silently skipped (no-op). + * + * The data will be persisted across processes at the end of this method. + * + * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates + * Array of session data item updates. + * + * @returns {Array<SessionDataItemUpdate>} + * The subset of session data item updates which want to be applied. + */ + applySessionData(sessionDataItemUpdates = []) { + // The subset of session data item updates, which are cleaned up from + // duplicates and unknown items. + let updates = []; + for (const sessionDataItemUpdate of sessionDataItemUpdates) { + const { category, contextDescriptor, method, moduleName, values } = + sessionDataItemUpdate; + const updatedValues = []; + for (const value of values) { + const item = { moduleName, category, contextDescriptor, value }; + + if (method === SessionDataMethod.Add) { + const hasItem = this._findIndex(item) != -1; + + if (!hasItem) { + this._data.push(item); + updatedValues.push(value); + } else { + lazy.logger.warn( + `Duplicated session data item was not added: ${JSON.stringify( + item + )}` + ); + } + } else { + const itemIndex = this._findIndex(item); + + if (itemIndex != -1) { + // The item was found in the session data, remove it. + this._data.splice(itemIndex, 1); + updatedValues.push(value); + } else { + lazy.logger.warn( + `Missing session data item was not removed: ${JSON.stringify( + item + )}` + ); + } + } + } + + if (updatedValues.length) { + updates.push({ + ...sessionDataItemUpdate, + values: updatedValues, + }); + } + } + // Persist the sessionDataMap. + this._persist(); + + return updates; + } + + /** + * Retrieve the SessionDataItems for a given module and type. + * + * @param {string} moduleName + * The name of the module responsible for this data item. + * @param {string} category + * The session data category. + * @param {ContextDescriptor=} contextDescriptor + * Optional context descriptor, to retrieve only session data items added + * for a specific context descriptor. + * @returns {Array<SessionDataItem>} + * Array of SessionDataItems for the provided module and type. + */ + getSessionData(moduleName, category, contextDescriptor) { + return this._data.filter( + item => + item.moduleName === moduleName && + item.category === category && + (!contextDescriptor || + this._isSameContextDescriptor( + item.contextDescriptor, + contextDescriptor + )) + ); + } + + /** + * Update session data items of a given module, category and + * contextDescriptor and propagate the information + * via a command to existing MessageHandlers. + * + * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates + * Array of session data item updates. + */ + async updateSessionData(sessionDataItemUpdates = []) { + const updates = this.applySessionData(sessionDataItemUpdates); + + if (!updates.length) { + // Avoid unnecessary broadcast if no items were updated. + return; + } + + // Create a Map with the structure moduleName -> category -> list of descriptors. + const structuredUpdates = new Map(); + for (const { moduleName, category, contextDescriptor } of updates) { + if (!structuredUpdates.has(moduleName)) { + structuredUpdates.set(moduleName, new Map()); + } + if (!structuredUpdates.get(moduleName).has(category)) { + structuredUpdates.get(moduleName).set(category, new Set()); + } + const descriptors = structuredUpdates.get(moduleName).get(category); + // If there is at least one update for all contexts, + // keep only this descriptor in the list of descriptors + if (contextDescriptor.type === lazy.ContextDescriptorType.All) { + structuredUpdates + .get(moduleName) + .set(category, new Set([contextDescriptor])); + } + // Add an individual descriptor if there is no descriptor for all contexts. + else if ( + descriptors.size !== 1 || + Array.from(descriptors)[0]?.type !== lazy.ContextDescriptorType.All + ) { + descriptors.add(contextDescriptor); + } + } + + const rootDestination = { + type: lazy.RootMessageHandler.type, + }; + const sessionDataPromises = []; + + for (const [moduleName, categories] of structuredUpdates.entries()) { + for (const [category, contextDescriptors] of categories.entries()) { + // Find sessionData for the category and the moduleName. + const relevantSessionData = this._data.filter( + item => item.category == category && item.moduleName === moduleName + ); + for (const contextDescriptor of contextDescriptors.values()) { + const windowGlobalDestination = { + type: lazy.WindowGlobalMessageHandler.type, + contextDescriptor, + }; + + for (const destination of [ + windowGlobalDestination, + rootDestination, + ]) { + // Only apply session data if the module is present for the destination. + if ( + this._messageHandler.supportsCommand( + moduleName, + "_applySessionData", + destination + ) + ) { + sessionDataPromises.push( + this._messageHandler + .handleCommand({ + moduleName, + commandName: "_applySessionData", + params: { + sessionData: relevantSessionData, + category, + contextDescriptor, + }, + destination, + }) + ?.catch(reason => + lazy.logger.error( + `_applySessionData for module: ${moduleName} failed, reason: ${reason}` + ) + ) + ); + } + } + } + } + } + + await Promise.allSettled(sessionDataPromises); + } + + _isSameItem(item1, item2) { + const descriptor1 = item1.contextDescriptor; + const descriptor2 = item2.contextDescriptor; + + return ( + item1.moduleName === item2.moduleName && + item1.category === item2.category && + this._isSameContextDescriptor(descriptor1, descriptor2) && + this._isSameValue(item1.category, item1.value, item2.value) + ); + } + + _isSameContextDescriptor(contextDescriptor1, contextDescriptor2) { + if (contextDescriptor1.type === lazy.ContextDescriptorType.All) { + // Ignore the id for type "all" since we made the id optional for this type. + return contextDescriptor1.type === contextDescriptor2.type; + } + + return ( + contextDescriptor1.type === contextDescriptor2.type && + contextDescriptor1.id === contextDescriptor2.id + ); + } + + _isSameValue(category, value1, value2) { + if (category === SessionDataCategory.PreloadScript) { + return value1.script === value2.script; + } + + return value1 === value2; + } + + _findIndex(item) { + return this._data.findIndex(_item => this._isSameItem(item, _item)); + } + + _persist() { + // Update the sessionDataMap singleton. + sessionDataMap.set(this._messageHandler.sessionId, this._data); + + // Update sharedData and flush to force consistency. + Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap); + Services.ppmm.sharedData.flush(); + } +} diff --git a/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs new file mode 100644 index 0000000000..6d5ea08e59 --- /dev/null +++ b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs @@ -0,0 +1,27 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SESSION_DATA_SHARED_DATA_KEY: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "sharedData", () => { + const isInParent = + Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + + return isInParent ? Services.ppmm.sharedData : Services.cpmm.sharedData; +}); + +/** + * Returns a snapshot of the session data map, which is cloned from the + * sessionDataMap singleton of SessionData.jsm. + * + * @returns {Map.<string, Array<SessionDataItem>>} + * Map of session id to arrays of SessionDataItems. + */ +export const readSessionData = () => + lazy.sharedData.get(lazy.SESSION_DATA_SHARED_DATA_KEY) || new Map(); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser.toml b/remote/shared/messagehandler/test/browser/broadcast/browser.toml new file mode 100644 index 0000000000..f18bfdaab2 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser.toml @@ -0,0 +1,22 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = [ + "doc_messagehandler_broadcasting_xul.xhtml", + "head.js", + "!/remote/shared/messagehandler/test/browser/head.js", + "!/remote/shared/messagehandler/test/browser/resources/*" +] +prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"] + +["browser_filter_top_browsing_context.js"] + +["browser_only_content_process.js"] + +["browser_two_tabs.js"] + +["browser_two_tabs_with_params.js"] + +["browser_two_windows.js"] + +["browser_with_frames.js"] diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js new file mode 100644 index 0000000000..c140c26fc6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const COM_TEST_PAGE = "https://example.com/document-builder.sjs?html=COM"; +const FRAME_TEST_PAGE = createTestMarkupWithFrames(); + +add_task(async function test_broadcasting_filter_top_browsing_context() { + info("Navigate the initial tab to the COM test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, COM_TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + info("Open a second tab on the frame test URL"); + const tab2 = await addTab(FRAME_TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const contextsForTab2 = + tab2.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + is( + contextsForTab2.length, + 4, + "Frame test tab has 3 children contexts (4 in total)" + ); + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_filter_top_browsing_context" + ); + + const broadcastValue1 = await sendBroadcastForTopBrowsingContext( + browsingContext1, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue1), + "The broadcast returned an array of values" + ); + + is(broadcastValue1.length, 1, "The broadcast returned one value as expected"); + + ok( + broadcastValue1.includes("broadcast-" + browsingContext1.id), + "The broadcast returned the expected value from tab1" + ); + + const broadcastValue2 = await sendBroadcastForTopBrowsingContext( + browsingContext2, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue2), + "The broadcast returned an array of values" + ); + + is(broadcastValue2.length, 4, "The broadcast returned 4 values as expected"); + + for (const context of contextsForTab2) { + ok( + broadcastValue2.includes("broadcast-" + context.id), + "The broadcast contains the value for browsing context " + context.id + ); + } + + rootMessageHandler.destroy(); +}); + +function sendBroadcastForTopBrowsingContext( + topBrowsingContext, + rootMessageHandler +) { + return sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + { + type: ContextDescriptorType.TopBrowsingContext, + id: topBrowsingContext.browserId, + }, + rootMessageHandler + ); +} diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js new file mode 100644 index 0000000000..d5090c701e --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_broadcasting_only_content_process() { + info("Navigate the initial tab to the test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL( + tab1.linkedBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + info("Open a new tab on a parent process about: page"); + await addTab("about:robots"); + + info("Open a new tab on a XUL page"); + await addTab( + getRootDirectory(gTestPath) + "doc_messagehandler_broadcasting_xul.xhtml" + ); + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_only_content_process" + ); + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + contextDescriptorAll, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + + is(broadcastValue.length, 1, "The broadcast returned 1 value as expected"); + ok( + broadcastValue.includes("broadcast-" + browsingContext1.id), + "The broadcast returned the expected value from tab1" + ); + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js new file mode 100644 index 0000000000..16b97e2a0a --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_broadcasting_two_tabs_command() { + info("Navigate the initial tab to the test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + info("Open a new tab on the same test URL"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_two_tabs_command" + ); + + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + contextDescriptorAll, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + + is(broadcastValue.length, 2, "The broadcast returned 2 values as expected"); + + ok( + broadcastValue.includes("broadcast-" + browsingContext1.id), + "The broadcast returned the expected value from tab1" + ); + ok( + broadcastValue.includes("broadcast-" + browsingContext2.id), + "The broadcast returned the expected value from tab2" + ); + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js new file mode 100644 index 0000000000..261b8c4cd6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_broadcasting_two_tabs_with_params_command() { + info("Navigate the initial tab to the test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + info("Open a new tab on the same test URL"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_two_tabs_command" + ); + + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcastWithParameter", + { + value: "some-value", + }, + contextDescriptorAll, + rootMessageHandler + ); + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + + is(broadcastValue.length, 2, "The broadcast returned 2 values as expected"); + + ok( + broadcastValue.includes("broadcast-" + browsingContext1.id + "-some-value"), + "The broadcast returned the expected value from tab1" + ); + ok( + broadcastValue.includes("broadcast-" + browsingContext2.id + "-some-value"), + "The broadcast returned the expected value from tab2" + ); + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js new file mode 100644 index 0000000000..f59bebba69 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_broadcasting_two_windows_command() { + const window1Browser = gBrowser.selectedTab.linkedBrowser; + await loadURL(window1Browser, TEST_PAGE); + const browsingContext1 = window1Browser.browsingContext; + + const window2 = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(() => BrowserTestUtils.closeWindow(window2)); + + const window2Browser = window2.gBrowser.selectedBrowser; + await loadURL(window2Browser, TEST_PAGE); + const browsingContext2 = window2Browser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_two_windows_command" + ); + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + contextDescriptorAll, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + is(broadcastValue.length, 2, "The broadcast returned 2 values as expected"); + + ok( + broadcastValue.includes("broadcast-" + browsingContext1.id), + "The broadcast returned the expected value from tab1" + ); + ok( + broadcastValue.includes("broadcast-" + browsingContext2.id), + "The broadcast returned the expected value from tab2" + ); + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js new file mode 100644 index 0000000000..50326d3885 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_broadcasting_with_frames() { + info("Navigate the initial tab to the test URL"); + const tab = gBrowser.selectedTab; + await loadURL(tab.linkedBrowser, createTestMarkupWithFrames()); + + const contexts = + tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)"); + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_with_frames" + ); + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + contextDescriptorAll, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + is(broadcastValue.length, 4, "The broadcast returned 4 values as expected"); + + for (const context of contexts) { + ok( + broadcastValue.includes("broadcast-" + context.id), + "The broadcast contains the value for browsing context " + context.id + ); + } + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml new file mode 100644 index 0000000000..91f3503ac3 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml @@ -0,0 +1,3 @@ +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <box id="box" style="background-color: red;">Test chrome broadcasting</box> +</window> diff --git a/remote/shared/messagehandler/test/browser/broadcast/head.js b/remote/shared/messagehandler/test/browser/broadcast/head.js new file mode 100644 index 0000000000..eb97549c26 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/head.js @@ -0,0 +1,48 @@ +/* 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"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/head.js", + this +); + +/** + * Broadcast the provided method to WindowGlobal contexts on a MessageHandler + * network. + * Returns a promise which will resolve the result of the command broadcast. + * + * @param {string} module + * The name of the module implementing the command to broadcast. + * @param {string} command + * The name of the command to broadcast. + * @param {object} params + * The parameters for the command. + * @param {ContextDescriptor} contextDescriptor + * The context descriptor to use for this broadcast + * @param {RootMessageHandler} rootMessageHandler + * The root of the MessageHandler network. + * @returns {Promise.<Array>} + * Promise which resolves an array where each item is the result of the + * command handled by an individual context. + */ +function sendTestBroadcastCommand( + module, + command, + params, + contextDescriptor, + rootMessageHandler +) { + info("Send a test broadcast command"); + return rootMessageHandler.handleCommand({ + moduleName: module, + commandName: command, + params, + destination: { + contextDescriptor, + type: WindowGlobalMessageHandler.type, + }, + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser.toml b/remote/shared/messagehandler/test/browser/browser.toml new file mode 100644 index 0000000000..ffbc880a0a --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser.toml @@ -0,0 +1,46 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = [ + "head.js", + "resources/*" +] +prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"] + +["browser_bfcache.js"] + +["browser_events_dispatcher.js"] + +["browser_events_handler.js"] + +["browser_events_interception.js"] + +["browser_events_module.js"] + +["browser_frame_context_utils.js"] + +["browser_handle_command_errors.js"] + +["browser_handle_command_retry.js"] + +["browser_handle_simple_command.js"] + +["browser_navigation_manager.js"] + +["browser_realms.js"] + +["browser_registry.js"] + +["browser_session_data.js"] + +["browser_session_data_browser_element.js"] + +["browser_session_data_constructor_race.js"] + +["browser_session_data_update.js"] + +["browser_session_data_update_categories.js"] + +["browser_session_data_update_contexts.js"] + +["browser_windowglobal_to_root.js"] diff --git a/remote/shared/messagehandler/test/browser/browser_bfcache.js b/remote/shared/messagehandler/test/browser/browser_bfcache.js new file mode 100644 index 0000000000..f829d8b58d --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_bfcache.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +const TEST_PREF = "remote.messagehandler.test.pref"; + +// Check that pages in bfcache no longer have message handlers attached to them, +// and that they will not emit unexpected events. +add_task(async function test_bfcache_broadcast() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const rootMessageHandler = createRootMessageHandler("session-id-bfcache"); + + try { + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + // Whenever a "preference-changed" event from the eventonprefchange module + // will be received on the root MessageHandler, increment a counter. + let preferenceChangeEventCount = 0; + const onEvent = (evtName, wrappedEvt) => { + if (wrappedEvt.name === "preference-changed") { + preferenceChangeEventCount++; + } + }; + rootMessageHandler.on("message-handler-event", onEvent); + + // Initialize the preference, no eventonprefchange module should be created + // yet so preferenceChangeEventCount is not expected to be updated. + Services.prefs.setIntPref(TEST_PREF, 0); + await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 0); + is(preferenceChangeEventCount, 0); + + // Broadcast a "ping" command to force the creation of the eventonprefchange + // module + let values = await sendPingCommand(rootMessageHandler, contextDescriptor); + is(values.length, 1, "Broadcast returned a single value"); + + Services.prefs.setIntPref(TEST_PREF, 1); + await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 1); + is(preferenceChangeEventCount, 1); + + info("Navigate to another page"); + await loadURL( + tab.linkedBrowser, + "https://example.com/document-builder.sjs?html=othertab" + ); + + values = await sendPingCommand(rootMessageHandler, contextDescriptor); + is(values.length, 1, "Broadcast returned a single value after navigation"); + + info("Update the preference and check we only receive 1 event"); + Services.prefs.setIntPref(TEST_PREF, 2); + await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 2); + is(preferenceChangeEventCount, 2); + + info("Navigate to another origin"); + await loadURL( + tab.linkedBrowser, + "https://example.org/document-builder.sjs?html=otherorigin" + ); + + values = await sendPingCommand(rootMessageHandler, contextDescriptor); + is( + values.length, + 1, + "Broadcast returned a single value after cross origin navigation" + ); + + info("Update the preference and check again that we only receive 1 event"); + Services.prefs.setIntPref(TEST_PREF, 3); + await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 3); + is(preferenceChangeEventCount, 3); + } finally { + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); + Services.prefs.clearUserPref(TEST_PREF); + } +}); + +function sendPingCommand(rootMessageHandler, contextDescriptor) { + return rootMessageHandler.handleCommand({ + moduleName: "eventonprefchange", + commandName: "ping", + params: {}, + destination: { + contextDescriptor, + type: WindowGlobalMessageHandler.type, + }, + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js new file mode 100644 index 0000000000..98d9fd2890 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js @@ -0,0 +1,532 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check the basic behavior of on/off. + */ +add_task(async function test_add_remove_event_listener() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const root = createRootMessageHandler("session-id-event"); + const monitoringEvents = await setupEventMonitoring(root); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(await isSubscribed(root, browsingContext), false); + + info("Add an listener for eventemitter.testEvent"); + const events = []; + const onEvent = (event, data) => events.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 1); + + info( + "Remove a listener for a callback not added before and check that the first one is still registered" + ); + const anotherCallback = () => {}; + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + anotherCallback + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 2); + + info("Remove the listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), false); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 2); + + info("Add the listener for eventemitter.testEvent again"); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 3); + + info("Remove the listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), false); + + info("Remove the listener again to check the API will not throw"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + + root.destroy(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_has_listener() { + const tab1 = await addTab("https://example.com/document-builder.sjs?html=1"); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const tab2 = await addTab("https://example.com/document-builder.sjs?html=2"); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const contextDescriptor1 = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }; + const contextDescriptor2 = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }; + + const root = createRootMessageHandler("session-id-event"); + + // Shortcut for the EventsDispatcher.hasListener API. + function hasListener(contextId) { + return root.eventsDispatcher.hasListener("eventemitter.testEvent", { + contextId, + }); + } + + const onEvent = () => {}; + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor1, + onEvent + ); + ok(hasListener(browsingContext1.id), "Has a listener for browsingContext1"); + ok(!hasListener(browsingContext2.id), "No listener for browsingContext2"); + + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor2, + onEvent + ); + ok(hasListener(browsingContext1.id), "Still a listener for browsingContext1"); + ok(hasListener(browsingContext2.id), "Has a listener for browsingContext2"); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor1, + onEvent + ); + ok(!hasListener(browsingContext1.id), "No listener for browsingContext1"); + ok(hasListener(browsingContext2.id), "Still a listener for browsingContext2"); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor2, + onEvent + ); + ok(!hasListener(browsingContext1.id), "No listener for browsingContext1"); + ok(!hasListener(browsingContext2.id), "No listener for browsingContext2"); + + await root.eventsDispatcher.on( + "eventemitter.testEvent", + { + type: ContextDescriptorType.All, + }, + onEvent + ); + ok(hasListener(browsingContext1.id), "Has a listener for browsingContext1"); + ok(hasListener(browsingContext2.id), "Has a listener for browsingContext2"); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + { + type: ContextDescriptorType.All, + }, + onEvent + ); + ok(!hasListener(browsingContext1.id), "No listener for browsingContext1"); + ok(!hasListener(browsingContext2.id), "No listener for browsingContext2"); + + root.destroy(); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab1); +}); + +/** + * Check that two callbacks can subscribe to the same event in the same context + * in parallel. + */ +add_task(async function test_two_callbacks() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const root = createRootMessageHandler("session-id-event"); + const monitoringEvents = await setupEventMonitoring(root); + + info("Add an listener for eventemitter.testEvent"); + const events = []; + const onEvent = (event, data) => events.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 1); + + info("Add another listener for eventemitter.testEvent"); + const otherevents = []; + const otherCallback = (event, data) => otherevents.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + otherCallback + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 2); + is(otherevents.length, 1); + + info("Remove the other listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + otherCallback + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 3); + is(otherevents.length, 1); + + info("Remove the first listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), false); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 3); + is(otherevents.length, 1); + + root.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Check that two callbacks can subscribe to the same event in the two contexts. + */ +add_task(async function test_two_contexts() { + const tab1 = await addTab("https://example.com/document-builder.sjs?html=1"); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const tab2 = await addTab("https://example.com/document-builder.sjs?html=2"); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const contextDescriptor1 = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }; + const contextDescriptor2 = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }; + + const root = createRootMessageHandler("session-id-event"); + + const monitoringEvents = await setupEventMonitoring(root); + + const events1 = []; + const onEvent1 = (event, data) => events1.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor1, + onEvent1 + ); + is(await isSubscribed(root, browsingContext1), true); + is(await isSubscribed(root, browsingContext2), false); + + const events2 = []; + const onEvent2 = (event, data) => events2.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor2, + onEvent2 + ); + is(await isSubscribed(root, browsingContext1), true); + is(await isSubscribed(root, browsingContext2), true); + + await emitTestEvent(root, browsingContext1, monitoringEvents); + is(events1.length, 1); + is(events2.length, 0); + + await emitTestEvent(root, browsingContext2, monitoringEvents); + is(events1.length, 1); + is(events2.length, 1); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor1, + onEvent1 + ); + is(await isSubscribed(root, browsingContext1), false); + is(await isSubscribed(root, browsingContext2), true); + + // No event expected here since the module for browsingContext1 is no longer + // subscribed + await emitTestEvent(root, browsingContext1, monitoringEvents); + is(events1.length, 1); + is(events2.length, 1); + + // Whereas the module for browsingContext2 is still subscribed + await emitTestEvent(root, browsingContext2, monitoringEvents); + is(events1.length, 1); + is(events2.length, 2); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor2, + onEvent2 + ); + is(await isSubscribed(root, browsingContext1), false); + is(await isSubscribed(root, browsingContext2), false); + + await emitTestEvent(root, browsingContext1, monitoringEvents); + await emitTestEvent(root, browsingContext2, monitoringEvents); + is(events1.length, 1); + is(events2.length, 2); + + root.destroy(); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab1); +}); + +/** + * Check that adding and removing first listener for the specific context and then + * for the global context works as expected. + */ +add_task( + async function test_remove_context_event_listener_and_then_global_event_listener() { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=tab" + ); + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + const contextDescriptorAll = { + type: ContextDescriptorType.All, + }; + + const root = createRootMessageHandler("session-id-event"); + const monitoringEvents = await setupEventMonitoring(root); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(await isSubscribed(root, browsingContext), false); + + info("Add an listener for eventemitter.testEvent"); + const events = []; + const onEvent = (event, data) => events.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 1); + + info( + "Add another listener for eventemitter.testEvent, using global context" + ); + const eventsAll = []; + const onEventAll = (event, data) => eventsAll.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptorAll, + onEventAll + ); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 1); + is(events.length, 2); + + info("Remove the first listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + + info("Check that we are still subscribed to eventemitter.testEvent"); + is(await isSubscribed(root, browsingContext), true); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 2); + is(events.length, 2); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptorAll, + onEventAll + ); + is(await isSubscribed(root, browsingContext), false); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 2); + is(events.length, 2); + + root.destroy(); + gBrowser.removeTab(tab); + } +); + +/** + * Check that adding and removing first listener for the global context and then + * for the specific context works as expected. + */ +add_task( + async function test_global_event_listener_and_then_remove_context_event_listener() { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=tab" + ); + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + const contextDescriptorAll = { + type: ContextDescriptorType.All, + }; + + const root = createRootMessageHandler("session-id-event"); + const monitoringEvents = await setupEventMonitoring(root); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(await isSubscribed(root, browsingContext), false); + + info("Add an listener for eventemitter.testEvent"); + const events = []; + const onEvent = (event, data) => events.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 1); + + info( + "Add another listener for eventemitter.testEvent, using global context" + ); + const eventsAll = []; + const onEventAll = (event, data) => eventsAll.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptorAll, + onEventAll + ); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 1); + is(events.length, 2); + + info("Remove the global listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptorAll, + onEventAll + ); + + info( + "Check that we are still subscribed to eventemitter.testEvent for the specific context" + ); + is(await isSubscribed(root, browsingContext), true); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 1); + is(events.length, 3); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + + is(await isSubscribed(root, browsingContext), false); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 1); + is(events.length, 3); + + root.destroy(); + gBrowser.removeTab(tab); + } +); + +async function setupEventMonitoring(root) { + const monitoringEvents = []; + const onMonitoringEvent = (event, data) => monitoringEvents.push(data.text); + root.on("eventemitter.monitoringEvent", onMonitoringEvent); + + registerCleanupFunction(() => + root.off("eventemitter.monitoringEvent", onMonitoringEvent) + ); + + return monitoringEvents; +} + +async function emitTestEvent(root, browsingContext, monitoringEvents) { + const count = monitoringEvents.length; + info("Call eventemitter.emitTestEvent"); + await root.handleCommand({ + moduleName: "eventemitter", + commandName: "emitTestEvent", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + // The monitoring event is always emitted, regardless of the status of the + // module. Wait for catching this event before resuming the assertions. + info("Wait for the monitoring event"); + await BrowserTestUtils.waitForCondition( + () => monitoringEvents.length >= count + 1 + ); + is(monitoringEvents.length, count + 1); +} + +function isSubscribed(root, browsingContext) { + info("Call eventemitter.isSubscribed"); + return root.handleCommand({ + moduleName: "eventemitter", + commandName: "isSubscribed", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser_events_handler.js b/remote/shared/messagehandler/test/browser/browser_events_handler.js new file mode 100644 index 0000000000..705c306de3 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_handler.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that the window-global-handler-created event gets emitted for each + * individual frame's browsing context. + */ +add_task(async function test_windowGlobalHandlerCreated() { + const events = []; + + const rootMessageHandler = createRootMessageHandler( + "session-id-event_with_frames" + ); + + info("Add a new session data item to get window global handlers created"); + await rootMessageHandler.addSessionDataItem({ + moduleName: "command", + category: "browser_session_data_browser_element", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: [true], + }); + + const onEvent = (evtName, wrappedEvt) => { + if (wrappedEvt.name === "window-global-handler-created") { + console.info(`Received event for context ${wrappedEvt.data.contextId}`); + events.push(wrappedEvt.data); + } + }; + rootMessageHandler.on("message-handler-event", onEvent); + + info("Navigate the initial tab to the test URL"); + const browser = gBrowser.selectedTab.linkedBrowser; + await loadURL(browser, createTestMarkupWithFrames()); + + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)"); + + // Wait for all the events + await TestUtils.waitForCondition(() => events.length >= 4); + + for (const context of contexts) { + const contextEvents = events.filter(evt => { + return ( + evt.contextId === context.id && + evt.innerWindowId === context.currentWindowGlobal.innerWindowId + ); + }); + is(contextEvents.length, 1, `Found event for context ${context.id}`); + } + + rootMessageHandler.off("message-handler-event", onEvent); + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_events_interception.js b/remote/shared/messagehandler/test/browser/browser_events_interception.js new file mode 100644 index 0000000000..aaf39353a6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_interception.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +/** + * Test that events can be intercepted in the windowglobal-in-root layer. + */ +add_task(async function test_intercepted_event() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-intercepted_event" + ); + + const onInterceptedEvent = rootMessageHandler.once( + "event.testEventWithInterception" + ); + rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitEventWithInterception", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + const interceptedEvent = await onInterceptedEvent; + is( + interceptedEvent.additionalInformation, + "information added through interception", + "Intercepted event contained additional information" + ); + + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Test that events can be canceled in the windowglobal-in-root layer. + */ +add_task(async function test_cancelable_event() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-cancelable_event" + ); + + const cancelableEvents = []; + const onCancelableEvent = (name, event) => cancelableEvents.push(event); + rootMessageHandler.on( + "event.testEventCancelableWithInterception", + onCancelableEvent + ); + + // Emit an event that should be canceled in the windowglobal-in-root layer. + // Note that `shouldCancel` is only something supported for this test event, + // and not a general message handler mechanism to cancel events. + await rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitEventCancelableWithInterception", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + shouldCancel: true, + }, + }); + + is(cancelableEvents.length, 0, "No event was received"); + + // Emit another event which should not be canceled (shouldCancel: false). + await rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitEventCancelableWithInterception", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + shouldCancel: false, + }, + }); + + await TestUtils.waitForCondition(() => cancelableEvents.length == 1); + is(cancelableEvents[0].shouldCancel, false, "Expected event was received"); + + rootMessageHandler.off( + "event.testEventCancelableWithInterception", + onCancelableEvent + ); + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_events_module.js b/remote/shared/messagehandler/test/browser/browser_events_module.js new file mode 100644 index 0000000000..32b60d34b1 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_module.js @@ -0,0 +1,296 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +/** + * Emit an event from a WindowGlobal module triggered by a specific command. + * Check that the event is emitted on the RootMessageHandler as well as on + * the parent process MessageHandlerRegistry. + */ +add_task(async function test_event() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler("session-id-event"); + let messageHandlerEvent; + let registryEvent; + + // Events are emitted both as generic message-handler-event events as well + // as under their own name. We expect to receive the event for both. + const _onMessageHandlerEvent = (eventName, eventData) => { + if (eventData.name === "event-from-window-global") { + messageHandlerEvent = eventData; + } + }; + rootMessageHandler.on("message-handler-event", _onMessageHandlerEvent); + const onNamedEvent = rootMessageHandler.once("event-from-window-global"); + // MessageHandlerRegistry should forward all the message-handler-events. + const _onMessageHandlerRegistryEvent = (eventName, eventData) => { + if (eventData.name === "event-from-window-global") { + registryEvent = eventData; + } + }; + RootMessageHandlerRegistry.on( + "message-handler-registry-event", + _onMessageHandlerRegistryEvent + ); + + callTestEmitEvent(rootMessageHandler, browsingContext.id); + + const namedEvent = await onNamedEvent; + is( + namedEvent.text, + `event from ${browsingContext.id}`, + "Received the expected payload" + ); + + is( + messageHandlerEvent.name, + "event-from-window-global", + "Received event on the ROOT MessageHandler" + ); + is( + messageHandlerEvent.data.text, + `event from ${browsingContext.id}`, + "Received the expected payload" + ); + + is( + registryEvent, + messageHandlerEvent, + "The event forwarded by the MessageHandlerRegistry is identical to the MessageHandler event" + ); + rootMessageHandler.off("message-handler-event", _onMessageHandlerEvent); + RootMessageHandlerRegistry.off( + "message-handler-registry-event", + _onMessageHandlerRegistryEvent + ); + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Emit an event from a Root module triggered by a specific command. + * Check that the event is emitted on the RootMessageHandler. + */ +add_task(async function test_root_event() { + const rootMessageHandler = createRootMessageHandler("session-id-root_event"); + + // events are emitted both as generic message-handler-event events as + // well as under their own name. We expect to receive the event for both. + const onHandlerEvent = rootMessageHandler.once("message-handler-event"); + const onNamedEvent = rootMessageHandler.once("event-from-root"); + + rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitRootEvent", + destination: { + type: RootMessageHandler.type, + }, + }); + + const { name, data } = await onHandlerEvent; + is(name, "event-from-root", "Received event on the ROOT MessageHandler"); + is(data.text, "event from root", "Received the expected payload"); + + const namedEvent = await onNamedEvent; + is(namedEvent.text, "event from root", "Received the expected payload"); + + rootMessageHandler.destroy(); +}); + +/** + * Emit an event from a windowglobal-in-root module triggered by a specific command. + * Check that the event is emitted on the RootMessageHandler. + */ +add_task(async function test_windowglobal_in_root_event() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobal_in_root_event" + ); + + // events are emitted both as generic message-handler-event events as + // well as under their own name. We expect to receive the event for both. + const onHandlerEvent = rootMessageHandler.once("message-handler-event"); + const onNamedEvent = rootMessageHandler.once( + "event-from-window-global-in-root" + ); + rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitWindowGlobalInRootEvent", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + const { name, data } = await onHandlerEvent; + is( + name, + "event-from-window-global-in-root", + "Received event on the ROOT MessageHandler" + ); + is( + data.text, + `windowglobal-in-root event for ${browsingContext.id}`, + "Received the expected payload" + ); + + const namedEvent = await onNamedEvent; + is( + namedEvent.text, + `windowglobal-in-root event for ${browsingContext.id}`, + "Received the expected payload" + ); + + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Emit an event from a windowglobal module, but from 2 different sessions. + * Check that the event is emitted by the corresponding RootMessageHandler as + * well as by the parent process MessageHandlerRegistry. + */ +add_task(async function test_event_multisession() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContextId = tab.linkedBrowser.browsingContext.id; + + const root1 = createRootMessageHandler("session-id-event_multisession-1"); + let root1Events = 0; + const onRoot1Event = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "event-from-window-global") { + root1Events++; + } + }; + root1.on("message-handler-event", onRoot1Event); + + const root2 = createRootMessageHandler("session-id-event_multisession-2"); + let root2Events = 0; + const onRoot2Event = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "event-from-window-global") { + root2Events++; + } + }; + root2.on("message-handler-event", onRoot2Event); + + let registryEvents = 0; + const onRegistryEvent = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "event-from-window-global") { + registryEvents++; + } + }; + RootMessageHandlerRegistry.on( + "message-handler-registry-event", + onRegistryEvent + ); + + callTestEmitEvent(root1, browsingContextId); + callTestEmitEvent(root2, browsingContextId); + + info("Wait for root1 event to be received"); + await TestUtils.waitForCondition(() => root1Events === 1); + info("Wait for root2 event to be received"); + await TestUtils.waitForCondition(() => root2Events === 1); + + await TestUtils.waitForTick(); + is(root1Events, 1, "Session 1 only received 1 event"); + is(root2Events, 1, "Session 2 only received 1 event"); + is( + registryEvents, + 2, + "MessageHandlerRegistry forwarded events from both sessions" + ); + + root1.off("message-handler-event", onRoot1Event); + root2.off("message-handler-event", onRoot2Event); + RootMessageHandlerRegistry.off( + "message-handler-registry-event", + onRegistryEvent + ); + root1.destroy(); + root2.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Test that events can be emitted from individual frame contexts and that + * events going through a shared content process MessageHandlerRegistry are not + * duplicated. + */ +add_task(async function test_event_with_frames() { + info("Navigate the initial tab to the test URL"); + const tab = gBrowser.selectedTab; + await loadURL(tab.linkedBrowser, createTestMarkupWithFrames()); + + const contexts = + tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)"); + + const rootMessageHandler = createRootMessageHandler( + "session-id-event_with_frames" + ); + + const rootEvents = []; + const onRootEvent = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "event-from-window-global") { + rootEvents.push(wrappedEvt.data.text); + } + }; + rootMessageHandler.on("message-handler-event", onRootEvent); + + const namedEvents = []; + const onNamedEvent = (name, event) => namedEvents.push(event.text); + rootMessageHandler.on("event-from-window-global", onNamedEvent); + + for (const context of contexts) { + callTestEmitEvent(rootMessageHandler, context.id); + info("Wait for root event to be received in both event arrays"); + await TestUtils.waitForCondition(() => + [namedEvents, rootEvents].every(events => + events.includes(`event from ${context.id}`) + ) + ); + } + + info("Wait for a bit and check that we did not receive duplicated events"); + await TestUtils.waitForTick(); + is(rootEvents.length, 4, "Only received 4 events"); + + rootMessageHandler.off("message-handler-event", onRootEvent); + rootMessageHandler.off("event-from-window-global", onNamedEvent); + rootMessageHandler.destroy(); +}); + +function callTestEmitEvent(rootMessageHandler, browsingContextId) { + rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitEvent", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js new file mode 100644 index 0000000000..cddcba3529 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { isBrowsingContextCompatible } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs" +); +const TEST_COM_PAGE = "https://example.com/document-builder.sjs?html=com"; +const TEST_NET_PAGE = "https://example.net/document-builder.sjs?html=net"; + +// Test helpers from BrowsingContextUtils in various processes. +add_task(async function () { + const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_COM_PAGE); + const contentBrowser1 = tab1.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser1); + const browserId1 = contentBrowser1.browsingContext.browserId; + + const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_NET_PAGE); + const contentBrowser2 = tab2.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser2); + const browserId2 = contentBrowser2.browsingContext.browserId; + + const { extension, sidebarBrowser } = await installSidebarExtension(); + + const tab3 = BrowserTestUtils.addTab( + gBrowser, + `moz-extension://${extension.uuid}/tab.html` + ); + const { bcId } = await extension.awaitMessage("tab-loaded"); + const tabExtensionBrowser = BrowsingContext.get(bcId).top.embedderElement; + + const parentBrowser1 = createParentBrowserElement(tab1, "content"); + const parentBrowser2 = createParentBrowserElement(tab1, "chrome"); + + info("Check browsing context compatibility for content browser 1"); + await checkBrowsingContextCompatible(contentBrowser1, undefined, true); + await checkBrowsingContextCompatible(contentBrowser1, browserId1, true); + await checkBrowsingContextCompatible(contentBrowser1, browserId2, false); + + info("Check browsing context compatibility for content browser 2"); + await checkBrowsingContextCompatible(contentBrowser2, undefined, true); + await checkBrowsingContextCompatible(contentBrowser2, browserId1, false); + await checkBrowsingContextCompatible(contentBrowser2, browserId2, true); + + info("Check browsing context compatibility for parent browser 1"); + await checkBrowsingContextCompatible(parentBrowser1, undefined, false); + await checkBrowsingContextCompatible(parentBrowser1, browserId1, false); + await checkBrowsingContextCompatible(parentBrowser1, browserId2, false); + + info("Check browsing context compatibility for parent browser 2"); + await checkBrowsingContextCompatible(parentBrowser2, undefined, false); + await checkBrowsingContextCompatible(parentBrowser2, browserId1, false); + await checkBrowsingContextCompatible(parentBrowser2, browserId2, false); + + info("Check browsing context compatibility for extension"); + await checkBrowsingContextCompatible(sidebarBrowser, undefined, false); + await checkBrowsingContextCompatible(sidebarBrowser, browserId1, false); + await checkBrowsingContextCompatible(sidebarBrowser, browserId2, false); + + info("Check browsing context compatibility for extension viewed in a tab"); + await checkBrowsingContextCompatible(tabExtensionBrowser, undefined, false); + await checkBrowsingContextCompatible(tabExtensionBrowser, browserId1, false); + await checkBrowsingContextCompatible(tabExtensionBrowser, browserId2, false); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab3); + await extension.unload(); +}); + +async function checkBrowsingContextCompatible(browser, browserId, expected) { + const options = { browserId }; + info("Check browsing context compatibility from the parent process"); + is(isBrowsingContextCompatible(browser.browsingContext, options), expected); + + info( + "Check browsing context compatibility from the browsing context's process" + ); + await SpecialPowers.spawn( + browser, + [browserId, expected], + (_browserId, _expected) => { + const BrowsingContextUtils = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs" + ); + is( + BrowsingContextUtils.isBrowsingContextCompatible( + content.browsingContext, + { + browserId: _browserId, + } + ), + _expected + ); + } + ); +} diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js new file mode 100644 index 0000000000..c115517980 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +// Check that errors from WindowGlobal modules can be caught by the consumer +// of the RootMessageHandler. +add_task(async function test_module_error() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler("session-id-error"); + + info("Call a module method which will throw"); + + await Assert.rejects( + rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testError", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }), + err => err.message.includes("error-from-module"), + "Error from window global module caught" + ); + + rootMessageHandler.destroy(); +}); + +// Check that sending commands to incorrect destinations creates an error which +// can be caught by the consumer of the RootMessageHandler. +add_task(async function test_destination_error() { + const rootMessageHandler = createRootMessageHandler("session-id-error"); + + const fakeBrowsingContextId = -1; + ok( + !BrowsingContext.get(fakeBrowsingContextId), + "No browsing context matches fakeBrowsingContextId" + ); + + info("Call a valid module method, but on a non-existent browsing context id"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testOnlyInWindowGlobal", + destination: { + type: WindowGlobalMessageHandler.type, + id: fakeBrowsingContextId, + }, + }), + err => err.message == `Unable to find a BrowsingContext for id -1` + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_invalid_module_error() { + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_module" + ); + + info("Attempt to call a Root module which has a syntax error"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "invalid", + commandName: "someMethod", + destination: { + type: RootMessageHandler.type, + }, + }), + err => + err.name === "SyntaxError" && + err.message == "expected expression, got ';'" + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_missing_root_module_error() { + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_module" + ); + + info("Attempt to call a Root module which doesn't exist"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "missingmodule", + commandName: "someMethod", + destination: { + type: RootMessageHandler.type, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == + `missingmodule.someMethod not supported for destination ROOT` + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_missing_windowglobal_module_error() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_windowglobal_module" + ); + + info("Attempt to call a WindowGlobal module which doesn't exist"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "missingmodule", + commandName: "someMethod", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == + `missingmodule.someMethod not supported for destination WINDOW_GLOBAL` + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_missing_root_method_error() { + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_root_method" + ); + + info("Attempt to call an invalid method on a Root module"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "wrongMethod", + destination: { + type: RootMessageHandler.type, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == `command.wrongMethod not supported for destination ROOT` + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_missing_windowglobal_method_error() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_windowglobal_method" + ); + + info("Attempt to call an invalid method on a WindowGlobal module"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "wrongMethod", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == + `commandwindowglobalonly.wrongMethod not supported for destination WINDOW_GLOBAL` + ); + + rootMessageHandler.destroy(); +}); + +/** + * This test checks that even if a command is rerouted to another command after + * the RootMessageHandler, we still check the new command and log a useful + * error message. + * + * This illustrates why it is important to perform the command check at each + * layer of the MessageHandler network. + */ +add_task(async function test_missing_intermediary_method_error() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_intermediary_method" + ); + + info( + "Call a (valid) command that relies on another (missing) command on a WindowGlobal module" + ); + await Assert.rejects( + rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testMissingIntermediaryMethod", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == + `commandwindowglobalonly.missingMethod not supported for destination WINDOW_GLOBAL` + ); + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js new file mode 100644 index 0000000000..1d020397e1 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js @@ -0,0 +1,229 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We are forcing the actors to shutdown while queries are unresolved. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Actor 'MessageHandlerFrame' destroyed before query 'MessageHandlerFrameParent:sendCommand' was resolved/ +); + +// The tests in this file assert the retry behavior for MessageHandler commands. +// We call "blocked" commands from resources/modules/windowglobal/retry.jsm and +// then trigger reload and navigations to simulate AbortErrors and force the +// MessageHandler to retry the commands, when possible. + +// Test that without retry behavior, a pending command rejects when the +// underlying JSWindowActor pair is destroyed. +add_task(async function test_no_retry() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler("session-id-no-retry"); + + try { + info("Call a module method which will throw"); + const onBlockedOneTime = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedOneTime", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + // Reloading the tab will reject the pending query with an AbortError. + await BrowserTestUtils.reloadTab(tab); + + await Assert.rejects( + onBlockedOneTime, + e => e.name == "AbortError", + "Caught the expected abort error when reloading" + ); + } finally { + await cleanup(rootMessageHandler, tab); + } +}); + +// Test various commands, which all need a different number of "retries" to +// succeed. Check that they only resolve when the expected number of "retries" +// was reached. For commands which require more "retries" than we allow, check +// that we still fail with an AbortError once all the attempts are consumed. +add_task(async function test_retry() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler("session-id-retry"); + + try { + // This command will return if called twice. + const onBlockedOneTime = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedOneTime", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + foo: "bar", + }, + retryOnAbort: true, + }); + + // This command will return if called three times. + const onBlockedTenTimes = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedTenTimes", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + foo: "baz", + }, + retryOnAbort: true, + }); + + // This command will return if called twelve times, which is greater than the + // maximum amount of retries allowed. + const onBlockedElevenTimes = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedElevenTimes", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + retryOnAbort: true, + }); + + info("Reload one time"); + await BrowserTestUtils.reloadTab(tab); + + info("blockedOneTime should resolve on the first retry"); + let { callsToCommand, foo } = await onBlockedOneTime; + is( + callsToCommand, + 2, + "The command was called twice (initial call + 1 retry)" + ); + is(foo, "bar", "The parameter was sent when the command was retried"); + + // We already reloaded 1 time. Reload 9 more times to unblock blockedTenTimes. + for (let i = 2; i < 11; i++) { + info("blockedTenTimes/blockedElevenTimes should not have resolved yet"); + ok(!(await hasPromiseResolved(onBlockedTenTimes))); + ok(!(await hasPromiseResolved(onBlockedElevenTimes))); + + info(`Reload the tab (time: ${i})`); + await BrowserTestUtils.reloadTab(tab); + } + + info("blockedTenTimes should resolve on the 10th reload"); + ({ callsToCommand, foo } = await onBlockedTenTimes); + is( + callsToCommand, + 11, + "The command was called 11 times (initial call + 10 retry)" + ); + is(foo, "baz", "The parameter was sent when the command was retried"); + + info("Reload one more time"); + await BrowserTestUtils.reloadTab(tab); + + info( + "The call to blockedElevenTimes now exceeds the maximum attempts allowed" + ); + await Assert.rejects( + onBlockedElevenTimes, + e => e.name == "AbortError", + "Caught the expected abort error when reloading" + ); + } finally { + await cleanup(rootMessageHandler, tab); + } +}); + +// Test cross-group navigations to check that the retry mechanism will +// transparently switch to the new Browsing Context created by the cross-group +// navigation. +add_task(async function test_retry_cross_group() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=COM" + + // Attach an unload listener to prevent the page from going into bfcache, + // so that pending queries will be rejected with an AbortError. + "<script type='text/javascript'>window.onunload = function() {};</script>" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-retry-cross-group" + ); + + try { + // This command hangs and only returns if the current domain is example.net. + // We send the command while on example.com, perform a series of reload and + // navigations, and the retry mechanism should allow onBlockedOnNetDomain to + // resolve. + const onBlockedOnNetDomain = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedOnNetDomain", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + foo: "bar", + }, + retryOnAbort: true, + }); + + info("Reload one time"); + await BrowserTestUtils.reloadTab(tab); + + info("blockedOnNetDomain should not have resolved yet"); + ok(!(await hasPromiseResolved(onBlockedOnNetDomain))); + + info( + "Navigate to example.net with COOP headers to destroy browsing context" + ); + await loadURL( + tab.linkedBrowser, + "https://example.net/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=NET" + ); + + info("blockedOnNetDomain should resolve now"); + let { foo } = await onBlockedOnNetDomain; + is(foo, "bar", "The parameter was sent when the command was retried"); + } finally { + await cleanup(rootMessageHandler, tab); + } +}); + +async function cleanup(rootMessageHandler, tab) { + const browsingContext = tab.linkedBrowser.browsingContext; + // Cleanup global JSM state in the test module. + await rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "cleanup", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +} diff --git a/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js new file mode 100644 index 0000000000..0a086d6f09 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js @@ -0,0 +1,203 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +// Test calling methods only implemented in the root version of a module. +add_task(async function test_rootModule_command() { + const rootMessageHandler = createRootMessageHandler("session-id-rootModule"); + const rootValue = await rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testRootModule", + destination: { + type: RootMessageHandler.type, + }, + }); + + is( + rootValue, + "root-value", + "Retrieved the expected value from testRootModule" + ); + + rootMessageHandler.destroy(); +}); + +// Test calling methods only implemented in the windowglobal-in-root version of +// a module. +add_task(async function test_windowglobalInRootModule_command() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobalInRootModule" + ); + const interceptedValue = await rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testInterceptModule", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + interceptedValue, + "intercepted-value", + "Retrieved the expected value from testInterceptModule" + ); + + rootMessageHandler.destroy(); +}); + +// Test calling methods only implemented in the windowglobal version of a +// module. +add_task(async function test_windowglobalModule_command() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobalModule" + ); + const windowGlobalValue = await rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testWindowGlobalModule", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + windowGlobalValue, + "windowglobal-value", + "Retrieved the expected value from testWindowGlobalModule" + ); + + rootMessageHandler.destroy(); +}); + +// Test calling a method on a module which is only available in the "windowglobal" +// folder. This will check that the MessageHandler/ModuleCache correctly moves +// on to the next layer when no implementation can be found in the root layer. +add_task(async function test_windowglobalOnlyModule_command() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobalOnlyModule" + ); + const windowGlobalOnlyValue = await rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testOnlyInWindowGlobal", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + windowGlobalOnlyValue, + "only-in-windowglobal", + "Retrieved the expected value from testOnlyInWindowGlobal" + ); + + rootMessageHandler.destroy(); +}); + +// Try to create 2 sessions which will both set values in individual modules +// via a command `testSetValue`, and then retrieve the values via another +// command `testGetValue`. +// This will ensure that different sessions use different module instances. +add_task(async function test_multisession() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler1 = createRootMessageHandler( + "session-id-multisession-1" + ); + const rootMessageHandler2 = createRootMessageHandler( + "session-id-multisession-2" + ); + + info("Set value for session 1"); + await rootMessageHandler1.handleCommand({ + moduleName: "command", + commandName: "testSetValue", + params: { value: "session1-value" }, + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + info("Set value for session 2"); + await rootMessageHandler2.handleCommand({ + moduleName: "command", + commandName: "testSetValue", + params: { value: "session2-value" }, + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + const session1Value = await rootMessageHandler1.handleCommand({ + moduleName: "command", + commandName: "testGetValue", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + session1Value, + "session1-value", + "Retrieved the expected value for session 1" + ); + + const session2Value = await rootMessageHandler2.handleCommand({ + moduleName: "command", + commandName: "testGetValue", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + session2Value, + "session2-value", + "Retrieved the expected value for session 2" + ); + + rootMessageHandler1.destroy(); + rootMessageHandler2.destroy(); +}); + +// Test calling a method from the windowglobal-in-root module which will +// internally forward to the windowglobal module and will return a composite +// result built both in parent and content process. +add_task(async function test_forwarding_command() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler("session-id-forwarding"); + const interceptAndForwardValue = await rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testInterceptAndForwardModule", + params: { id: "value" }, + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + interceptAndForwardValue, + "intercepted-and-forward+forward-to-windowglobal-value", + "Retrieved the expected value from testInterceptAndForwardModule" + ); + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_navigation_manager.js b/remote/shared/messagehandler/test/browser/browser_navigation_manager.js new file mode 100644 index 0000000000..474605e90f --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_navigation_manager.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +// Check that a functional navigation manager is available on the +// RootMessageHandler. +add_task(async function test_navigationManager() { + const sessionId = "navigationManager-test"; + const type = RootMessageHandler.type; + + const rootMessageHandlerRegistry = new MessageHandlerRegistry(type); + + const rootMessageHandler = + rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId); + + const navigationManager = rootMessageHandler.navigationManager; + ok(!!navigationManager, "ROOT MessageHandler provides a navigation manager"); + + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + info("Check the navigation manager monitors navigations"); + + const testUrl = "https://example.com/document-builder.sjs?html=test"; + const tab1 = BrowserTestUtils.addTab(gBrowser, testUrl); + const contentBrowser1 = tab1.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser1); + + const navigation = navigationManager.getNavigationForBrowsingContext( + contentBrowser1.browsingContext + ); + is(navigation.url, testUrl, "Navigation has the expected URL"); + + is(events.length, 2, "Received 2 navigation events"); + is(events[0].name, "navigation-started"); + is(events[1].name, "navigation-stopped"); + + info( + "Check the navigation manager is destroyed after destroying the message handler" + ); + rootMessageHandler.destroy(); + const otherUrl = "https://example.com/document-builder.sjs?html=other"; + const tab2 = BrowserTestUtils.addTab(gBrowser, otherUrl); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); + is(events.length, 2, "No new navigation event received"); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_realms.js b/remote/shared/messagehandler/test/browser/browser_realms.js new file mode 100644 index 0000000000..815bfbbe85 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_realms.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +add_task(async function test_tab_is_removed() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const sessionId = "realms"; + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const rootMessageHandler = createRootMessageHandler(sessionId); + + const onRealmCreated = rootMessageHandler.once("realm-created"); + + // Add a new session data item to get window global handlers created + await rootMessageHandler.addSessionDataItem({ + moduleName: "command", + category: "browser_realms", + contextDescriptor, + values: [true], + }); + + const realmCreatedEvent = await onRealmCreated; + const createdRealmId = realmCreatedEvent.realmInfo.realm; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + const onRealmDestroyed = rootMessageHandler.once("realm-destroyed"); + + gBrowser.removeTab(tab); + + const realmDestroyedEvent = await onRealmDestroyed; + + is( + realmDestroyedEvent.realm, + createdRealmId, + "Received a correct realm id in realm-destroyed event" + ); + is(rootMessageHandler.realms.size, 0, "The realm map is cleaned up"); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_same_origin_navigation() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const sessionId = "realms"; + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const rootMessageHandler = createRootMessageHandler(sessionId); + + const onRealmCreated = rootMessageHandler.once("realm-created"); + + // Add a new session data item to get window global handlers created + await rootMessageHandler.addSessionDataItem({ + moduleName: "command", + category: "browser_realms", + contextDescriptor, + values: [true], + }); + + const realmCreatedEvent = await onRealmCreated; + const createdRealmId = realmCreatedEvent.realmInfo.realm; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + const onRealmDestroyed = rootMessageHandler.once("realm-destroyed"); + const onNewRealmCreated = rootMessageHandler.once("realm-created"); + + // Navigate to another page with the same origin + await loadURL( + tab.linkedBrowser, + "https://example.com/document-builder.sjs?html=othertab" + ); + + const realmDestroyedEvent = await onRealmDestroyed; + + is( + realmDestroyedEvent.realm, + createdRealmId, + "Received a correct realm id in realm-destroyed event" + ); + + await onNewRealmCreated; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + gBrowser.removeTab(tab); + rootMessageHandler.destroy(); +}); + +add_task(async function test_cross_origin_navigation() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const sessionId = "realms"; + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const rootMessageHandler = createRootMessageHandler(sessionId); + + const onRealmCreated = rootMessageHandler.once("realm-created"); + + // Add a new session data item to get window global handlers created + await rootMessageHandler.addSessionDataItem({ + moduleName: "command", + category: "browser_realms", + contextDescriptor, + values: [true], + }); + + const realmCreatedEvent = await onRealmCreated; + const createdRealmId = realmCreatedEvent.realmInfo.realm; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + const onRealmDestroyed = rootMessageHandler.once("realm-destroyed"); + const onNewRealmCreated = rootMessageHandler.once("realm-created"); + + // Navigate to another page with the different origin + await loadURL( + tab.linkedBrowser, + "https://example.com/document-builder.sjs?html=otherorigin" + ); + + const realmDestroyedEvent = await onRealmDestroyed; + + is( + realmDestroyedEvent.realm, + createdRealmId, + "Received a correct realm id in realm-destroyed event" + ); + + await onNewRealmCreated; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + gBrowser.removeTab(tab); + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_registry.js b/remote/shared/messagehandler/test/browser/browser_registry.js new file mode 100644 index 0000000000..945ac06c19 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_registry.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +add_task(async function test_messageHandlerRegistry_API() { + const sessionId = 1; + const type = RootMessageHandler.type; + + const rootMessageHandlerRegistry = new MessageHandlerRegistry(type); + + const rootMessageHandler = + rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId); + ok(rootMessageHandler, "Valid ROOT MessageHandler created"); + + const contextId = rootMessageHandler.contextId; + ok(contextId, "ROOT MessageHandler has a valid contextId"); + + is( + rootMessageHandler, + rootMessageHandlerRegistry.getExistingMessageHandler(sessionId), + "ROOT MessageHandler can be retrieved from the registry" + ); + + rootMessageHandler.destroy(); + ok( + !rootMessageHandlerRegistry.getExistingMessageHandler(sessionId), + "Destroyed ROOT MessageHandler is no longer returned by the Registry" + ); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data.js b/remote/shared/messagehandler/test/browser/browser_session_data.js new file mode 100644 index 0000000000..591073feb6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data.js @@ -0,0 +1,273 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); +const { SessionData } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs" +); + +const TEST_PAGE = "http://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_sessionData() { + info("Navigate the initial tab to the test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + + const sessionId = "sessionData-test"; + + const rootMessageHandlerRegistry = new MessageHandlerRegistry( + RootMessageHandler.type + ); + + const rootMessageHandler = + rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId); + ok(rootMessageHandler, "Valid ROOT MessageHandler created"); + + const sessionData = rootMessageHandler.sessionData; + ok( + sessionData instanceof SessionData, + "ROOT MessageHandler has a valid sessionData" + ); + + let sessionDataSnapshot = await getSessionDataFromContent(); + is(sessionDataSnapshot.size, 0, "session data is empty"); + + info("Store a string value in session data"); + sessionData.updateSessionData([ + { + method: "add", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: ["value-1"], + }, + ]); + + sessionDataSnapshot = await getSessionDataFromContent(); + is(sessionDataSnapshot.size, 1, "session data contains 1 session"); + ok(sessionDataSnapshot.has(sessionId)); + let snapshot = sessionDataSnapshot.get(sessionId); + ok(Array.isArray(snapshot)); + is(snapshot.length, 1); + + const stringDataItem = snapshot[0]; + checkSessionDataItem( + stringDataItem, + "fakemodule", + "testCategory", + ContextDescriptorType.All, + "value-1" + ); + + info("Store a number value in session data"); + sessionData.updateSessionData([ + { + method: "add", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: [12], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 2); + + const numberDataItem = snapshot[1]; + checkSessionDataItem( + numberDataItem, + "fakemodule", + "testCategory", + ContextDescriptorType.All, + 12 + ); + + info("Store a boolean value in session data"); + sessionData.updateSessionData([ + { + method: "add", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: [true], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 3); + + const boolDataItem = snapshot[2]; + checkSessionDataItem( + boolDataItem, + "fakemodule", + "testCategory", + ContextDescriptorType.All, + true + ); + + info("Remove one value"); + sessionData.updateSessionData([ + { + method: "remove", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: [12], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 2); + checkSessionDataItem( + snapshot[0], + "fakemodule", + "testCategory", + ContextDescriptorType.All, + "value-1" + ); + checkSessionDataItem( + snapshot[1], + "fakemodule", + "testCategory", + ContextDescriptorType.All, + true + ); + + info("Remove all values"); + sessionData.updateSessionData([ + { + method: "remove", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: ["value-1", true], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 0, "Session data is now empty"); + + info("Add another value before destroy"); + sessionData.updateSessionData([ + { + method: "add", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: ["value-2"], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 1); + checkSessionDataItem( + snapshot[0], + "fakemodule", + "testCategory", + ContextDescriptorType.All, + "value-2" + ); + + sessionData.destroy(); + sessionDataSnapshot = await getSessionDataFromContent(); + is(sessionDataSnapshot.size, 0, "session data should be empty again"); +}); + +add_task(async function test_sessionDataRootOnlyModule() { + const sessionId = "sessionData-test-rootOnly"; + + const rootMessageHandler = createRootMessageHandler(sessionId); + ok(rootMessageHandler, "Valid ROOT MessageHandler created"); + + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + + const windowGlobalCreated = rootMessageHandler.once( + "window-global-handler-created" + ); + + info("Test that adding SessionData items works the root module"); + // Updating the session data on the root message handler should not cause + // failures for other message handlers if the module only exists for root. + await rootMessageHandler.addSessionDataItem({ + moduleName: "rootOnly", + category: "session_data_root_only", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: [true], + }); + + await windowGlobalCreated; + ok(true, "Window global has been initialized"); + + let sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({ + moduleName: "rootOnly", + commandName: "getSessionDataReceived", + destination: { + type: RootMessageHandler.type, + }, + }); + + is(sessionDataReceivedByRoot.length, 1); + is(sessionDataReceivedByRoot[0].category, "session_data_root_only"); + is(sessionDataReceivedByRoot[0].added.length, 1); + is(sessionDataReceivedByRoot[0].added[0], true); + is( + sessionDataReceivedByRoot[0].contextDescriptor.type, + ContextDescriptorType.All + ); + + info("Now test that removing items also works on the root module"); + await rootMessageHandler.removeSessionDataItem({ + moduleName: "rootOnly", + category: "session_data_root_only", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: [true], + }); + + sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({ + moduleName: "rootOnly", + commandName: "getSessionDataReceived", + destination: { + type: RootMessageHandler.type, + }, + }); + + is(sessionDataReceivedByRoot.length, 2); + is(sessionDataReceivedByRoot[1].category, "session_data_root_only"); + is(sessionDataReceivedByRoot[1].removed.length, 1); + is(sessionDataReceivedByRoot[1].removed[0], true); + is( + sessionDataReceivedByRoot[1].contextDescriptor.type, + ContextDescriptorType.All + ); + + rootMessageHandler.destroy(); +}); + +function checkSessionDataItem(item, moduleName, category, contextType, value) { + is(item.moduleName, moduleName, "Data item has the expected module name"); + is(item.category, category, "Data item has the expected category"); + is( + item.contextDescriptor.type, + contextType, + "Data item has the expected context type" + ); + is(item.value, value, "Data item has the expected value"); +} + +function getSessionDataFromContent() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const { readSessionData } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs" + ); + return readSessionData(); + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js new file mode 100644 index 0000000000..9c15974ae6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +/** + * Check that message handlers are not created for parent process browser + * elements, even if they have the type="content" attribute (eg used for the + * DevTools toolbox), as well as for webextension contexts. + */ +add_task(async function test_session_data_broadcast() { + // Prepare: + // - one content tab + // - one browser type content + // - one browser type chrome + // - one sidebar webextension + // We only expect session data to be applied to the content tab + const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + const contentBrowser1 = tab1.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser1); + const parentBrowser1 = createParentBrowserElement(tab1, "content"); + const parentBrowser2 = createParentBrowserElement(tab1, "chrome"); + const { extension: extension1, sidebarBrowser: extSidebarBrowser1 } = + await installSidebarExtension(); + + const root = createRootMessageHandler("session-id-event"); + + // When the windowglobal command.jsm module applies the session data + // browser_session_data_browser_element, it will emit an event. + // Collect the events to detect which MessageHandlers have been started. + info("Watch events emitted when session data is applied"); + const sessionDataEvents = []; + const onRootEvent = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "received-session-data") { + sessionDataEvents.push(wrappedEvt.data.contextId); + } + }; + root.on("message-handler-event", onRootEvent); + + info("Add a new session data item, expect one return value"); + await root.addSessionDataItem({ + moduleName: "command", + category: "browser_session_data_browser_element", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: [true], + }); + + function hasSessionData(browsingContext) { + return sessionDataEvents.includes(browsingContext.id); + } + + info( + "Check that only the content tab window global received the session data" + ); + is(hasSessionData(contentBrowser1.browsingContext), true); + is(hasSessionData(parentBrowser1.browsingContext), false); + is(hasSessionData(parentBrowser2.browsingContext), false); + is(hasSessionData(extSidebarBrowser1.browsingContext), false); + + const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + const contentBrowser2 = tab2.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser2); + const parentBrowser3 = createParentBrowserElement(contentBrowser2, "content"); + const parentBrowser4 = createParentBrowserElement(contentBrowser2, "chrome"); + + const { extension: extension2, sidebarBrowser: extSidebarBrowser2 } = + await installSidebarExtension(); + + info("Wait until the session data was applied to the new tab"); + await TestUtils.waitForCondition(() => + sessionDataEvents.includes(contentBrowser2.browsingContext.id) + ); + + info("Check that parent browser elements did not apply the session data"); + is(hasSessionData(parentBrowser3.browsingContext), false); + is(hasSessionData(parentBrowser4.browsingContext), false); + + info( + "Check that extension did not apply the session data, " + + extSidebarBrowser2.browsingContext.id + ); + is(hasSessionData(extSidebarBrowser2.browsingContext), false); + + root.destroy(); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + await extension1.unload(); + await extension2.unload(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js new file mode 100644 index 0000000000..03ed59166f --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +/** + * Check that modules created early for session data are still created with a + * fully initialized MessageHandler. See Bug 1743083. + */ +add_task(async function () { + const tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-id-event"); + + info("Add some session data for the command module"); + await root.addSessionDataItem({ + moduleName: "command", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: ["some-value"], + }); + + info("Reload the current tab to create new message handlers and modules"); + await BrowserTestUtils.reloadTab(tab); + + info( + "Check if the command module was created by the MessageHandler constructor" + ); + const isCreatedByMessageHandlerConstructor = await root.handleCommand({ + moduleName: "command", + commandName: "testIsCreatedByMessageHandlerConstructor", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + is( + isCreatedByMessageHandlerConstructor, + false, + "The command module from session data should not be created by the MessageHandler constructor" + ); + root.destroy(); + + gBrowser.removeTab(tab); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update.js b/remote/shared/messagehandler/test/browser/browser_session_data_update.js new file mode 100644 index 0000000000..342a4a6139 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_update.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +const { assertUpdate, createSessionDataUpdate, getUpdates } = + SessionDataUpdateHelpers; + +// Test various session data update scenarios against a single browsing context. +add_task(async function test_session_data_update() { + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-data-update"); + + info("Add a new session data item, expect one return value"); + await root.updateSessionData([ + createSessionDataUpdate(["text-1"], "add", "category1"), + ]); + let processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 1); + assertUpdate(processedUpdates.at(-1), ["text-1"], "category1"); + + info("Add two session data items, expect one return value with both items"); + await root.updateSessionData([ + createSessionDataUpdate(["text-2"], "add", "category1"), + createSessionDataUpdate(["text-3"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 2); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3"], + "category1" + ); + + info("Try to add an existing data item, expect no update broadcast"); + await root.updateSessionData([ + createSessionDataUpdate(["text-1"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 2); + + info("Add an existing and a new item"); + await root.updateSessionData([ + createSessionDataUpdate(["text-2", "text-4"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 3); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3", "text-4"], + "category1" + ); + + info("Remove an item, expect only the new item to return"); + await root.updateSessionData([ + createSessionDataUpdate(["text-3"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 4); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-4"], + "category1" + ); + + info("Remove a unknown item, expect no return value"); + await root.updateSessionData([ + createSessionDataUpdate(["text-unknown"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 4); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-4"], + "category1" + ); + + info("Remove an existing and a unknown item"); + await root.updateSessionData([ + createSessionDataUpdate(["text-2"], "remove", "category1"), + createSessionDataUpdate(["text-unknown"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 5); + assertUpdate(processedUpdates.at(-1), ["text-1", "text-4"], "category1"); + + info("Add and remove at once"); + await root.updateSessionData([ + createSessionDataUpdate(["text-5"], "add", "category1"), + createSessionDataUpdate(["text-4"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 6); + assertUpdate(processedUpdates.at(-1), ["text-1", "text-5"], "category1"); + + info("Adding and removing an item does not trigger any update"); + await root.updateSessionData([ + createSessionDataUpdate(["text-6"], "add", "category1"), + createSessionDataUpdate(["text-6"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + // TODO: We could detect transactions which can't have any impact and fully + // ignore them. See Bug 1810807. + todo_is(processedUpdates.length, 6); + assertUpdate(processedUpdates.at(-1), ["text-1", "text-5"], "category1"); + + root.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js b/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js new file mode 100644 index 0000000000..b1cadcf095 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +const { assertUpdate, createSessionDataUpdate, getUpdates } = + SessionDataUpdateHelpers; + +// Test session data update scenarios involving different session data item +// categories. +add_task(async function test_session_data_update_categories() { + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-data-update-categories"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-1"], "add", "category1"), + createSessionDataUpdate(["value1-2"], "add", "category1"), + ]); + + let processedUpdates = await getUpdates(root, browsingContext1); + + is(processedUpdates.length, 1); + assertUpdate(processedUpdates.at(-1), ["value1-1", "value1-2"], "category1"); + + info("Adding a new item in category1 broadcasts all category1 items"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-3"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 2); + assertUpdate( + processedUpdates.at(-1), + ["value1-1", "value1-2", "value1-3"], + "category1" + ); + + info("Removing a new item in category1 broadcasts all category1 items"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-1"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 3); + assertUpdate(processedUpdates.at(-1), ["value1-2", "value1-3"], "category1"); + + info("Adding a new category does not broadcast category1 items"); + await root.updateSessionData([ + createSessionDataUpdate(["value2-1"], "add", "category2"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 4); + assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2"); + + info("Adding an item in 2 categories triggers an update for each category"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-4"], "add", "category1"), + createSessionDataUpdate(["value2-2"], "add", "category2"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 6); + assertUpdate( + processedUpdates.at(-2), + ["value1-2", "value1-3", "value1-4"], + "category1" + ); + assertUpdate(processedUpdates.at(-1), ["value2-1", "value2-2"], "category2"); + + info("Removing an item in 2 categories triggers an update for each category"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-4"], "remove", "category1"), + createSessionDataUpdate(["value2-2"], "remove", "category2"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 8); + assertUpdate(processedUpdates.at(-2), ["value1-2", "value1-3"], "category1"); + assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2"); + + info("Opening a new tab triggers an update for each category"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 2); + assertUpdate(processedUpdates.at(-2), ["value1-2", "value1-3"], "category1"); + assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2"); + + root.destroy(); + gBrowser.removeTab(tab2); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js b/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js new file mode 100644 index 0000000000..711df1fc56 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js @@ -0,0 +1,194 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +const { assertUpdate, createSessionDataUpdate, getUpdates } = + SessionDataUpdateHelpers; + +// Test session data update scenarios involving 2 browsing contexts, and using +// the TopBrowsingContext ContextDescriptor type. +add_task(async function test_session_data_update_contexts() { + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-data-update-contexts"); + + info("Add several items over 2 separate updates for all contexts"); + await root.updateSessionData([ + createSessionDataUpdate(["text-1"], "add", "category1"), + ]); + await root.updateSessionData([ + createSessionDataUpdate(["text-2"], "add", "category1"), + createSessionDataUpdate(["text-3"], "add", "category1"), + ]); + + info("Check we processed two distinct updates in browsingContext 1"); + let processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 2); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3"], + "category1" + ); + + info("Open a new tab on the same test URL"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 1); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3"], + "category1" + ); + + info("Add two items: one globally and one in a single context"); + await root.updateSessionData([ + createSessionDataUpdate(["text-4"], "add", "category1"), + createSessionDataUpdate(["text-5"], "add", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }), + ]); + + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 3); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3", "text-4"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 2); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3", "text-4", "text-5"], + "category1" + ); + + info("Remove two items: one globally and one in a single context"); + await root.updateSessionData([ + createSessionDataUpdate(["text-1"], "remove", "category1"), + createSessionDataUpdate(["text-5"], "remove", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }), + ]); + + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 4); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 3); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4"], + "category1" + ); + + info( + "Add session data item to all contexts and remove this event for one context (2 steps)" + ); + + info("First step: add an item to browsingContext1"); + await root.updateSessionData([ + createSessionDataUpdate(["text-6"], "add", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }), + ]); + + info( + "Second step: remove the item from browsingContext1, and add it globally" + ); + await root.updateSessionData([ + createSessionDataUpdate(["text-6"], "remove", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }), + createSessionDataUpdate(["text-6"], "add", "category1"), + ]); + + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 6); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 4); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6"], + "category1" + ); + + info( + "Remove the event, which has also an individual subscription, for all contexts (2 steps)" + ); + + info("First step: Add the same item for browsingContext1 and globally"); + await root.updateSessionData([ + createSessionDataUpdate(["text-7"], "add", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }), + createSessionDataUpdate(["text-7"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 7); + // We will find text-7 twice here, the module is responsible for not applying + // the same session data item twice. Each item corresponds to a different + // descriptor which matched browsingContext1. + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6", "text-7", "text-7"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 5); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6", "text-7"], + "category1" + ); + + info("Second step: Remove the item globally"); + await root.updateSessionData([ + createSessionDataUpdate(["text-7"], "remove", "category1"), + ]); + + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 8); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6", "text-7"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 6); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6"], + "category1" + ); + + root.destroy(); + + gBrowser.removeTab(tab2); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js b/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js new file mode 100644 index 0000000000..57629e5485 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +add_task(async function test_windowGlobal_to_root_command() { + // Navigate to a page to make sure that the windowglobal modules run in a + // different process than the root module. + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContextId = tab.linkedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobal-to-rootModule" + ); + + for (const commandName of [ + "testHandleCommandToRoot", + "testSendRootCommand", + ]) { + const valueFromRoot = await rootMessageHandler.handleCommand({ + moduleName: "windowglobaltoroot", + commandName, + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + valueFromRoot, + "root-value-called-from-windowglobal", + "Retrieved the expected value from windowglobaltoroot using " + + commandName + ); + } + + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); diff --git a/remote/shared/messagehandler/test/browser/head.js b/remote/shared/messagehandler/test/browser/head.js new file mode 100644 index 0000000000..81cf0942d3 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/head.js @@ -0,0 +1,236 @@ +/* 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"; + +var { ContextDescriptorType } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs" +); + +var { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs" +); + +var contextDescriptorAll = { + type: ContextDescriptorType.All, +}; + +function createRootMessageHandler(sessionId) { + const { RootMessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs" + ); + return RootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId); +} + +/** + * Load the provided url in an existing browser. + * Returns a promise which will resolve when the page is loaded. + * + * @param {Browser} browser + * The browser element where the URL should be loaded. + * @param {string} url + * The URL to load in the new tab + */ +async function loadURL(browser, url) { + const loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, url); + return loaded; +} + +/** + * Create a new foreground tab loading the provided url. + * Returns a promise which will resolve when the page is loaded. + * + * @param {string} url + * The URL to load in the new tab + */ +async function addTab(url) { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + registerCleanupFunction(() => { + gBrowser.removeTab(tab); + }); + return tab; +} + +/** + * Create inline markup for a simple iframe that can be used with + * document-builder.sjs. The iframe will be served under the provided domain. + * + * @param {string} domain + * A domain (eg "example.com"), compatible with build/pgo/server-locations.txt + */ +function createFrame(domain) { + return createFrameForUri( + `https://${domain}/document-builder.sjs?html=frame-${domain}` + ); +} + +function createFrameForUri(uri) { + return `<iframe src="${encodeURI(uri)}"></iframe>`; +} + +/** + * Create a XUL browser element in the provided XUL tab, with the provided type. + * + * @param {XULTab} tab + * The XUL tab in which the browser element should be inserted. + * @param {string} type + * The type attribute of the browser element, "chrome" or "content". + * @returns {XULBrowser} + * The created browser element. + */ +function createParentBrowserElement(tab, type) { + const parentBrowser = gBrowser.ownerDocument.createXULElement("browser"); + parentBrowser.setAttribute("type", type); + const container = gBrowser.getBrowserContainer(tab.linkedBrowser); + container.appendChild(parentBrowser); + + return parentBrowser; +} + +// Create a test page with 2 iframes: +// - one with a different eTLD+1 (example.com) +// - one with a nested iframe on a different eTLD+1 (example.net) +// +// Overall the document structure should look like: +// +// html (example.org) +// iframe (example.org) +// iframe (example.net) +// iframe(example.com) +// +// Which means we should have 4 browsing contexts in total. +function createTestMarkupWithFrames() { + // Create the markup for an example.net frame nested in an example.com frame. + const NESTED_FRAME_MARKUP = createFrameForUri( + `https://example.org/document-builder.sjs?html=${createFrame( + "example.net" + )}` + ); + + // Combine the nested frame markup created above with an example.com frame. + const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`; + + // Create the test page URI on example.org. + return `https://example.org/document-builder.sjs?html=${encodeURI( + TEST_URI_MARKUP + )}`; +} + +const hasPromiseResolved = async function (promise) { + let resolved = false; + promise.finally(() => (resolved = true)); + // Make sure microtasks have time to run. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + return resolved; +}; + +/** + * Install a sidebar extension. + * + * @returns {object} + * Return value with two properties: + * - extension: test wrapper as returned by SpecialPowers.loadExtension. + * Make sure to explicitly call extension.unload() before the end of the test. + * - sidebarBrowser: the browser element containing the extension sidebar. + */ +async function installSidebarExtension() { + info("Load the test extension"); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + Test extension + <script src="sidebar.js"></script> + </html> + `, + "sidebar.js": function () { + const { browser } = this; + browser.test.sendMessage("sidebar-loaded", { + bcId: SpecialPowers.wrap(window).browsingContext.id, + }); + }, + "tab.html": ` + <!DOCTYPE html> + <html> + Test extension (tab) + <script src="tab.js"></script> + </html> + `, + "tab.js": function () { + const { browser } = this; + browser.test.sendMessage("tab-loaded", { + bcId: SpecialPowers.wrap(window).browsingContext.id, + }); + }, + }, + }); + + info("Wait for the extension to start"); + await extension.startup(); + + info("Wait for the extension browsing context"); + const { bcId } = await extension.awaitMessage("sidebar-loaded"); + const sidebarBrowser = BrowsingContext.get(bcId).top.embedderElement; + ok(sidebarBrowser, "Got a browser element for the extension sidebar"); + + return { + extension, + sidebarBrowser, + }; +} + +const SessionDataUpdateHelpers = { + getUpdates(rootMessageHandler, browsingContext) { + return rootMessageHandler.handleCommand({ + moduleName: "sessiondataupdate", + commandName: "getSessionDataUpdates", + destination: { + id: browsingContext.id, + type: WindowGlobalMessageHandler.type, + }, + }); + }, + + createSessionDataUpdate( + values, + method, + category, + descriptor = { type: ContextDescriptorType.All } + ) { + return { + method, + values, + moduleName: "sessiondataupdate", + category, + contextDescriptor: descriptor, + }; + }, + + assertUpdate(update, expectedValues, expectedCategory) { + is( + update.length, + expectedValues.length, + "Update has the expected number of values" + ); + + for (const item of update) { + info(`Check session data update item '${item.value}'`); + is(item.category, expectedCategory, "Item has the expected category"); + is( + expectedValues[update.indexOf(item)], + item.value, + "Item has the expected value" + ); + } + }, +}; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs new file mode 100644 index 0000000000..7d93f45b33 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs @@ -0,0 +1,40 @@ +/* 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/. */ + +export const modules = { + root: {}, + "windowglobal-in-root": {}, + windowglobal: {}, +}; + +const BASE_FOLDER = + "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules"; + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules.root, { + command: `${BASE_FOLDER}/root/command.sys.mjs`, + event: `${BASE_FOLDER}/root/event.sys.mjs`, + invalid: `${BASE_FOLDER}/root/invalid.sys.mjs`, + rootOnly: `${BASE_FOLDER}/root/rootOnly.sys.mjs`, + windowglobaltoroot: `${BASE_FOLDER}/root/windowglobaltoroot.sys.mjs`, +}); + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules["windowglobal-in-root"], { + command: `${BASE_FOLDER}/windowglobal-in-root/command.sys.mjs`, + event: `${BASE_FOLDER}/windowglobal-in-root/event.sys.mjs`, +}); + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules.windowglobal, { + command: `${BASE_FOLDER}/windowglobal/command.sys.mjs`, + commandwindowglobalonly: `${BASE_FOLDER}/windowglobal/commandwindowglobalonly.sys.mjs`, + event: `${BASE_FOLDER}/windowglobal/event.sys.mjs`, + eventemitter: `${BASE_FOLDER}/windowglobal/eventemitter.sys.mjs`, + eventnointercept: `${BASE_FOLDER}/windowglobal/eventnointercept.sys.mjs`, + eventonprefchange: `${BASE_FOLDER}/windowglobal/eventonprefchange.sys.mjs`, + retry: `${BASE_FOLDER}/windowglobal/retry.sys.mjs`, + sessiondataupdate: `${BASE_FOLDER}/windowglobal/sessiondataupdate.sys.mjs`, + windowglobaltoroot: `${BASE_FOLDER}/windowglobal/windowglobaltoroot.sys.mjs`, +}); diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs new file mode 100644 index 0000000000..29e4a75828 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs @@ -0,0 +1,29 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class CommandModule extends Module { + destroy() {} + + /** + * Commands + */ + + testRootModule() { + return "root-value"; + } + + testMissingIntermediaryMethod(params, destination) { + // Spawn a new internal command, but with a commandName which doesn't match + // any method. + return this.messageHandler.handleCommand({ + moduleName: "command", + commandName: "missingMethod", + destination, + }); + } +} + +export const command = CommandModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs new file mode 100644 index 0000000000..e49437e80d --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs @@ -0,0 +1,21 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventModule extends Module { + destroy() {} + + /** + * Commands + */ + + testEmitRootEvent() { + this.emitEvent("event-from-root", { + text: "event from root", + }); + } +} + +export const event = EventModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs new file mode 100644 index 0000000000..3b74769d06 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs @@ -0,0 +1,4 @@ +// This module is meant to check error reporting when importing a module fails +// due to an actual issue (syntax error etc...). + +SyntaxError(; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs new file mode 100644 index 0000000000..0931a7ee8e --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs @@ -0,0 +1,70 @@ +/* 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/. */ + +import { ContextDescriptorType } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"; +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class RootOnlyModule extends Module { + #sessionDataReceived; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + + this.#sessionDataReceived = []; + this.#subscribedEvents = new Set(); + } + + destroy() {} + + /** + * Commands + */ + + getSessionDataReceived() { + return this.#sessionDataReceived; + } + + testCommand(params = {}) { + return params; + } + + _applySessionData(params) { + const added = []; + const removed = []; + + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#subscribedEvents.delete(event); + removed.push(event); + } + } + + // Subscribe to all events, which have an item in SessionData + for (const { value } of filteredSessionData) { + if (!this.#subscribedEvents.has(value)) { + this.#subscribedEvents.add(value); + added.push(value); + } + } + + this.#sessionDataReceived.push({ + category: params.category, + added, + removed, + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }); + } +} + +export const rootOnly = RootOnlyModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs new file mode 100644 index 0000000000..0975c4abd5 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs @@ -0,0 +1,29 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class WindowGlobalToRootModule extends Module { + destroy() {} + + /** + * Commands + */ + + getValueFromRoot() { + this.#assertParentProcess(); + return "root-value-called-from-windowglobal"; + } + + #assertParentProcess() { + const isParent = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT; + + if (!isParent) { + throw new Error("Can only run in the parent process"); + } + } +} + +export const windowglobaltoroot = WindowGlobalToRootModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs new file mode 100644 index 0000000000..f9a2e5d4eb --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs @@ -0,0 +1,28 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class CommandModule extends Module { + destroy() {} + + /** + * Commands + */ + + testInterceptModule() { + return "intercepted-value"; + } + + async testInterceptAndForwardModule(params, destination) { + const windowGlobalValue = await this.messageHandler.handleCommand({ + moduleName: "command", + commandName: "testForwardToWindowGlobal", + destination, + }); + return "intercepted-and-forward+" + windowGlobalValue; + } +} + +export const command = CommandModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs new file mode 100644 index 0000000000..be8b284e8d --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs @@ -0,0 +1,39 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventModule extends Module { + destroy() {} + + interceptEvent(name, payload) { + if (name === "event.testEventWithInterception") { + return { + ...payload, + additionalInformation: "information added through interception", + }; + } + + if (name === "event.testEventCancelableWithInterception") { + if (payload.shouldCancel) { + return null; + } + return payload; + } + + return payload; + } + + /** + * Commands + */ + + testEmitWindowGlobalInRootEvent(params, destination) { + this.emitEvent("event-from-window-global-in-root", { + text: `windowglobal-in-root event for ${destination.id}`, + }); + } +} + +export const event = EventModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs new file mode 100644 index 0000000000..99ee76a4b8 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs @@ -0,0 +1,85 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class CommandModule extends Module { + constructor(messageHandler) { + super(messageHandler); + this._subscribedEvents = new Set(); + + this._createdByMessageHandlerConstructor = + this._isCreatedByMessageHandlerConstructor(); + } + destroy() {} + + /** + * Commands + */ + + _applySessionData(params) { + if (params.category === "testCategory") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this._subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this._subscribedEvents.delete(event); + } + } + + // Subscribe to all events, which have an item in SessionData + for (const { value } of filteredSessionData) { + if (!this._subscribedEvents.has(value)) { + this._subscribedEvents.add(value); + } + } + } + + if (params.category === "browser_session_data_browser_element") { + this.emitEvent("received-session-data", { + contextId: this.messageHandler.contextId, + }); + } + } + + testWindowGlobalModule() { + return "windowglobal-value"; + } + + testSetValue(params) { + const { value } = params; + + this._testValue = value; + } + + testGetValue() { + return this._testValue; + } + + testForwardToWindowGlobal() { + return "forward-to-windowglobal-value"; + } + + testIsCreatedByMessageHandlerConstructor() { + return this._createdByMessageHandlerConstructor; + } + + _isCreatedByMessageHandlerConstructor() { + let caller = Components.stack.caller; + while (caller) { + if (caller.name === this.messageHandler.constructor.name) { + return true; + } + caller = caller.caller; + } + return false; + } +} + +export const command = CommandModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs new file mode 100644 index 0000000000..1e4e6c1574 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs @@ -0,0 +1,41 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class CommandWindowGlobalOnlyModule extends Module { + destroy() {} + + /** + * Commands + */ + + testOnlyInWindowGlobal() { + return "only-in-windowglobal"; + } + + testBroadcast() { + return `broadcast-${this.messageHandler.contextId}`; + } + + testBroadcastWithParameter(params) { + return `broadcast-${this.messageHandler.contextId}-${params.value}`; + } + + testError() { + throw new Error("error-from-module"); + } + + testMissingIntermediaryMethod(params, destination) { + // Spawn a new internal command, but with a commandName which doesn't match + // any method. + return this.messageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "missingMethod", + destination, + }); + } +} + +export const commandwindowglobalonly = CommandWindowGlobalOnlyModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs new file mode 100644 index 0000000000..415f32032e --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs @@ -0,0 +1,32 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventModule extends Module { + destroy() {} + + /** + * Commands + */ + + testEmitEvent() { + // Emit a payload including the contextId to check which context emitted + // a specific event. + const text = `event from ${this.messageHandler.contextId}`; + this.emitEvent("event-from-window-global", { text }); + } + + testEmitEventCancelableWithInterception(params) { + this.emitEvent("event.testEventCancelableWithInterception", { + shouldCancel: params.shouldCancel, + }); + } + + testEmitEventWithInterception() { + this.emitEvent("event.testEventWithInterception", {}); + } +} + +export const event = EventModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs new file mode 100644 index 0000000000..c86954c5e0 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs @@ -0,0 +1,81 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventEmitterModule extends Module { + #isSubscribed; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + this.#isSubscribed = false; + this.#subscribedEvents = new Set(); + } + + destroy() {} + + /** + * Commands + */ + + emitTestEvent() { + if (this.#isSubscribed) { + const text = `event from ${this.messageHandler.contextId}`; + this.emitEvent("eventemitter.testEvent", { text }); + } + + // Emit another event consistently for monitoring during the test. + this.emitEvent("eventemitter.monitoringEvent", {}); + } + + isSubscribed() { + return this.#isSubscribed; + } + + _applySessionData(params) { + const { category } = params; + if (category === "event") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#unsubscribeEvent(event); + } + } + + // Subscribe to all events, which have an item in SessionData + for (const { value } of filteredSessionData) { + this.#subscribeEvent(value); + } + } + } + + #subscribeEvent(event) { + if (event === "eventemitter.testEvent") { + if (this.#isSubscribed) { + throw new Error("Already subscribed to eventemitter.testEvent"); + } + this.#isSubscribed = true; + this.#subscribedEvents.add(event); + } + } + + #unsubscribeEvent(event) { + if (event === "eventemitter.testEvent") { + if (!this.#isSubscribed) { + throw new Error("Not subscribed to eventemitter.testEvent"); + } + this.#isSubscribed = false; + this.#subscribedEvents.delete(event); + } + } +} + +export const eventemitter = EventEmitterModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs new file mode 100644 index 0000000000..48bbfbf951 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs @@ -0,0 +1,16 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventNoInterceptModule extends Module { + destroy() {} + + testEvent() { + const text = `event no interception`; + this.emitEvent("eventnointercept.testEvent", { text }); + } +} + +export const eventnointercept = EventNoInterceptModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs new file mode 100644 index 0000000000..33cb25d10b --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs @@ -0,0 +1,33 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const TEST_PREF = "remote.messagehandler.test.pref"; + +class EventOnPrefChangeModule extends Module { + constructor(messageHandler) { + super(messageHandler); + Services.prefs.addObserver(TEST_PREF, this.#onPreferenceUpdated); + } + + destroy() { + Services.prefs.removeObserver(TEST_PREF, this.#onPreferenceUpdated); + } + + #onPreferenceUpdated = () => { + this.emitEvent("preference-changed"); + }; + + /** + * Commands + */ + + ping() { + // We only use this command to force creating the module. + return 1; + } +} + +export const eventonprefchange = EventOnPrefChangeModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs new file mode 100644 index 0000000000..f7b2279018 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs @@ -0,0 +1,84 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +// Store counters in the JSM scope to persist them across reloads. +let callsToBlockedOneTime = 0; +let callsToBlockedTenTimes = 0; +let callsToBlockedElevenTimes = 0; + +// This module provides various commands which all hang for various reasons. +// The test is supposed to trigger the command and then destroy the +// JSWindowActor pair by any mean (eg a navigation) in order to trigger an +// AbortError and a retry. +class RetryModule extends Module { + destroy() {} + + /** + * Commands + */ + + // Resolves only if called while on the example.net domain. + async blockedOnNetDomain(params) { + // Note: we do not store a call counter here, because this is used for a + // cross-group navigation test, and the JSM will be loaded in different + // processes. + const uri = this.messageHandler.window.document.baseURI; + if (!uri.includes("example.net")) { + await new Promise(r => {}); + } + + return { ...params }; + } + + // Resolves only if called more than once. + async blockedOneTime(params) { + callsToBlockedOneTime++; + if (callsToBlockedOneTime < 2) { + await new Promise(r => {}); + } + + // Return: + // - params sent to the command to check that retries have correct params + // - the call counter + return { ...params, callsToCommand: callsToBlockedOneTime }; + } + + // Resolves only if called more than ten times (which is exactly the maximum + // of retry attempts). + async blockedTenTimes(params) { + callsToBlockedTenTimes++; + if (callsToBlockedTenTimes < 11) { + await new Promise(r => {}); + } + + // Return: + // - params sent to the command to check that retries have correct params + // - the call counter + return { ...params, callsToCommand: callsToBlockedTenTimes }; + } + + // Resolves only if called more than eleven times (which is greater than the + // maximum of retry attempts). + async blockedElevenTimes(params) { + callsToBlockedElevenTimes++; + if (callsToBlockedElevenTimes < 12) { + await new Promise(r => {}); + } + + // Return: + // - params sent to the command to check that retries have correct params + // - the call counter + return { ...params, callsToCommand: callsToBlockedElevenTimes }; + } + + cleanup() { + callsToBlockedOneTime = 0; + callsToBlockedTenTimes = 0; + callsToBlockedElevenTimes = 0; + } +} + +export const retry = RetryModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs new file mode 100644 index 0000000000..5e9ce00b46 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs @@ -0,0 +1,33 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class SessionDataUpdateModule extends Module { + #sessionDataUpdates; + + constructor(messageHandler) { + super(messageHandler); + this.#sessionDataUpdates = []; + } + + destroy() {} + + /** + * Commands + */ + + _applySessionData(params) { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + this.#sessionDataUpdates.push(filteredSessionData); + } + + getSessionDataUpdates() { + return this.#sessionDataUpdates; + } +} + +export const sessiondataupdate = SessionDataUpdateModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs new file mode 100644 index 0000000000..815a836d9c --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs @@ -0,0 +1,47 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; +import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"; + +class WindowGlobalToRootModule extends Module { + constructor(messageHandler) { + super(messageHandler); + this.#assertContentProcess(); + } + + destroy() {} + + /** + * Commands + */ + + testHandleCommandToRoot(params, destination) { + return this.messageHandler.handleCommand({ + moduleName: "windowglobaltoroot", + commandName: "getValueFromRoot", + destination: { + type: RootMessageHandler.type, + }, + }); + } + + testSendRootCommand(params, destination) { + return this.messageHandler.sendRootCommand({ + moduleName: "windowglobaltoroot", + commandName: "getValueFromRoot", + }); + } + + #assertContentProcess() { + const isContent = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + + if (!isContent) { + throw new Error("Can only run in a content process"); + } + } +} + +export const windowglobaltoroot = WindowGlobalToRootModule; diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser.toml b/remote/shared/messagehandler/test/browser/webdriver/browser.toml new file mode 100644 index 0000000000..45ccca74ef --- /dev/null +++ b/remote/shared/messagehandler/test/browser/webdriver/browser.toml @@ -0,0 +1,7 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = ["!/remote/shared/messagehandler/test/browser/resources/*"] +prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"] + +["browser_session_execute_command_errors.js"] diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js new file mode 100644 index 0000000000..36a510bb29 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { WebDriverSession } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Session.sys.mjs" +); + +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); + +add_task(async function test_execute_missing_command_error() { + const session = new WebDriverSession(); + + info("Attempt to execute an unknown protocol command"); + await Assert.rejects( + session.execute("command", "missingCommand"), + err => + err.name == "UnknownCommandError" && + err.message == `command.missingCommand` + ); +}); + +add_task(async function test_execute_missing_internal_command_error() { + const session = new WebDriverSession(); + + info( + "Attempt to execute a protocol command which relies on an unknown internal method" + ); + await Assert.rejects( + session.execute("command", "testMissingIntermediaryMethod"), + err => + err.name == "UnsupportedCommandError" && + err.message == + `command.missingMethod not supported for destination ROOT` && + !error.isWebDriverError(err) + ); +}); diff --git a/remote/shared/messagehandler/test/xpcshell/test_Errors.js b/remote/shared/messagehandler/test/xpcshell/test_Errors.js new file mode 100644 index 0000000000..26187dac11 --- /dev/null +++ b/remote/shared/messagehandler/test/xpcshell/test_Errors.js @@ -0,0 +1,91 @@ +/* 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/. */ + +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/Errors.sys.mjs" +); + +// Note: this test file is similar to remote/shared/webdriver/test/xpcshell/test_Errors.js +// because shared/webdriver/Errors.jsm and shared/messagehandler/Errors.jsm share +// similar helpers. + +add_task(function test_toJSON() { + let e0 = new error.MessageHandlerError(); + let e0s = e0.toJSON(); + equal(e0s.error, "message handler error"); + equal(e0s.message, ""); + + let e1 = new error.MessageHandlerError("a"); + let e1s = e1.toJSON(); + equal(e1s.message, e1.message); + + let e2 = new error.UnsupportedCommandError("foo"); + let e2s = e2.toJSON(); + equal(e2.status, e2s.error); + equal(e2.message, e2s.message); +}); + +add_task(function test_fromJSON() { + Assert.throws( + () => error.MessageHandlerError.fromJSON({ error: "foo" }), + /Not of MessageHandlerError descent/ + ); + Assert.throws( + () => error.MessageHandlerError.fromJSON({ error: "Error" }), + /Not of MessageHandlerError descent/ + ); + Assert.throws( + () => error.MessageHandlerError.fromJSON({}), + /Undeserialisable error type/ + ); + Assert.throws( + () => error.MessageHandlerError.fromJSON(undefined), + /TypeError/ + ); + + let e1 = new error.MessageHandlerError("1"); + let e1r = error.MessageHandlerError.fromJSON({ + error: "message handler error", + message: "1", + }); + ok(e1r instanceof error.MessageHandlerError); + equal(e1r.name, e1.name); + equal(e1r.status, e1.status); + equal(e1r.message, e1.message); + + let e2 = new error.UnsupportedCommandError("foo"); + let e2r = error.MessageHandlerError.fromJSON({ + error: "unsupported message handler command", + message: "foo", + }); + ok(e2r instanceof error.MessageHandlerError); + ok(e2r instanceof error.UnsupportedCommandError); + equal(e2r.name, e2.name); + equal(e2r.status, e2.status); + equal(e2r.message, e2.message); + + // parity with toJSON + let e3 = new error.UnsupportedCommandError("foo"); + let e3toJSON = e3.toJSON(); + let e3fromJSON = error.MessageHandlerError.fromJSON(e3toJSON); + equal(e3toJSON.error, e3fromJSON.status); + equal(e3toJSON.message, e3fromJSON.message); + equal(e3toJSON.stacktrace, e3fromJSON.stack); +}); + +add_task(function test_MessageHandlerError() { + let err = new error.MessageHandlerError("foo"); + equal("MessageHandlerError", err.name); + equal("foo", err.message); + equal("message handler error", err.status); + ok(err instanceof error.MessageHandlerError); +}); + +add_task(function test_UnsupportedCommandError() { + let e = new error.UnsupportedCommandError("foo"); + equal("UnsupportedCommandError", e.name); + equal("foo", e.message); + equal("unsupported message handler command", e.status); + ok(e instanceof error.MessageHandlerError); +}); diff --git a/remote/shared/messagehandler/test/xpcshell/test_SessionData.js b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js new file mode 100644 index 0000000000..ef61ce27d4 --- /dev/null +++ b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js @@ -0,0 +1,296 @@ +/* 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/. */ + +const { ContextDescriptorType } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); +const { SessionData, SessionDataMethod } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs" +); + +add_task(async function test_sessionData() { + const sessionData = new SessionData(new RootMessageHandler("session-id-1")); + equal(sessionData.getSessionData("mod", "event").length, 0); + + const globalContext = { + type: ContextDescriptorType.All, + }; + const otherContext = { type: "other-type", id: "some-id" }; + + info("Add a first event for the global context"); + let updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + let updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value added"); + equal(updatedValues[0], "first.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + ]); + + info("Add the exact same data (same module, type, context, value)"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]), + ]); + equal(updatedItems.length, 0, "No new item updated"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + ]); + + info("Add another context for the same event"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value added"); + equal(updatedValues[0], "first.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + { + value: "first.event", + contextDescriptor: otherContext, + }, + ]); + + info("Add a second event for the global context"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value added"); + equal(updatedValues[0], "second.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + { + value: "first.event", + contextDescriptor: otherContext, + }, + { + value: "second.event", + contextDescriptor: globalContext, + }, + ]); + + info("Add two events for the global context"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, globalContext, [ + "third.event", + "fourth.event", + ]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 2, "Two values added"); + equal(updatedValues[0], "third.event", "Expected value was added"); + equal(updatedValues[1], "fourth.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + { + value: "first.event", + contextDescriptor: otherContext, + }, + { + value: "second.event", + contextDescriptor: globalContext, + }, + { + value: "third.event", + contextDescriptor: globalContext, + }, + { + value: "fourth.event", + contextDescriptor: globalContext, + }, + ]); + + info("Remove the second, third and fourth events"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, globalContext, [ + "second.event", + "third.event", + "fourth.event", + ]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 3, "Three values removed"); + equal(updatedValues[0], "second.event", "Expected value was removed"); + equal(updatedValues[1], "third.event", "Expected value was removed"); + equal(updatedValues[2], "fourth.event", "Expected value was removed"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + { + value: "first.event", + contextDescriptor: otherContext, + }, + ]); + + info("Remove the global context from the first event"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, globalContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value removed"); + equal(updatedValues[0], "first.event", "Expected value was removed"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: otherContext, + }, + ]); + + info("Remove the other context from the first event"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value removed"); + equal(updatedValues[0], "first.event", "Expected value was removed"); + checkEvents(sessionData.getSessionData("mod", "event"), []); + + info("Add two events for different contexts"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]), + createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]), + ]); + equal(updatedItems.length, 2, "Two items updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "First item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value for first item added"); + equal(updatedValues[0], "first.event", "Expected value first item was added"); + equal(updatedItems[1].method, SessionDataMethod.Add, "Second item added"); + updatedValues = updatedItems[1].values; + equal(updatedValues.length, 1, "One value for second item added"); + equal( + updatedValues[0], + "second.event", + "Expected value second item was added" + ); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: otherContext, + }, + { + value: "second.event", + contextDescriptor: globalContext, + }, + ]); + + info("Remove two events for different contexts"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]), + createUpdate(SessionDataMethod.Remove, globalContext, ["second.event"]), + ]); + equal(updatedItems.length, 2, "Two items updated"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "First item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value for first item removed"); + equal( + updatedValues[0], + "first.event", + "Expected value first item was removed" + ); + equal( + updatedItems[1].method, + SessionDataMethod.Remove, + "Second item removed" + ); + updatedValues = updatedItems[1].values; + equal(updatedValues.length, 1, "One value for second item removed"); + equal( + updatedValues[0], + "second.event", + "Expected value second item was removed" + ); + checkEvents(sessionData.getSessionData("mod", "event"), []); + + info("Add and remove event in different order"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]), + createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value added"); + equal(updatedValues[0], "first.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: otherContext, + }, + ]); + + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]), + createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "No item update"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value removed"); + equal(updatedValues[0], "first.event", "Expected value was removed"); + checkEvents(sessionData.getSessionData("mod", "event"), []); +}); + +function checkEvents(events, expectedEvents) { + // Check the arrays have the same size. + equal(events.length, expectedEvents.length); + + // Check all the expectedEvents can be found in the events array. + for (const expected of expectedEvents) { + ok( + events.some( + event => + expected.contextDescriptor.type === event.contextDescriptor.type && + expected.contextDescriptor.id === event.contextDescriptor.id && + expected.value == event.value + ) + ); + } +} + +function createUpdate(method, contextDescriptor, values) { + return { + method, + moduleName: "mod", + category: "event", + contextDescriptor, + values, + }; +} diff --git a/remote/shared/messagehandler/test/xpcshell/xpcshell.toml b/remote/shared/messagehandler/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..10f8b2f715 --- /dev/null +++ b/remote/shared/messagehandler/test/xpcshell/xpcshell.toml @@ -0,0 +1,5 @@ +[DEFAULT] + +["test_Errors.js"] + +["test_SessionData.js"] diff --git a/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs b/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs new file mode 100644 index 0000000000..482f90948a --- /dev/null +++ b/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs @@ -0,0 +1,57 @@ +/* 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/. */ + +function isExtensionContext(browsingContext) { + let principal; + if (CanonicalBrowsingContext.isInstance(browsingContext)) { + principal = browsingContext.currentWindowGlobal.documentPrincipal; + } else { + principal = browsingContext.window.document.nodePrincipal; + } + + // In practice, note that the principal will never be an expanded principal. + // The are only used for content scripts executed in a Sandbox, and do not + // have a browsing context on their own. + // But we still use this flag because there is no isAddonPrincipal flag. + return principal.isAddonOrExpandedAddonPrincipal; +} + +function isParentProcess(browsingContext) { + if (CanonicalBrowsingContext.isInstance(browsingContext)) { + return browsingContext.currentWindowGlobal.osPid === -1; + } + + // If `browsingContext` is not a `CanonicalBrowsingContext`, then we are + // necessarily in a content process page. + return false; +} + +/** + * Check if the given browsing context is valid for the message handler + * to use. + * + * @param {BrowsingContext} browsingContext + * The browsing context to check. + * @param {object=} options + * @param {string=} options.browserId + * The id of the browser to filter the browsing contexts by (optional). + * @returns {boolean} + * True if the browsing context is valid, false otherwise. + */ +export function isBrowsingContextCompatible(browsingContext, options = {}) { + const { browserId } = options; + + // If a browserId was provided, skip browsing contexts which are not + // associated with this browserId. + if (browserId !== undefined && browsingContext.browserId !== browserId) { + return false; + } + + // Skip: + // - extension contexts until we support debugging webextensions, see Bug 1755014. + // - privileged contexts until we support debugging Chrome context, see Bug 1713440. + return ( + !isExtensionContext(browsingContext) && !isParentProcess(browsingContext) + ); +} diff --git a/remote/shared/messagehandler/transports/RootTransport.sys.mjs b/remote/shared/messagehandler/transports/RootTransport.sys.mjs new file mode 100644 index 0000000000..b60d3726ef --- /dev/null +++ b/remote/shared/messagehandler/transports/RootTransport.sys.mjs @@ -0,0 +1,188 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + isBrowsingContextCompatible: + "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + MessageHandlerFrameActor: + "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const MAX_RETRY_ATTEMPTS = 10; + +/** + * RootTransport is intended to be used from a ROOT MessageHandler to communicate + * with WINDOW_GLOBAL MessageHandlers via the MessageHandlerFrame JSWindow + * actors. + */ +export class RootTransport { + /** + * @param {MessageHandler} messageHandler + * The MessageHandler instance which owns this RootTransport instance. + */ + constructor(messageHandler) { + this._messageHandler = messageHandler; + + // RootTransport will rely on the MessageHandlerFrame JSWindow actors. + // Make sure they are registered when instanciating a RootTransport. + lazy.MessageHandlerFrameActor.register(); + } + + /** + * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the + * MessageHandlerFrame actors. + * + * @param {Command} command + * The command to forward. See type definition in MessageHandler.js + * @returns {Promise} + * Returns a promise that resolves with the result of the command after + * being processed by WINDOW_GLOBAL MessageHandlers. + */ + forwardCommand(command) { + if (command.destination.id && command.destination.contextDescriptor) { + throw new Error( + "Invalid command destination with both 'id' and 'contextDescriptor' properties" + ); + } + + // With an id given forward the command to only this specific destination. + if (command.destination.id) { + const browsingContext = BrowsingContext.get(command.destination.id); + if (!browsingContext) { + throw new Error( + "Unable to find a BrowsingContext for id " + command.destination.id + ); + } + return this._sendCommandToBrowsingContext(command, browsingContext); + } + + // ... otherwise broadcast to destinations matching the contextDescriptor. + if (command.destination.contextDescriptor) { + return this._broadcastCommand(command); + } + + throw new Error( + "Unrecognized command destination, missing 'id' or 'contextDescriptor' properties" + ); + } + + _broadcastCommand(command) { + const { contextDescriptor } = command.destination; + const browsingContexts = + this._getBrowsingContextsForDescriptor(contextDescriptor); + + return Promise.all( + browsingContexts.map(async browsingContext => { + try { + return await this._sendCommandToBrowsingContext( + command, + browsingContext + ); + } catch (e) { + console.error( + `Failed to broadcast a command to browsingContext ${browsingContext.id}`, + e + ); + return null; + } + }) + ); + } + + async _sendCommandToBrowsingContext(command, browsingContext) { + const name = `${command.moduleName}.${command.commandName}`; + + // The browsing context might be destroyed by a navigation. Keep a reference + // to the webProgress, which will persist, and always use it to retrieve the + // currently valid browsing context. + const webProgress = browsingContext.webProgress; + + const { retryOnAbort = false } = command; + + let attempts = 0; + while (true) { + try { + return await webProgress.browsingContext.currentWindowGlobal + .getActor("MessageHandlerFrame") + .sendCommand(command, this._messageHandler.sessionId); + } catch (e) { + if (!retryOnAbort || e.name != "AbortError") { + // Only retry if the command supports retryOnAbort and when the + // JSWindowActor pair gets destroyed. + throw e; + } + + if (++attempts > MAX_RETRY_ATTEMPTS) { + lazy.logger.trace( + `RootTransport reached the limit of retry attempts (${MAX_RETRY_ATTEMPTS})` + + ` for command ${name} and browsing context ${webProgress.browsingContext.id}.` + ); + throw e; + } + + lazy.logger.trace( + `RootTransport retrying command ${name} for ` + + `browsing context ${webProgress.browsingContext.id}, attempt: ${attempts}.` + ); + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + } + } + } + + toString() { + return `[object ${this.constructor.name} ${this._messageHandler.name}]`; + } + + _getBrowsingContextsForDescriptor(contextDescriptor) { + const { id, type } = contextDescriptor; + + if (type === lazy.ContextDescriptorType.All) { + return this._getBrowsingContexts(); + } + + if (type === lazy.ContextDescriptorType.TopBrowsingContext) { + return this._getBrowsingContexts({ browserId: id }); + } + + // TODO: Handle other types of context descriptors. + throw new Error( + `Unsupported contextDescriptor type for broadcasting: ${type}` + ); + } + + /** + * Get all browsing contexts, optionally matching the provided options. + * + * @param {object} options + * @param {string=} options.browserId + * The id of the browser to filter the browsing contexts by (optional). + * @returns {Array<BrowsingContext>} + * The browsing contexts matching the provided options or all browsing contexts + * if no options are provided. + */ + _getBrowsingContexts(options = {}) { + // extract browserId from options + const { browserId } = options; + let browsingContexts = []; + + // Fetch all tab related browsing contexts for top-level windows. + for (const { browsingContext } of lazy.TabManager.browsers) { + if (lazy.isBrowsingContextCompatible(browsingContext, { browserId })) { + browsingContexts = browsingContexts.concat( + browsingContext.getAllBrowsingContextsInSubtree() + ); + } + } + + return browsingContexts; + } +} diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs new file mode 100644 index 0000000000..c236cebac7 --- /dev/null +++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs @@ -0,0 +1,51 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs", + + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const FRAME_ACTOR_CONFIG = { + parent: { + esModuleURI: + "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs", + }, + child: { + esModuleURI: + "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs", + events: { + DOMWindowCreated: {}, + pagehide: {}, + pageshow: {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], +}; + +/** + * MessageHandlerFrameActor exposes a simple registration helper to lazily + * register MessageHandlerFrame JSWindow actors. + */ +export const MessageHandlerFrameActor = { + registered: false, + + register() { + if (this.registered) { + return; + } + + lazy.ActorManagerParent.addJSWindowActors({ + MessageHandlerFrame: FRAME_ACTOR_CONFIG, + }); + this.registered = true; + lazy.logger.trace("Registered MessageHandlerFrame actors"); + }, +}; diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs new file mode 100644 index 0000000000..52a8fdc4c9 --- /dev/null +++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs @@ -0,0 +1,111 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + isBrowsingContextCompatible: + "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs", + MessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +/** + * Map from MessageHandlerRegistry to MessageHandlerFrameChild actor. This will + * allow a WindowGlobalMessageHandler to find the JSWindowActorChild instance to + * use to send commands. + */ +const registryToActor = new WeakMap(); + +/** + * Retrieve the MessageHandlerFrameChild which is linked to the provided + * WindowGlobalMessageHandler instance. + * + * @param {WindowGlobalMessageHandler} messageHandler + * The WindowGlobalMessageHandler for which to get the JSWindowActor. + * @returns {MessageHandlerFrameChild} + * The corresponding MessageHandlerFrameChild instance. + */ +export function getMessageHandlerFrameChildActor(messageHandler) { + return registryToActor.get(messageHandler.registry); +} + +/** + * Child actor for the MessageHandlerFrame JSWindowActor. The + * MessageHandlerFrame actor is used by RootTransport to communicate between + * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers. + */ +export class MessageHandlerFrameChild extends JSWindowActorChild { + actorCreated() { + this.type = lazy.WindowGlobalMessageHandler.type; + this.context = this.manager.browsingContext; + + this._registry = new lazy.MessageHandlerRegistry(this.type, this.context); + registryToActor.set(this._registry, this); + + this._onRegistryEvent = this._onRegistryEvent.bind(this); + + // MessageHandlerFrameChild is responsible for forwarding events from + // WindowGlobalMessageHandler to the parent process. + // Such events are re-emitted on the MessageHandlerRegistry to avoid + // setting up listeners on individual MessageHandler instances. + this._registry.on("message-handler-registry-event", this._onRegistryEvent); + } + + handleEvent({ persisted, type }) { + if (type == "DOMWindowCreated" || (type == "pageshow" && persisted)) { + // When the window is created or is retrieved from BFCache, instantiate + // a MessageHandler for all sessions which might need it. + if (lazy.isBrowsingContextCompatible(this.manager.browsingContext)) { + this._registry.createAllMessageHandlers(); + } + } else if (type == "pagehide" && persisted) { + // When the page is moved to BFCache, all the currently created message + // handlers should be destroyed. + this._registry.destroy(); + } + } + + async receiveMessage(message) { + if (message.name === "MessageHandlerFrameParent:sendCommand") { + const { sessionId, command } = message.data; + const messageHandler = + this._registry.getOrCreateMessageHandler(sessionId); + try { + return await messageHandler.handleCommand(command); + } catch (e) { + if (e?.isRemoteError) { + return { + error: e.toJSON(), + isMessageHandlerError: e.isMessageHandlerError, + }; + } + throw e; + } + } + + return null; + } + + sendCommand(command, sessionId) { + return this.sendQuery("MessageHandlerFrameChild:sendCommand", { + command, + sessionId, + }); + } + + _onRegistryEvent(eventName, wrappedEvent) { + this.sendAsyncMessage( + "MessageHandlerFrameChild:messageHandlerEvent", + wrappedEvent + ); + } + + didDestroy() { + this._registry.off("message-handler-registry-event", this._onRegistryEvent); + this._registry.destroy(); + } +} diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs new file mode 100644 index 0000000000..a4901571d9 --- /dev/null +++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs @@ -0,0 +1,127 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + RootMessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +ChromeUtils.defineLazyGetter(lazy, "WebDriverError", () => { + return ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" + ).error.WebDriverError; +}); + +/** + * Parent actor for the MessageHandlerFrame JSWindowActor. The + * MessageHandlerFrame actor is used by RootTransport to communicate between + * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers. + */ +export class MessageHandlerFrameParent extends JSWindowActorParent { + async receiveMessage(message) { + switch (message.name) { + case "MessageHandlerFrameChild:sendCommand": { + return this.#handleSendCommandMessage(message.data); + } + case "MessageHandlerFrameChild:messageHandlerEvent": { + return this.#handleMessageHandlerEventMessage(message.data); + } + default: + throw new Error("Unsupported message:" + message.name); + } + } + + /** + * Send a command to the corresponding MessageHandlerFrameChild actor via a + * JSWindowActor query. + * + * @param {Command} command + * The command to forward. See type definition in MessageHandler.js + * @param {string} sessionId + * ID of the session that sent the command. + * @returns {Promise} + * Promise that will resolve with the result of query sent to the + * MessageHandlerFrameChild actor. + */ + async sendCommand(command, sessionId) { + const result = await this.sendQuery( + "MessageHandlerFrameParent:sendCommand", + { + command, + sessionId, + } + ); + + if (result?.error) { + if (result.isMessageHandlerError) { + throw lazy.error.MessageHandlerError.fromJSON(result.error); + } + + // TODO: Do not assume WebDriver is the session protocol, see Bug 1779026. + throw lazy.WebDriverError.fromJSON(result.error); + } + + return result; + } + + async #handleMessageHandlerEventMessage(messageData) { + const { name, contextInfo, data, sessionId } = messageData; + const [moduleName] = name.split("."); + + // Re-emit the event on the RootMessageHandler. + const messageHandler = + lazy.RootMessageHandlerRegistry.getExistingMessageHandler(sessionId); + // TODO: getModuleInstance expects a CommandDestination in theory, + // but only uses the MessageHandler type in practice, see Bug 1776389. + const module = messageHandler.moduleCache.getModuleInstance(moduleName, { + type: lazy.WindowGlobalMessageHandler.type, + }); + let eventPayload = data; + + // Modify an event payload if there is a special method in the targeted module. + // If present it can be found in windowglobal-in-root module. + if (module?.interceptEvent) { + eventPayload = await module.interceptEvent(name, data); + + if (eventPayload === null) { + lazy.logger.trace( + `${moduleName}.interceptEvent returned null, skipping event: ${name}, data: ${data}` + ); + return; + } + // Make sure that an event payload is returned. + if (!eventPayload) { + throw new Error( + `${moduleName}.interceptEvent doesn't return the event payload` + ); + } + } + messageHandler.emitEvent(name, eventPayload, contextInfo); + } + + async #handleSendCommandMessage(messageData) { + const { sessionId, command } = messageData; + const messageHandler = + lazy.RootMessageHandlerRegistry.getExistingMessageHandler(sessionId); + try { + return await messageHandler.handleCommand(command); + } catch (e) { + if (e?.isRemoteError) { + return { + error: e.toJSON(), + isMessageHandlerError: e.isMessageHandlerError, + }; + } + throw e; + } + } +} diff --git a/remote/shared/moz.build b/remote/shared/moz.build new file mode 100644 index 0000000000..69b7d9e8a1 --- /dev/null +++ b/remote/shared/moz.build @@ -0,0 +1,17 @@ +# 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/. + +BROWSER_CHROME_MANIFESTS += [ + "listeners/test/browser/browser.toml", + "messagehandler/test/browser/broadcast/browser.toml", + "messagehandler/test/browser/browser.toml", + "messagehandler/test/browser/webdriver/browser.toml", + "test/browser/browser.toml", +] + +XPCSHELL_TESTS_MANIFESTS += [ + "messagehandler/test/xpcshell/xpcshell.toml", + "test/xpcshell/xpcshell.toml", + "webdriver/test/xpcshell/xpcshell.toml", +] diff --git a/remote/shared/test/browser/browser.toml b/remote/shared/test/browser/browser.toml new file mode 100644 index 0000000000..de336a1cb7 --- /dev/null +++ b/remote/shared/test/browser/browser.toml @@ -0,0 +1,16 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = ["head.js"] + +["browser_NavigationManager.js"] + +["browser_NavigationManager_failed_navigation.js"] + +["browser_NavigationManager_no_navigation.js"] + +["browser_NavigationManager_notify.js"] + +["browser_TabManager.js"] + +["browser_UserContextManager.js"] diff --git a/remote/shared/test/browser/browser_NavigationManager.js b/remote/shared/test/browser/browser_NavigationManager.js new file mode 100644 index 0000000000..7e0464c2fa --- /dev/null +++ b/remote/shared/test/browser/browser_NavigationManager.js @@ -0,0 +1,372 @@ +/* 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/. */ + +const { NavigationManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/NavigationManager.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +const FIRST_URL = "https://example.com/document-builder.sjs?html=first"; +const SECOND_URL = "https://example.com/document-builder.sjs?html=second"; +const THIRD_URL = "https://example.com/document-builder.sjs?html=third"; + +const FIRST_COOP_URL = + "https://example.com/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=first_coop"; +const SECOND_COOP_URL = + "https://example.net/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=second_coop"; + +add_task(async function test_simpleNavigation() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const tab = addTab(gBrowser, FIRST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + const navigableId = TabManager.getIdForBrowser(browser); + + navigationManager.startMonitoring(); + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation recorded yet" + ); + is(events.length, 0, "No event recorded"); + + await loadURL(browser, SECOND_URL); + + const firstNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(firstNavigation, SECOND_URL); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + SECOND_URL, + firstNavigation.navigationId, + navigableId + ); + + await loadURL(browser, THIRD_URL); + + const secondNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(secondNavigation, THIRD_URL); + assertUniqueNavigationIds(firstNavigation, secondNavigation); + + is(events.length, 4, "Two new events recorded"); + assertNavigationEvents( + events, + THIRD_URL, + secondNavigation.navigationId, + navigableId + ); + + navigationManager.stopMonitoring(); + + // Navigate again to the first URL + await loadURL(browser, FIRST_URL); + is(events.length, 4, "No new event recorded"); + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation recorded" + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); +}); + +add_task(async function test_loadTwoTabsSimultaneously() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + info("Add two tabs simultaneously"); + const tab1 = addTab(gBrowser, FIRST_URL); + const browser1 = tab1.linkedBrowser; + const navigableId1 = TabManager.getIdForBrowser(browser1); + const onLoad1 = BrowserTestUtils.browserLoaded(browser1, false, FIRST_URL); + + const tab2 = addTab(gBrowser, SECOND_URL); + const browser2 = tab2.linkedBrowser; + const navigableId2 = TabManager.getIdForBrowser(browser2); + const onLoad2 = BrowserTestUtils.browserLoaded(browser2, false, SECOND_URL); + + info("Wait for the tabs to load"); + await Promise.all([onLoad1, onLoad2]); + + is(events.length, 4, "Recorded 4 navigation events"); + + info("Check navigation monitored for tab1"); + const nav1 = navigationManager.getNavigationForBrowsingContext( + browser1.browsingContext + ); + assertNavigation(nav1, FIRST_URL); + assertNavigationEvents(events, FIRST_URL, nav1.navigationId, navigableId1); + + info("Check navigation monitored for tab2"); + const nav2 = navigationManager.getNavigationForBrowsingContext( + browser2.browsingContext + ); + assertNavigation(nav2, SECOND_URL); + assertNavigationEvents(events, SECOND_URL, nav2.navigationId, navigableId2); + assertUniqueNavigationIds(nav1, nav2); + + info("Reload the two tabs simultaneously"); + await Promise.all([ + BrowserTestUtils.reloadTab(tab1), + BrowserTestUtils.reloadTab(tab2), + ]); + + is(events.length, 8, "Recorded 8 navigation events"); + + info("Check the second navigation for tab1"); + const nav3 = navigationManager.getNavigationForBrowsingContext( + browser1.browsingContext + ); + assertNavigation(nav3, FIRST_URL); + assertNavigationEvents(events, FIRST_URL, nav3.navigationId, navigableId1); + + info("Check the second navigation monitored for tab2"); + const nav4 = navigationManager.getNavigationForBrowsingContext( + browser2.browsingContext + ); + assertNavigation(nav4, SECOND_URL); + assertNavigationEvents(events, SECOND_URL, nav4.navigationId, navigableId2); + assertUniqueNavigationIds(nav1, nav2, nav3, nav4); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function test_loadPageWithIframes() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + info("Add a tab with iframes"); + const testUrl = createTestPageWithFrames(); + const tab = addTab(gBrowser, testUrl); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, testUrl); + + is(events.length, 8, "Recorded 8 navigation events"); + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + + const navigations = []; + for (const context of contexts) { + const navigation = + navigationManager.getNavigationForBrowsingContext(context); + const navigable = TabManager.getIdForBrowsingContext(context); + + const url = context.currentWindowGlobal.documentURI.spec; + assertNavigation(navigation, url); + assertNavigationEvents(events, url, navigation.navigationId, navigable); + navigations.push(navigation); + } + assertUniqueNavigationIds(...navigations); + + await BrowserTestUtils.reloadTab(tab); + + is(events.length, 16, "Recorded 8 additional navigation events"); + const newContexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + + for (const context of newContexts) { + const navigation = + navigationManager.getNavigationForBrowsingContext(context); + const navigable = TabManager.getIdForBrowsingContext(context); + + const url = context.currentWindowGlobal.documentURI.spec; + assertNavigation(navigation, url); + assertNavigationEvents(events, url, navigation.navigationId, navigable); + navigations.push(navigation); + } + assertUniqueNavigationIds(...navigations); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function test_loadPageWithCoop() { + const tab = addTab(gBrowser, FIRST_COOP_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, FIRST_COOP_URL); + + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + const navigableId = TabManager.getIdForBrowser(browser); + await loadURL(browser, SECOND_COOP_URL); + + const coopNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(coopNavigation, SECOND_COOP_URL); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + SECOND_COOP_URL, + coopNavigation.navigationId, + navigableId + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function test_sameDocumentNavigation() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("location-changed", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const url = "https://example.com/document-builder.sjs?html=test"; + const tab = addTab(gBrowser, url); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + navigationManager.startMonitoring(); + const navigableId = TabManager.getIdForBrowser(browser); + + is(events.length, 0, "No event recorded"); + + info("Perform a same-document navigation"); + let onLocationChanged = navigationManager.once("location-changed"); + BrowserTestUtils.startLoadingURIString(browser, url + "#hash"); + await onLocationChanged; + + const hashNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + is(events.length, 1, "Recorded 1 navigation event"); + assertNavigationEvents( + events, + url + "#hash", + hashNavigation.navigationId, + navigableId, + true + ); + + // Navigate from `url + "#hash"` to `url`, this will trigger a regular + // navigation and we can use `loadURL` to properly wait for the navigation to + // complete. + info("Perform a regular navigation"); + await loadURL(browser, url); + + const regularNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + is(events.length, 3, "Recorded 2 additional navigation events"); + assertNavigationEvents( + events, + url, + regularNavigation.navigationId, + navigableId + ); + + info("Perform another same-document navigation"); + onLocationChanged = navigationManager.once("location-changed"); + BrowserTestUtils.startLoadingURIString(browser, url + "#foo"); + await onLocationChanged; + + const otherHashNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + + is(events.length, 4, "Recorded 1 additional navigation event"); + + info("Perform a same-hash navigation"); + onLocationChanged = navigationManager.once("location-changed"); + BrowserTestUtils.startLoadingURIString(browser, url + "#foo"); + await onLocationChanged; + + const sameHashNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + + is(events.length, 5, "Recorded 1 additional navigation event"); + assertNavigationEvents( + events, + url + "#foo", + sameHashNavigation.navigationId, + navigableId, + true + ); + + assertUniqueNavigationIds([ + hashNavigation, + regularNavigation, + otherHashNavigation, + sameHashNavigation, + ]); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("location-changed", onEvent); + navigationManager.off("navigation-stopped", onEvent); + + navigationManager.stopMonitoring(); +}); + +add_task(async function test_startNavigationAndCloseTab() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const tab = addTab(gBrowser, FIRST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + navigationManager.startMonitoring(); + loadURL(browser, SECOND_URL); + gBrowser.removeTab(tab); + + // On top of the assertions below, the test also validates that there is no + // unhandled promise rejection related to handling the navigation-started event + // for the destroyed browsing context. + is(events.length, 0, "No event was received"); + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation was recorded for the destroyed tab" + ); + navigationManager.stopMonitoring(); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); +}); diff --git a/remote/shared/test/browser/browser_NavigationManager_failed_navigation.js b/remote/shared/test/browser/browser_NavigationManager_failed_navigation.js new file mode 100644 index 0000000000..70c695b7ac --- /dev/null +++ b/remote/shared/test/browser/browser_NavigationManager_failed_navigation.js @@ -0,0 +1,99 @@ +/* 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/. */ + +const { NavigationManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/NavigationManager.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +const TEST_URL = "https://example.com/document-builder.sjs?html=test1"; +const TEST_URL_CLOSED_PORT = "http://127.0.0.1:36325/"; +const TEST_URL_WRONG_URI = "https://www.wronguri.wronguri/"; + +add_task(async function testClosedPort() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const tab = addTab(gBrowser, TEST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + const navigableId = TabManager.getIdForBrowser(browser); + + navigationManager.startMonitoring(); + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation recorded yet" + ); + is(events.length, 0, "No event recorded"); + + await loadURL(browser, TEST_URL_CLOSED_PORT, { maybeErrorPage: true }); + + const firstNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(firstNavigation, TEST_URL_CLOSED_PORT); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + TEST_URL_CLOSED_PORT, + firstNavigation.navigationId, + navigableId + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function testWrongURI() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const tab = addTab(gBrowser, TEST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + const navigableId = TabManager.getIdForBrowser(browser); + + navigationManager.startMonitoring(); + + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation recorded yet" + ); + is(events.length, 0, "No event recorded"); + + await loadURL(browser, TEST_URL_WRONG_URI, { maybeErrorPage: true }); + + const firstNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(firstNavigation, TEST_URL_WRONG_URI); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + TEST_URL_WRONG_URI, + firstNavigation.navigationId, + navigableId + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); diff --git a/remote/shared/test/browser/browser_NavigationManager_no_navigation.js b/remote/shared/test/browser/browser_NavigationManager_no_navigation.js new file mode 100644 index 0000000000..370c09d351 --- /dev/null +++ b/remote/shared/test/browser/browser_NavigationManager_no_navigation.js @@ -0,0 +1,60 @@ +/* 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/. */ + +const { NavigationManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/NavigationManager.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +add_task(async function testDocumentOpenWriteClose() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("location-changed", onEvent); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const url = "https://example.com/document-builder.sjs?html=test"; + + const tab = addTab(gBrowser, url); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + navigationManager.startMonitoring(); + is(events.length, 0, "No event recorded"); + + info("Replace the document"); + await SpecialPowers.spawn(browser, [], async () => { + // Note: we need to use eval here to have reduced permissions and avoid + // security errors. + content.eval(` + document.open(); + document.write("<h1 class='replaced'>Replaced</h1>"); + document.close(); + `); + + await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(".replaced") + ); + }); + + // See Bug 1844517. + // document.open/write/close is identical to same-url + same-hash navigations. + todo_is(events.length, 0, "No event recorded after replacing the document"); + + info("Reload the page, which should trigger a navigation"); + await loadURL(browser, url); + + // See Bug 1844517. + // document.open/write/close is identical to same-url + same-hash navigations. + todo_is(events.length, 2, "Recorded navigation events"); + + navigationManager.off("location-changed", onEvent); + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); diff --git a/remote/shared/test/browser/browser_NavigationManager_notify.js b/remote/shared/test/browser/browser_NavigationManager_notify.js new file mode 100644 index 0000000000..4dca0f7b4e --- /dev/null +++ b/remote/shared/test/browser/browser_NavigationManager_notify.js @@ -0,0 +1,170 @@ +/* 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/. */ + +const { NavigationManager, notifyNavigationStarted, notifyNavigationStopped } = + ChromeUtils.importESModule( + "chrome://remote/content/shared/NavigationManager.sys.mjs" + ); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +const FIRST_URL = "https://example.com/document-builder.sjs?html=first"; +const SECOND_URL = "https://example.com/document-builder.sjs?html=second"; + +add_task(async function test_notifyNavigationStartedStopped() { + const tab = addTab(gBrowser, FIRST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, FIRST_URL); + + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + const navigableId = TabManager.getIdForBrowser(browser); + + info("Programmatically start a navigation"); + const startedNavigation = notifyNavigationStarted({ + contextDetails: { + context: browser.browsingContext, + }, + url: SECOND_URL, + }); + + const navigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(navigation, SECOND_URL); + + is( + startedNavigation, + navigation, + "notifyNavigationStarted returned the expected navigation" + ); + is(events.length, 1, "Only one event recorded"); + + info("Attempt to start a navigation while another one is in progress"); + const alreadyStartedNavigation = notifyNavigationStarted({ + contextDetails: { + context: browser.browsingContext, + }, + url: SECOND_URL, + }); + is( + alreadyStartedNavigation, + navigation, + "notifyNavigationStarted returned the ongoing navigation" + ); + is(events.length, 1, "Still only one event recorded"); + + info("Programmatically stop the navigation"); + const stoppedNavigation = notifyNavigationStopped({ + contextDetails: { + context: browser.browsingContext, + }, + url: SECOND_URL, + }); + is( + stoppedNavigation, + navigation, + "notifyNavigationStopped returned the expected navigation" + ); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + SECOND_URL, + navigation.navigationId, + navigableId + ); + + info("Attempt to stop an already stopped navigation"); + const alreadyStoppedNavigation = notifyNavigationStopped({ + contextDetails: { + context: browser.browsingContext, + }, + url: SECOND_URL, + }); + is( + alreadyStoppedNavigation, + navigation, + "notifyNavigationStopped returned the already stopped navigation" + ); + is(events.length, 2, "Still only two events recorded"); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function test_notifyNavigationWithContextDetails() { + const tab = addTab(gBrowser, FIRST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, FIRST_URL); + + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + const navigableId = TabManager.getIdForBrowser(browser); + + info("Programmatically start a navigation using browsing context details"); + const startedNavigation = notifyNavigationStarted({ + contextDetails: { + browsingContextId: browser.browsingContext.id, + browserId: browser.browsingContext.browserId, + isTopBrowsingContext: browser.browsingContext.parent === null, + }, + url: SECOND_URL, + }); + + const navigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(navigation, SECOND_URL); + + is( + startedNavigation, + navigation, + "notifyNavigationStarted returned the expected navigation" + ); + is(events.length, 1, "Only one event recorded"); + + info("Programmatically stop the navigation using browsing context details"); + const stoppedNavigation = notifyNavigationStopped({ + contextDetails: { + browsingContextId: browser.browsingContext.id, + browserId: browser.browsingContext.browserId, + isTopBrowsingContext: browser.browsingContext.parent === null, + }, + url: SECOND_URL, + }); + is( + stoppedNavigation, + navigation, + "notifyNavigationStopped returned the expected navigation" + ); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + SECOND_URL, + navigation.navigationId, + navigableId + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); diff --git a/remote/shared/test/browser/browser_TabManager.js b/remote/shared/test/browser/browser_TabManager.js new file mode 100644 index 0000000000..fdc0d5c8b1 --- /dev/null +++ b/remote/shared/test/browser/browser_TabManager.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +const FRAME_URL = "https://example.com/document-builder.sjs?html=frame"; +const FRAME_MARKUP = `<iframe src="${encodeURI(FRAME_URL)}"></iframe>`; +const TEST_URL = `https://example.com/document-builder.sjs?html=${encodeURI( + FRAME_MARKUP +)}`; + +add_task(async function test_getBrowsingContextById() { + const browser = gBrowser.selectedBrowser; + + is(TabManager.getBrowsingContextById(null), null); + is(TabManager.getBrowsingContextById(undefined), null); + is(TabManager.getBrowsingContextById("wrong-id"), null); + + info(`Navigate to ${TEST_URL}`); + await loadURL(browser, TEST_URL); + + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 2, "Top context has 1 child"); + + const topContextId = TabManager.getIdForBrowsingContext(contexts[0]); + is(TabManager.getBrowsingContextById(topContextId), contexts[0]); + const childContextId = TabManager.getIdForBrowsingContext(contexts[1]); + is(TabManager.getBrowsingContextById(childContextId), contexts[1]); +}); + +add_task(async function test_addTab_focus() { + let tabsCount = gBrowser.tabs.length; + + let newTab1, newTab2, newTab3; + try { + newTab1 = await TabManager.addTab({ focus: true }); + + ok(gBrowser.tabs.includes(newTab1), "A new tab was created"); + is(gBrowser.tabs.length, tabsCount + 1); + is(gBrowser.selectedTab, newTab1, "Tab added with focus: true is selected"); + + newTab2 = await TabManager.addTab({ focus: false }); + + ok(gBrowser.tabs.includes(newTab2), "A new tab was created"); + is(gBrowser.tabs.length, tabsCount + 2); + is( + gBrowser.selectedTab, + newTab1, + "Tab added with focus: false is not selected" + ); + + newTab3 = await TabManager.addTab(); + + ok(gBrowser.tabs.includes(newTab3), "A new tab was created"); + is(gBrowser.tabs.length, tabsCount + 3); + is( + gBrowser.selectedTab, + newTab1, + "Tab added with no focus parameter is not selected (defaults to false)" + ); + } finally { + gBrowser.removeTab(newTab1); + gBrowser.removeTab(newTab2); + gBrowser.removeTab(newTab3); + } +}); + +add_task(async function test_addTab_referenceTab() { + let tab1, tab2, tab3, tab4; + try { + tab1 = await TabManager.addTab(); + // Add a second tab with no referenceTab, should be added at the end. + tab2 = await TabManager.addTab(); + // Add a third tab with tab1 as referenceTab, should be added right after tab1. + tab3 = await TabManager.addTab({ referenceTab: tab1 }); + // Add a fourth tab with tab2 as referenceTab, should be added right after tab2. + tab4 = await TabManager.addTab({ referenceTab: tab2 }); + + // Check that the tab order is as expected: tab1 > tab3 > tab2 > tab4 + const tab1Index = gBrowser.tabs.indexOf(tab1); + is(gBrowser.tabs[tab1Index + 1], tab3); + is(gBrowser.tabs[tab1Index + 2], tab2); + is(gBrowser.tabs[tab1Index + 3], tab4); + } finally { + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab3); + gBrowser.removeTab(tab4); + } +}); + +add_task(async function test_addTab_window() { + const win1 = await BrowserTestUtils.openNewBrowserWindow(); + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + try { + // openNewBrowserWindow should ensure the new window is focused. + is(Services.wm.getMostRecentBrowserWindow(null), win2); + + const newTab1 = await TabManager.addTab({ window: win1 }); + is( + newTab1.ownerGlobal, + win1, + "The new tab was opened in the specified window" + ); + + const newTab2 = await TabManager.addTab({ window: win2 }); + is( + newTab2.ownerGlobal, + win2, + "The new tab was opened in the specified window" + ); + + const newTab3 = await TabManager.addTab(); + is( + newTab3.ownerGlobal, + win2, + "The new tab was opened in the foreground window" + ); + } finally { + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + } +}); + +add_task(async function test_getNavigableForBrowsingContext() { + const browser = gBrowser.selectedBrowser; + + info(`Navigate to ${TEST_URL}`); + await loadURL(browser, TEST_URL); + + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 2, "Top context has 1 child"); + + // For a top-level browsing context the content browser is returned. + const topContext = contexts[0]; + is( + TabManager.getNavigableForBrowsingContext(topContext), + browser, + "Top-Level browsing context has the content browser as navigable" + ); + + // For child browsing contexts the browsing context itself is returned. + const childContext = contexts[1]; + is( + TabManager.getNavigableForBrowsingContext(childContext), + childContext, + "Child browsing context has itself as navigable" + ); + + const invalidValues = [undefined, null, 1, "test", {}, []]; + for (const invalidValue of invalidValues) { + Assert.throws( + () => TabManager.getNavigableForBrowsingContext(invalidValue), + /Expected browsingContext to be a CanonicalBrowsingContext/ + ); + } +}); + +add_task(async function test_getTabForBrowsingContext() { + const tab = await TabManager.addTab(); + try { + const browser = tab.linkedBrowser; + + info(`Navigate to ${TEST_URL}`); + await loadURL(browser, TEST_URL); + + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + is(TabManager.getTabForBrowsingContext(contexts[0]), tab); + is(TabManager.getTabForBrowsingContext(contexts[1]), tab); + is(TabManager.getTabForBrowsingContext(null), null); + } finally { + gBrowser.removeTab(tab); + } +}); diff --git a/remote/shared/test/browser/browser_UserContextManager.js b/remote/shared/test/browser/browser_UserContextManager.js new file mode 100644 index 0000000000..2060c2bacd --- /dev/null +++ b/remote/shared/test/browser/browser_UserContextManager.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { UserContextManagerClass } = ChromeUtils.importESModule( + "chrome://remote/content/shared/UserContextManager.sys.mjs" +); + +add_task(async function test_invalid() { + const userContextManager = new UserContextManagerClass(); + + // Check invalid types for hasUserContextId/getInternalIdById which expects + // a string. + for (const value of [null, undefined, 1, [], {}]) { + is(userContextManager.hasUserContextId(value), false); + is(userContextManager.getInternalIdById(value), null); + } + + // Check an invalid value for hasUserContextId/getInternalIdById which expects + // either "default" or a UUID from Services.uuid.generateUUID. + is(userContextManager.hasUserContextId("foo"), false); + is(userContextManager.getInternalIdById("foo"), null); + + // Check invalid types for getIdByInternalId which expects a number. + for (const value of [null, undefined, "foo", [], {}]) { + is(userContextManager.getIdByInternalId(value), null); + } + + userContextManager.destroy(); +}); + +add_task(async function test_default_context() { + const userContextManager = new UserContextManagerClass(); + ok( + userContextManager.hasUserContextId("default"), + `Context id default is known by the manager` + ); + ok( + userContextManager.getUserContextIds().includes("default"), + `Context id default is listed by the manager` + ); + is( + userContextManager.getInternalIdById("default"), + 0, + "Default user context has the expected internal id" + ); + + userContextManager.destroy(); +}); + +add_task(async function test_new_internal_contexts() { + info("Create a new user context with ContextualIdentityService"); + const beforeInternalId = + ContextualIdentityService.create("before").userContextId; + + info("Create the UserContextManager"); + const userContextManager = new UserContextManagerClass(); + + const beforeContextId = + userContextManager.getIdByInternalId(beforeInternalId); + assertContextAvailable(userContextManager, beforeContextId, beforeInternalId); + + info("Create another user context with ContextualIdentityService"); + const afterInternalId = + ContextualIdentityService.create("after").userContextId; + const afterContextId = userContextManager.getIdByInternalId(afterInternalId); + assertContextAvailable(userContextManager, afterContextId, afterInternalId); + + info("Delete both user contexts"); + ContextualIdentityService.remove(beforeInternalId); + ContextualIdentityService.remove(afterInternalId); + assertContextRemoved(userContextManager, afterContextId, afterInternalId); + assertContextRemoved(userContextManager, beforeContextId, beforeInternalId); + + userContextManager.destroy(); +}); + +add_task(async function test_create_remove_context() { + const userContextManager = new UserContextManagerClass(); + + for (const closeContextTabs of [true, false]) { + info("Create two contexts via createContext"); + const userContextId1 = userContextManager.createContext(); + const internalId1 = userContextManager.getInternalIdById(userContextId1); + assertContextAvailable(userContextManager, userContextId1); + + const userContextId2 = userContextManager.createContext(); + const internalId2 = userContextManager.getInternalIdById(userContextId2); + assertContextAvailable(userContextManager, userContextId2); + + info("Create tabs in various user contexts"); + const url = "https://example.com/document-builder.sjs?html=tab"; + const tabDefault = await addTab(gBrowser, url); + const tabContext1 = await addTab(gBrowser, url, { + userContextId: internalId1, + }); + const tabContext2 = await addTab(gBrowser, url, { + userContextId: internalId2, + }); + + info("Remove the user context 1 via removeUserContext"); + userContextManager.removeUserContext(userContextId1, { closeContextTabs }); + + assertContextRemoved(userContextManager, userContextId1, internalId1); + if (closeContextTabs) { + ok(!gBrowser.tabs.includes(tabContext1), "Tab context 1 is closed"); + } else { + ok(gBrowser.tabs.includes(tabContext1), "Tab context 1 is not closed"); + } + ok(gBrowser.tabs.includes(tabDefault), "Tab default is not closed"); + ok(gBrowser.tabs.includes(tabContext2), "Tab context 2 is not closed"); + + info("Remove the user context 2 via removeUserContext"); + userContextManager.removeUserContext(userContextId2, { closeContextTabs }); + assertContextRemoved(userContextManager, userContextId2, internalId2); + if (closeContextTabs) { + ok(!gBrowser.tabs.includes(tabContext2), "Tab context 2 is closed"); + } else { + ok(gBrowser.tabs.includes(tabContext2), "Tab context 2 is not closed"); + } + ok(gBrowser.tabs.includes(tabDefault), "Tab default is not closed"); + } + + userContextManager.destroy(); +}); + +add_task(async function test_create_context_prefix() { + const userContextManager = new UserContextManagerClass(); + + info("Create a context with a custom prefix via createContext"); + const userContextId = userContextManager.createContext("test_prefix"); + const internalId = userContextManager.getInternalIdById(userContextId); + const identity = + ContextualIdentityService.getPublicIdentityFromId(internalId); + ok( + identity.name.startsWith("test_prefix"), + "The new identity used the provided prefix" + ); + + userContextManager.removeUserContext(userContextId); + userContextManager.destroy(); +}); + +add_task(async function test_several_managers() { + const manager1 = new UserContextManagerClass(); + const manager2 = new UserContextManagerClass(); + + info("Create a context via manager1"); + const contextId1 = manager1.createContext(); + const internalId = manager1.getInternalIdById(contextId1); + assertContextUnknown(manager2, contextId1); + + info("Retrieve the corresponding user context id in manager2"); + const contextId2 = manager2.getIdByInternalId(internalId); + is( + typeof contextId2, + "string", + "manager2 has a valid id for the user context created by manager 1" + ); + + ok( + contextId1 != contextId2, + "manager1 and manager2 have different ids for the same internal context id" + ); + + info("Remove the user context via manager2"); + manager2.removeUserContext(contextId2); + + info("Check that the user context is removed from both managers"); + assertContextRemoved(manager1, contextId1, internalId); + assertContextRemoved(manager2, contextId2, internalId); + + manager1.destroy(); + manager2.destroy(); +}); + +function assertContextAvailable(manager, contextId, expectedInternalId = null) { + ok( + manager.getUserContextIds().includes(contextId), + `Context id ${contextId} is listed by the manager` + ); + ok( + manager.hasUserContextId(contextId), + `Context id ${contextId} is known by the manager` + ); + + const internalId = manager.getInternalIdById(contextId); + if (expectedInternalId != null) { + is(internalId, expectedInternalId, "Internal id has the expected value"); + } + + is( + typeof internalId, + "number", + `Context id ${contextId} corresponds to a valid internal id (${internalId})` + ); + is( + manager.getIdByInternalId(internalId), + contextId, + `Context id ${contextId} is returned for internal id ${internalId}` + ); + ok( + ContextualIdentityService.getPublicUserContextIds().includes(internalId), + `User context for context id ${contextId} is found by ContextualIdentityService` + ); +} + +function assertContextUnknown(manager, contextId) { + ok( + !manager.getUserContextIds().includes(contextId), + `Context id ${contextId} is not listed by the manager` + ); + ok( + !manager.hasUserContextId(contextId), + `Context id ${contextId} is not known by the manager` + ); + is( + manager.getInternalIdById(contextId), + null, + `Context id ${contextId} does not match any internal id` + ); +} + +function assertContextRemoved(manager, contextId, internalId) { + assertContextUnknown(manager, contextId); + is( + manager.getIdByInternalId(internalId), + null, + `Internal id ${internalId} cannot be converted to user context id` + ); + ok( + !ContextualIdentityService.getPublicUserContextIds().includes(internalId), + `Internal id ${internalId} is not found in ContextualIdentityService` + ); +} diff --git a/remote/shared/test/browser/head.js b/remote/shared/test/browser/head.js new file mode 100644 index 0000000000..7960d99c9c --- /dev/null +++ b/remote/shared/test/browser/head.js @@ -0,0 +1,205 @@ +/* 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"; + +/** + * Add a new tab in a given browser, pointing to a given URL and automatically + * register the cleanup function to remove it at the end of the test. + * + * @param {Browser} browser + * The browser element where the tab should be added. + * @param {string} url + * The URL for the tab. + * @param {object=} options + * Options object to forward to BrowserTestUtils.addTab. + * @returns {Tab} + * The created tab. + */ +function addTab(browser, url, options) { + const tab = BrowserTestUtils.addTab(browser, url, options); + registerCleanupFunction(() => browser.removeTab(tab)); + return tab; +} + +/** + * Check if a given navigation is valid and has the expected url. + * + * @param {object} navigation + * The navigation to validate. + * @param {string} expectedUrl + * The expected url for the navigation. + */ +function assertNavigation(navigation, expectedUrl) { + ok(!!navigation, "Retrieved a navigation"); + is(navigation.url, expectedUrl, "Navigation has the expected URL"); + is( + typeof navigation.navigationId, + "string", + "Navigation has a string navigationId" + ); +} + +/** + * Check a pair of navigation events have the expected URL, navigation id and + * navigable id. The pair is expected to be ordered as follows: navigation-started + * and then navigation-stopped. + * + * @param {Array<object>} events + * The pair of events to validate. + * @param {string} url + * The expected url for the navigation. + * @param {string} navigationId + * The expected navigation id. + * @param {string} navigableId + * The expected navigable id. + * @param {boolean} isSameDocument + * If the navigation should be a same document navigation. + */ +function assertNavigationEvents( + events, + url, + navigationId, + navigableId, + isSameDocument +) { + const expectedEvents = isSameDocument ? 1 : 2; + + const navigationEvents = events.filter( + e => e.data.navigationId == navigationId + ); + is( + navigationEvents.length, + expectedEvents, + `Found ${expectedEvents} events for navigationId ${navigationId}` + ); + + if (isSameDocument) { + // Check there are no navigation-started/stopped events. + ok(!navigationEvents.some(e => e.name === "navigation-started")); + ok(!navigationEvents.some(e => e.name === "navigation-stopped")); + + const locationChanged = navigationEvents.find( + e => e.name === "location-changed" + ); + is(locationChanged.name, "location-changed", "event has the expected name"); + is(locationChanged.data.url, url, "event has the expected url"); + is( + locationChanged.data.navigableId, + navigableId, + "event has the expected navigable" + ); + } else { + // Check there is no location-changed event. + ok(!navigationEvents.some(e => e.name === "location-changed")); + + const started = navigationEvents.find(e => e.name === "navigation-started"); + const stopped = navigationEvents.find(e => e.name === "navigation-stopped"); + + // Check navigation-started + is(started.name, "navigation-started", "event has the expected name"); + is(started.data.url, url, "event has the expected url"); + is( + started.data.navigableId, + navigableId, + "event has the expected navigable" + ); + + // Check navigation-stopped + is(stopped.name, "navigation-stopped", "event has the expected name"); + is(stopped.data.url, url, "event has the expected url"); + is( + stopped.data.navigableId, + navigableId, + "event has the expected navigable" + ); + } +} + +/** + * Assert that the given navigations all have unique/different ids. + * + * @param {Array<object>} navigations + * The navigations to validate. + */ +function assertUniqueNavigationIds(...navigations) { + const ids = navigations.map(navigation => navigation.navigationId); + is(new Set(ids).size, ids.length, "Navigation ids are all different"); +} + +/** + * Create a document-builder based page with an iframe served by a given domain. + * + * @param {string} domain + * The domain which should serve the page. + * @returns {string} + * The URI for the page. + */ +function createFrame(domain) { + return createFrameForUri( + `https://${domain}/document-builder.sjs?html=frame-${domain}` + ); +} + +/** + * Create the markup for an iframe pointing to a given URI. + * + * @param {string} uri + * The uri for the iframe. + * @returns {string} + * The iframe markup. + */ +function createFrameForUri(uri) { + return `<iframe src="${encodeURI(uri)}"></iframe>`; +} + +/** + * Create the URL for a test page containing nested iframes + * + * @returns {string} + * The test page url. + */ +function createTestPageWithFrames() { + // Create the markup for an example.net frame nested in an example.com frame. + const NESTED_FRAME_MARKUP = createFrameForUri( + `https://example.org/document-builder.sjs?html=${createFrame( + "example.net" + )}` + ); + + // Combine the nested frame markup created above with an example.com frame. + const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`; + + // Create the test page URI on example.org. + return `https://example.org/document-builder.sjs?html=${encodeURI( + TEST_URI_MARKUP + )}`; +} + +/** + * Load the provided url in an existing browser. + * + * @param {Browser} browser + * The browser element where the URL should be loaded. + * @param {string} url + * The URL to load. + * @param {object=} options + * @param {boolean} options.includeSubFrames + * Whether we should monitor load of sub frames. Defaults to false. + * @param {boolean} options.maybeErrorPage + * Whether we might reach an error page or not. Defaults to false. + * @returns {Promise} + * Promise which will resolve when the page is loaded with the expected url. + */ +async function loadURL(browser, url, options = {}) { + const { includeSubFrames = false, maybeErrorPage = false } = options; + const loaded = BrowserTestUtils.browserLoaded( + browser, + includeSubFrames, + url, + maybeErrorPage + ); + BrowserTestUtils.startLoadingURIString(browser, url); + return loaded; +} diff --git a/remote/shared/test/xpcshell/head.js b/remote/shared/test/xpcshell/head.js new file mode 100644 index 0000000000..2e7cf578d3 --- /dev/null +++ b/remote/shared/test/xpcshell/head.js @@ -0,0 +1,3 @@ +const SVG_NS = "http://www.w3.org/2000/svg"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; diff --git a/remote/shared/test/xpcshell/test_AppInfo.js b/remote/shared/test/xpcshell/test_AppInfo.js new file mode 100644 index 0000000000..9149564aa1 --- /dev/null +++ b/remote/shared/test/xpcshell/test_AppInfo.js @@ -0,0 +1,53 @@ +/* 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 { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const { AppInfo, getTimeoutMultiplier } = ChromeUtils.importESModule( + "chrome://remote/content/shared/AppInfo.sys.mjs" +); + +// Minimal xpcshell tests for AppInfo; Services.appinfo.* is not available + +add_task(function test_custom_properties() { + const properties = [ + // platforms + "isAndroid", + "isLinux", + "isMac", + "isWindows", + // applications + "isFirefox", + "isThunderbird", + ]; + + for (const prop of properties) { + equal( + typeof AppInfo[prop], + "boolean", + `Custom property ${prop} has expected type` + ); + } +}); + +add_task(function test_getTimeoutMultiplier() { + const message = "Timeout multiplier has expected value"; + const timeoutMultiplier = getTimeoutMultiplier(); + + if ( + AppConstants.DEBUG || + AppConstants.MOZ_CODE_COVERAGE || + AppConstants.ASAN + ) { + equal(timeoutMultiplier, 4, message); + } else if (AppConstants.TSAN) { + equal(timeoutMultiplier, 8, message); + } else { + equal(timeoutMultiplier, 1, message); + } +}); diff --git a/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js b/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js new file mode 100644 index 0000000000..fa624e9c20 --- /dev/null +++ b/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js @@ -0,0 +1,140 @@ +/* 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/. */ + +const { parseChallengeHeader } = ChromeUtils.importESModule( + "chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs" +); + +add_task(async function test_single_scheme() { + const TEST_HEADERS = [ + { + // double quotes + header: 'Basic realm="test"', + params: [{ name: "realm", value: "test" }], + }, + { + // single quote + header: "Basic realm='test'", + params: [{ name: "realm", value: "test" }], + }, + { + // multiline + header: `Basic + realm='test'`, + params: [{ name: "realm", value: "test" }], + }, + { + // with additional parameter. + header: 'Basic realm="test", charset="UTF-8"', + params: [ + { name: "realm", value: "test" }, + { name: "charset", value: "UTF-8" }, + ], + }, + ]; + for (const { header, params } of TEST_HEADERS) { + const challenges = parseChallengeHeader(header); + equal(challenges.length, 1); + equal(challenges[0].scheme, "Basic"); + deepEqual(challenges[0].params, params); + } +}); + +add_task(async function test_realmless_scheme() { + const TEST_HEADERS = [ + { + // no parameter + header: "Custom", + params: [], + }, + { + // one non-realm parameter + header: "Custom charset='UTF-8'", + params: [{ name: "charset", value: "UTF-8" }], + }, + ]; + + for (const { header, params } of TEST_HEADERS) { + const challenges = parseChallengeHeader(header); + equal(challenges.length, 1); + equal(challenges[0].scheme, "Custom"); + deepEqual(challenges[0].params, params); + } +}); + +add_task(async function test_multiple_schemes() { + const TEST_HEADERS = [ + { + header: 'Scheme1 realm="foo", Scheme2 realm="bar"', + params: [ + [{ name: "realm", value: "foo" }], + [{ name: "realm", value: "bar" }], + ], + }, + { + header: 'Scheme1 realm="foo", charset="UTF-8", Scheme2 realm="bar"', + params: [ + [ + { name: "realm", value: "foo" }, + { name: "charset", value: "UTF-8" }, + ], + [{ name: "realm", value: "bar" }], + ], + }, + { + header: `Scheme1 realm="foo", + charset="UTF-8", + Scheme2 realm="bar"`, + params: [ + [ + { name: "realm", value: "foo" }, + { name: "charset", value: "UTF-8" }, + ], + [{ name: "realm", value: "bar" }], + ], + }, + ]; + for (const { header, params } of TEST_HEADERS) { + const challenges = parseChallengeHeader(header); + equal(challenges.length, 2); + equal(challenges[0].scheme, "Scheme1"); + deepEqual(challenges[0].params, params[0]); + equal(challenges[1].scheme, "Scheme2"); + deepEqual(challenges[1].params, params[1]); + } +}); + +add_task(async function test_digest_scheme() { + const header = `Digest + realm="http-auth@example.org", + qop="auth, auth-int", + algorithm=SHA-256, + nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"`; + + const challenges = parseChallengeHeader(header); + equal(challenges.length, 1); + equal(challenges[0].scheme, "Digest"); + + // Note: we are not doing a deepEqual check here, because one of the params + // actually contains a `,` inside quotes for its value, which will not be + // handled properly by the current ChallengeHeaderParser. See Bug 1857847. + const realmParam = challenges[0].params.find(param => param.name === "realm"); + ok(realmParam); + equal(realmParam.value, "http-auth@example.org"); + + // Once Bug 1857847 is addressed, this should start failing and can be + // switched to deepEqual. + notDeepEqual( + challenges[0].params, + [ + { name: "realm", value: "http-auth@example.org" }, + { name: "qop", value: "auth, auth-int" }, + { name: "algorithm", value: "SHA-256" }, + { name: "nonce", value: "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" }, + { name: "opaque", value: "FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS" }, + ], + "notDeepEqual should be changed to deepEqual when Bug 1857847 is fixed" + ); +}); diff --git a/remote/shared/test/xpcshell/test_DOM.js b/remote/shared/test/xpcshell/test_DOM.js new file mode 100644 index 0000000000..19844659b9 --- /dev/null +++ b/remote/shared/test/xpcshell/test_DOM.js @@ -0,0 +1,479 @@ +/* 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/. */ + +const { dom } = ChromeUtils.importESModule( + "chrome://remote/content/shared/DOM.sys.mjs" +); +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); + +class MockElement { + constructor(tagName, attrs = {}) { + this.tagName = tagName; + this.localName = tagName; + + this.isConnected = false; + this.ownerGlobal = { + document: { + isActive() { + return true; + }, + }, + }; + + for (let attr in attrs) { + this[attr] = attrs[attr]; + } + } + + get nodeType() { + return 1; + } + + get ELEMENT_NODE() { + return 1; + } + + // this is a severely limited CSS selector + // that only supports lists of tag names + matches(selector) { + let tags = selector.split(","); + return tags.includes(this.localName); + } +} + +class MockXULElement extends MockElement { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = XUL_NS; + + if (typeof this.ownerDocument == "undefined") { + this.ownerDocument = {}; + } + if (typeof this.ownerDocument.documentElement == "undefined") { + this.ownerDocument.documentElement = { namespaceURI: XUL_NS }; + } + } +} + +const xulEl = new MockXULElement("text"); + +const domElInPrivilegedDocument = new MockElement("input", { + nodePrincipal: { isSystemPrincipal: true }, +}); +const xulElInPrivilegedDocument = new MockXULElement("text", { + nodePrincipal: { isSystemPrincipal: true }, +}); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + + browser.document.body.innerHTML = ` + <div id="foo" style="margin: 50px"> + <iframe></iframe> + <video></video> + <svg xmlns="http://www.w3.org/2000/svg"></svg> + <textarea></textarea> + </div> + `; + + const divEl = browser.document.querySelector("div"); + const svgEl = browser.document.querySelector("svg"); + const textareaEl = browser.document.querySelector("textarea"); + const videoEl = browser.document.querySelector("video"); + + const iframeEl = browser.document.querySelector("iframe"); + const childEl = iframeEl.contentDocument.createElement("div"); + iframeEl.contentDocument.body.appendChild(childEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + return { + browser, + nodeCache: new NodeCache(), + childEl, + divEl, + iframeEl, + shadowRoot, + svgEl, + textareaEl, + videoEl, + }; +} + +add_task(function test_findClosest() { + const { divEl, videoEl } = setupTest(); + + equal(dom.findClosest(divEl, "foo"), null); + equal(dom.findClosest(videoEl, "div"), divEl); +}); + +add_task(function test_isSelected() { + const { browser, divEl } = setupTest(); + + const checkbox = browser.document.createElement("input"); + checkbox.setAttribute("type", "checkbox"); + + ok(!dom.isSelected(checkbox)); + checkbox.checked = true; + ok(dom.isSelected(checkbox)); + + // selected is not a property of <input type=checkbox> + checkbox.selected = true; + checkbox.checked = false; + ok(!dom.isSelected(checkbox)); + + const option = browser.document.createElement("option"); + + ok(!dom.isSelected(option)); + option.selected = true; + ok(dom.isSelected(option)); + + // checked is not a property of <option> + option.checked = true; + option.selected = false; + ok(!dom.isSelected(option)); + + // anything else should not be selected + for (const type of [undefined, null, "foo", true, [], {}, divEl]) { + ok(!dom.isSelected(type)); + } +}); + +add_task(function test_isElement() { + const { divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isElement(divEl)); + ok(dom.isElement(svgEl)); + ok(dom.isElement(xulEl)); + ok(dom.isElement(domElInPrivilegedDocument)); + ok(dom.isElement(xulElInPrivilegedDocument)); + + ok(!dom.isElement(shadowRoot)); + ok(!dom.isElement(divEl.ownerGlobal)); + ok(!dom.isElement(iframeEl.contentWindow)); + + for (const type of [true, 42, {}, [], undefined, null]) { + ok(!dom.isElement(type)); + } +}); + +add_task(function test_isDOMElement() { + const { divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isDOMElement(divEl)); + ok(dom.isDOMElement(svgEl)); + ok(dom.isDOMElement(domElInPrivilegedDocument)); + + ok(!dom.isDOMElement(shadowRoot)); + ok(!dom.isDOMElement(divEl.ownerGlobal)); + ok(!dom.isDOMElement(iframeEl.contentWindow)); + ok(!dom.isDOMElement(xulEl)); + ok(!dom.isDOMElement(xulElInPrivilegedDocument)); + + for (const type of [true, 42, "foo", {}, [], undefined, null]) { + ok(!dom.isDOMElement(type)); + } +}); + +add_task(function test_isXULElement() { + const { divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isXULElement(xulEl)); + ok(dom.isXULElement(xulElInPrivilegedDocument)); + + ok(!dom.isXULElement(divEl)); + ok(!dom.isXULElement(domElInPrivilegedDocument)); + ok(!dom.isXULElement(svgEl)); + ok(!dom.isXULElement(shadowRoot)); + ok(!dom.isXULElement(divEl.ownerGlobal)); + ok(!dom.isXULElement(iframeEl.contentWindow)); + + for (const type of [true, 42, "foo", {}, [], undefined, null]) { + ok(!dom.isXULElement(type)); + } +}); + +add_task(function test_isDOMWindow() { + const { divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isDOMWindow(divEl.ownerGlobal)); + ok(dom.isDOMWindow(iframeEl.contentWindow)); + + ok(!dom.isDOMWindow(divEl)); + ok(!dom.isDOMWindow(svgEl)); + ok(!dom.isDOMWindow(shadowRoot)); + ok(!dom.isDOMWindow(domElInPrivilegedDocument)); + ok(!dom.isDOMWindow(xulEl)); + ok(!dom.isDOMWindow(xulElInPrivilegedDocument)); + + for (const type of [true, 42, {}, [], undefined, null]) { + ok(!dom.isDOMWindow(type)); + } +}); + +add_task(function test_isShadowRoot() { + const { browser, divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isShadowRoot(shadowRoot)); + + ok(!dom.isShadowRoot(divEl)); + ok(!dom.isShadowRoot(svgEl)); + ok(!dom.isShadowRoot(divEl.ownerGlobal)); + ok(!dom.isShadowRoot(iframeEl.contentWindow)); + ok(!dom.isShadowRoot(xulEl)); + ok(!dom.isShadowRoot(domElInPrivilegedDocument)); + ok(!dom.isShadowRoot(xulElInPrivilegedDocument)); + + for (const type of [true, 42, "foo", {}, [], undefined, null]) { + ok(!dom.isShadowRoot(type)); + } + + const documentFragment = browser.document.createDocumentFragment(); + ok(!dom.isShadowRoot(documentFragment)); +}); + +add_task(function test_isReadOnly() { + const { browser, divEl, textareaEl } = setupTest(); + + const input = browser.document.createElement("input"); + input.readOnly = true; + ok(dom.isReadOnly(input)); + + textareaEl.readOnly = true; + ok(dom.isReadOnly(textareaEl)); + + ok(!dom.isReadOnly(divEl)); + divEl.readOnly = true; + ok(!dom.isReadOnly(divEl)); + + ok(!dom.isReadOnly(null)); +}); + +add_task(function test_isDisabled() { + const { browser, divEl, svgEl } = setupTest(); + + const select = browser.document.createElement("select"); + const option = browser.document.createElement("option"); + select.appendChild(option); + select.disabled = true; + ok(dom.isDisabled(option)); + + const optgroup = browser.document.createElement("optgroup"); + option.parentNode = optgroup; + ok(dom.isDisabled(option)); + + optgroup.parentNode = select; + ok(dom.isDisabled(option)); + + select.disabled = false; + ok(!dom.isDisabled(option)); + + for (const type of ["button", "input", "select", "textarea"]) { + const elem = browser.document.createElement(type); + ok(!dom.isDisabled(elem)); + elem.disabled = true; + ok(dom.isDisabled(elem)); + } + + ok(!dom.isDisabled(divEl)); + + svgEl.disabled = true; + ok(!dom.isDisabled(svgEl)); + + ok(!dom.isDisabled(new MockXULElement("browser", { disabled: true }))); +}); + +add_task(function test_isEditingHost() { + const { browser, divEl, svgEl } = setupTest(); + + ok(!dom.isEditingHost(null)); + + ok(!dom.isEditingHost(divEl)); + divEl.contentEditable = true; + ok(dom.isEditingHost(divEl)); + + ok(!dom.isEditingHost(svgEl)); + browser.document.designMode = "on"; + ok(dom.isEditingHost(svgEl)); +}); + +add_task(function test_isEditable() { + const { browser, divEl, svgEl, textareaEl } = setupTest(); + + ok(!dom.isEditable(null)); + + for (let type of [ + "checkbox", + "radio", + "hidden", + "submit", + "button", + "image", + ]) { + const input = browser.document.createElement("input"); + input.setAttribute("type", type); + + ok(!dom.isEditable(input)); + } + + const input = browser.document.createElement("input"); + ok(dom.isEditable(input)); + input.setAttribute("type", "text"); + ok(dom.isEditable(input)); + + ok(dom.isEditable(textareaEl)); + + const textareaDisabled = browser.document.createElement("textarea"); + textareaDisabled.disabled = true; + ok(!dom.isEditable(textareaDisabled)); + + const textareaReadOnly = browser.document.createElement("textarea"); + textareaReadOnly.readOnly = true; + ok(!dom.isEditable(textareaReadOnly)); + + ok(!dom.isEditable(divEl)); + divEl.contentEditable = true; + ok(dom.isEditable(divEl)); + + ok(!dom.isEditable(svgEl)); + browser.document.designMode = "on"; + ok(dom.isEditable(svgEl)); +}); + +add_task(function test_isMutableFormControlElement() { + const { browser, divEl, textareaEl } = setupTest(); + + ok(!dom.isMutableFormControl(null)); + + ok(dom.isMutableFormControl(textareaEl)); + + const textareaDisabled = browser.document.createElement("textarea"); + textareaDisabled.disabled = true; + ok(!dom.isMutableFormControl(textareaDisabled)); + + const textareaReadOnly = browser.document.createElement("textarea"); + textareaReadOnly.readOnly = true; + ok(!dom.isMutableFormControl(textareaReadOnly)); + + const mutableStates = new Set([ + "color", + "date", + "datetime-local", + "email", + "file", + "month", + "number", + "password", + "range", + "search", + "tel", + "text", + "url", + "week", + ]); + for (const type of mutableStates) { + const input = browser.document.createElement("input"); + input.setAttribute("type", type); + ok(dom.isMutableFormControl(input)); + } + + const inputHidden = browser.document.createElement("input"); + inputHidden.setAttribute("type", "hidden"); + ok(!dom.isMutableFormControl(inputHidden)); + + ok(!dom.isMutableFormControl(divEl)); + divEl.contentEditable = true; + ok(!dom.isMutableFormControl(divEl)); + browser.document.designMode = "on"; + ok(!dom.isMutableFormControl(divEl)); +}); + +add_task(function test_coordinates() { + const { divEl } = setupTest(); + + let coords = dom.coordinates(divEl); + ok(coords.hasOwnProperty("x")); + ok(coords.hasOwnProperty("y")); + equal(typeof coords.x, "number"); + equal(typeof coords.y, "number"); + + deepEqual(dom.coordinates(divEl), { x: 0, y: 0 }); + deepEqual(dom.coordinates(divEl, 10, 10), { x: 10, y: 10 }); + deepEqual(dom.coordinates(divEl, -5, -5), { x: -5, y: -5 }); + + Assert.throws(() => dom.coordinates(null), /node is null/); + + Assert.throws( + () => dom.coordinates(divEl, "string", undefined), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, undefined, "string"), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, "string", "string"), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, {}, undefined), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, undefined, {}), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, {}, {}), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, [], undefined), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, undefined, []), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, [], []), + /Offset must be a number/ + ); +}); + +add_task(function test_isDetached() { + const { childEl, iframeEl } = setupTest(); + + let detachedShadowRoot = childEl.attachShadow({ mode: "open" }); + detachedShadowRoot.innerHTML = "<input></input>"; + + // Connected to the DOM + ok(!dom.isDetached(detachedShadowRoot)); + + // Node document (ownerDocument) is not the active document + iframeEl.remove(); + ok(dom.isDetached(detachedShadowRoot)); + + // host element is stale (eg. not connected) + detachedShadowRoot.host.remove(); + equal(childEl.isConnected, false); + ok(dom.isDetached(detachedShadowRoot)); +}); + +add_task(function test_isStale() { + const { childEl, iframeEl } = setupTest(); + + // Connected to the DOM + ok(!dom.isStale(childEl)); + + // Not part of the active document + iframeEl.remove(); + ok(dom.isStale(childEl)); + + // Not connected to the DOM + childEl.remove(); + ok(dom.isStale(childEl)); +}); diff --git a/remote/shared/test/xpcshell/test_Format.js b/remote/shared/test/xpcshell/test_Format.js new file mode 100644 index 0000000000..cfdd35be08 --- /dev/null +++ b/remote/shared/test/xpcshell/test_Format.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { truncate, pprint } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Format.sys.mjs" +); + +const MAX_STRING_LENGTH = 250; +const HALF = "x".repeat(MAX_STRING_LENGTH / 2); + +add_task(function test_pprint() { + equal('[object Object] {"foo":"bar"}', pprint`${{ foo: "bar" }}`); + + equal("[object Number] 42", pprint`${42}`); + equal("[object Boolean] true", pprint`${true}`); + equal("[object Undefined] undefined", pprint`${undefined}`); + equal("[object Null] null", pprint`${null}`); + + let complexObj = { toJSON: () => "foo" }; + equal('[object Object] "foo"', pprint`${complexObj}`); + + let cyclic = {}; + cyclic.me = cyclic; + equal("[object Object] <cyclic object value>", pprint`${cyclic}`); + + let el = { + hasAttribute: attr => attr in el, + getAttribute: attr => (attr in el ? el[attr] : null), + nodeType: 1, + localName: "input", + id: "foo", + class: "a b", + href: "#", + name: "bar", + src: "s", + type: "t", + }; + equal( + '<input id="foo" class="a b" href="#" name="bar" src="s" type="t">', + pprint`${el}` + ); +}); + +add_task(function test_truncate_empty() { + equal(truncate``, ""); +}); + +add_task(function test_truncate_noFields() { + equal(truncate`foo bar`, "foo bar"); +}); + +add_task(function test_truncate_multipleFields() { + equal(truncate`${0}`, "0"); + equal(truncate`${1}${2}${3}`, "123"); + equal(truncate`a${1}b${2}c${3}`, "a1b2c3"); +}); + +add_task(function test_truncate_primitiveFields() { + equal(truncate`${123}`, "123"); + equal(truncate`${true}`, "true"); + equal(truncate`${null}`, ""); + equal(truncate`${undefined}`, ""); +}); + +add_task(function test_truncate_string() { + equal(truncate`${"foo"}`, "foo"); + equal(truncate`${"x".repeat(250)}`, "x".repeat(250)); + equal(truncate`${"x".repeat(260)}`, `${HALF} ... ${HALF}`); +}); + +add_task(function test_truncate_array() { + equal(truncate`${["foo"]}`, JSON.stringify(["foo"])); + equal(truncate`${"foo"} ${["bar"]}`, `foo ${JSON.stringify(["bar"])}`); + equal( + truncate`${["x".repeat(260)]}`, + JSON.stringify([`${HALF} ... ${HALF}`]) + ); +}); + +add_task(function test_truncate_object() { + equal(truncate`${{}}`, JSON.stringify({})); + equal(truncate`${{ foo: "bar" }}`, JSON.stringify({ foo: "bar" })); + equal( + truncate`${{ foo: "x".repeat(260) }}`, + JSON.stringify({ foo: `${HALF} ... ${HALF}` }) + ); + equal(truncate`${{ foo: ["bar"] }}`, JSON.stringify({ foo: ["bar"] })); + equal( + truncate`${{ foo: ["bar", { baz: 42 }] }}`, + JSON.stringify({ foo: ["bar", { baz: 42 }] }) + ); + + let complex = { + toString() { + return "hello world"; + }, + }; + equal(truncate`${complex}`, "hello world"); + + let longComplex = { + toString() { + return "x".repeat(260); + }, + }; + equal(truncate`${longComplex}`, `${HALF} ... ${HALF}`); +}); diff --git a/remote/shared/test/xpcshell/test_Navigate.js b/remote/shared/test/xpcshell/test_Navigate.js new file mode 100644 index 0000000000..e41508189a --- /dev/null +++ b/remote/shared/test/xpcshell/test_Navigate.js @@ -0,0 +1,879 @@ +/* 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/. */ + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const { + DEFAULT_UNLOAD_TIMEOUT, + getUnloadTimeoutMultiplier, + ProgressListener, + waitForInitialNavigationCompleted, +} = ChromeUtils.importESModule( + "chrome://remote/content/shared/Navigate.sys.mjs" +); + +const CURRENT_URI = Services.io.newURI("http://foo.bar/"); +const INITIAL_URI = Services.io.newURI("about:blank"); +const TARGET_URI = Services.io.newURI("http://foo.cheese/"); +const TARGET_URI_IS_ERROR_PAGE = Services.io.newURI("doesnotexist://"); +const TARGET_URI_WITH_HASH = Services.io.newURI("http://foo.cheese/#foo"); + +function wait(time) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + return new Promise(resolve => setTimeout(resolve, time)); +} + +class MockRequest { + constructor(uri) { + this.originalURI = uri; + } + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIRequest", "nsIChannel"]); + } +} + +class MockWebProgress { + constructor(browsingContext) { + this.browsingContext = browsingContext; + + this.documentRequest = null; + this.isLoadingDocument = false; + this.listener = null; + this.progressListenerRemoved = false; + } + + addProgressListener(listener) { + if (this.listener) { + throw new Error("Cannot register listener twice"); + } + + this.listener = listener; + } + + removeProgressListener(listener) { + if (listener === this.listener) { + this.listener = null; + this.progressListenerRemoved = true; + } else { + throw new Error("Unknown listener"); + } + } + + sendLocationChange(options = {}) { + const { flag = 0 } = options; + + this.documentRequest = null; + + if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { + this.browsingContext.currentURI = TARGET_URI_WITH_HASH; + } else if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) { + this.browsingContext.currentURI = TARGET_URI_IS_ERROR_PAGE; + } + + this.listener?.onLocationChange( + this, + this.documentRequest, + TARGET_URI_WITH_HASH, + flag + ); + + return new Promise(executeSoon); + } + + sendStartState(options = {}) { + const { coop = false, isInitial = false } = options; + + if (coop) { + this.browsingContext = new MockTopContext(this); + } + + if (!this.browsingContext.currentWindowGlobal) { + this.browsingContext.currentWindowGlobal = {}; + } + + this.browsingContext.currentWindowGlobal.isInitialDocument = isInitial; + + this.isLoadingDocument = true; + const uri = isInitial ? INITIAL_URI : TARGET_URI; + this.documentRequest = new MockRequest(uri); + + this.listener?.onStateChange( + this, + this.documentRequest, + Ci.nsIWebProgressListener.STATE_START, + null + ); + + return new Promise(executeSoon); + } + + sendStopState(options = {}) { + const { errorFlag = 0 } = options; + + this.browsingContext.currentURI = this.documentRequest.originalURI; + + this.isLoadingDocument = false; + this.documentRequest = null; + + this.listener?.onStateChange( + this, + this.documentRequest, + Ci.nsIWebProgressListener.STATE_STOP, + errorFlag + ); + + return new Promise(executeSoon); + } +} + +class MockTopContext { + constructor(webProgress = null) { + this.currentURI = CURRENT_URI; + this.currentWindowGlobal = { isInitialDocument: true }; + this.id = 7; + this.top = this; + this.webProgress = webProgress || new MockWebProgress(this); + } +} + +const hasPromiseResolved = async function (promise) { + let resolved = false; + promise.finally(() => (resolved = true)); + // Make sure microtasks have time to run. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + return resolved; +}; + +const hasPromiseRejected = async function (promise) { + let rejected = false; + promise.catch(() => (rejected = true)); + // Make sure microtasks have time to run. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + return rejected; +}; + +add_task( + async function test_waitForInitialNavigation_initialDocumentNoWindowGlobal() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + // In some cases there might be no window global yet. + delete browsingContext.currentWindowGlobal; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + await webProgress.sendStartState({ isInitial: true }); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal( + currentURI.spec, + INITIAL_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentNotLoaded() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + await webProgress.sendStartState({ isInitial: true }); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal( + currentURI.spec, + INITIAL_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentLoadingAndNoAdditionalLoad() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + ok(webProgress.isLoadingDocument, "Document is loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal( + currentURI.spec, + INITIAL_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentFinishedLoadingNoAdditionalLoad() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal( + currentURI.spec, + INITIAL_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentLoadingAndAdditionalLoad() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + + ok(webProgress.isLoadingDocument, "Document is loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + + await wait(100); + + await webProgress.sendStartState({ isInitial: false }); + await webProgress.sendStopState(); + + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentFinishedLoadingAndAdditionalLoad() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await wait(100); + + await webProgress.sendStartState({ isInitial: false }); + await webProgress.sendStopState(); + + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_notInitialDocumentNotLoading() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + await webProgress.sendStartState({ isInitial: false }); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_notInitialDocumentAlreadyLoading() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: false }); + ok(webProgress.isLoadingDocument, "Document is loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_notInitialDocumentFinishedLoading() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: false }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const { currentURI, targetURI } = await waitForInitialNavigationCompleted( + webProgress + ); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task(async function test_waitForInitialNavigation_resolveWhenStarted() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + ok(webProgress.isLoadingDocument, "Document is already loading"); + + const { currentURI, targetURI } = await waitForInitialNavigationCompleted( + webProgress, + { + resolveWhenStarted: true, + } + ); + + ok(webProgress.isLoadingDocument, "Document is still loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal(currentURI.spec, CURRENT_URI.spec, "Expected current URI has been set"); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); +}); + +add_task(async function test_waitForInitialNavigation_crossOrigin() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + await webProgress.sendStartState({ coop: true }); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + notEqual( + browsingContext, + webProgress.browsingContext, + "Got new browsing context" + ); + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal(currentURI.spec, TARGET_URI.spec, "Expected current URI has been set"); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); +}); + +add_task(async function test_waitForInitialNavigation_unloadTimeout_default() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + // Stop the navigation on an initial page which is not loading anymore. + // This situation happens with new tabs on Android, even though they are on + // the initial document, they will not start another navigation on their own. + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + // Start a timer longer than the timeout which will be used by + // waitForInitialNavigationCompleted, and check that navigated resolves first. + const waitForMoreThanDefaultTimeout = wait( + DEFAULT_UNLOAD_TIMEOUT * 1.5 * getUnloadTimeoutMultiplier() + ); + await Promise.race([navigated, waitForMoreThanDefaultTimeout]); + + ok( + await hasPromiseResolved(navigated), + "waitForInitialNavigationCompleted has resolved" + ); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Document is still on the initial document" + ); +}); + +add_task(async function test_waitForInitialNavigation_unloadTimeout_longer() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + // Stop the navigation on an initial page which is not loading anymore. + // This situation happens with new tabs on Android, even though they are on + // the initial document, they will not start another navigation on their own. + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress, { + unloadTimeout: DEFAULT_UNLOAD_TIMEOUT * 3, + }); + + // Start a timer longer than the default timeout of the Navigate module. + // However here we used a custom timeout, so we expect that the navigation + // will not be done yet by the time this timer is done. + const waitForMoreThanDefaultTimeout = wait( + DEFAULT_UNLOAD_TIMEOUT * 1.5 * getUnloadTimeoutMultiplier() + ); + await Promise.race([navigated, waitForMoreThanDefaultTimeout]); + + // The promise should not have resolved because we didn't reached the custom + // timeout which is 3 times the default one. + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + // The navigation should eventually resolve once we reach the custom timeout. + await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Document is still on the initial document" + ); +}); + +add_task(async function test_ProgressListener_expectNavigation() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress, { + expectNavigation: true, + unloadTimeout: 10, + }); + const navigated = progressListener.start(); + + // Wait for unloadTimeout to finish in case it started + await wait(30); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet"); + + await webProgress.sendStartState(); + await webProgress.sendStopState(); + + ok(await hasPromiseResolved(navigated), "Listener has resolved"); +}); + +add_task( + async function test_ProgressListener_expectNavigation_initialDocumentFinishedLoading() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress, { + expectNavigation: true, + unloadTimeout: 10, + }); + const navigated = progressListener.start(); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet"); + + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + // Wait for unloadTimeout to finish in case it started + await wait(30); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet"); + + await webProgress.sendStartState(); + await webProgress.sendStopState(); + + ok(await hasPromiseResolved(navigated), "Listener has resolved"); + } +); + +add_task(async function test_ProgressListener_isStarted() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress); + ok(!progressListener.isStarted); + + progressListener.start(); + ok(progressListener.isStarted); + + progressListener.stop(); + ok(!progressListener.isStarted); +}); + +add_task(async function test_ProgressListener_notWaitForExplicitStart() { + // Create a webprogress and start it before creating the progress listener. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create the progress listener for a webprogress already in a navigation. + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: false, + }); + const navigated = progressListener.start(); + + // Send stop state to complete the initial navigation + await webProgress.sendStopState(); + ok( + await hasPromiseResolved(navigated), + "Listener has resolved after initial navigation" + ); +}); + +add_task(async function test_ProgressListener_waitForExplicitStart() { + // Create a webprogress and start it before creating the progress listener. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create the progress listener for a webprogress already in a navigation. + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: true, + }); + const navigated = progressListener.start(); + + // Send stop state to complete the initial navigation + await webProgress.sendStopState(); + ok( + !(await hasPromiseResolved(navigated)), + "Listener has not resolved after initial navigation" + ); + + // Start a new navigation + await webProgress.sendStartState(); + ok( + !(await hasPromiseResolved(navigated)), + "Listener has not resolved after starting new navigation" + ); + + // Finish the new navigation + await webProgress.sendStopState(); + ok( + await hasPromiseResolved(navigated), + "Listener resolved after finishing the new navigation" + ); +}); + +add_task( + async function test_ProgressListener_waitForExplicitStartAndResolveWhenStarted() { + // Create a webprogress and start it before creating the progress listener. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create the progress listener for a webprogress already in a navigation. + const progressListener = new ProgressListener(webProgress, { + resolveWhenStarted: true, + waitForExplicitStart: true, + }); + const navigated = progressListener.start(); + + // Send stop state to complete the initial navigation + await webProgress.sendStopState(); + ok( + !(await hasPromiseResolved(navigated)), + "Listener has not resolved after initial navigation" + ); + + // Start a new navigation + await webProgress.sendStartState(); + ok( + await hasPromiseResolved(navigated), + "Listener resolved after starting the new navigation" + ); + } +); + +add_task( + async function test_ProgressListener_resolveWhenNavigatingInsideDocument() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress); + const navigated = progressListener.start(); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved"); + + // Send hash change location change notification to complete the navigation + await webProgress.sendLocationChange({ + flag: Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT, + }); + + ok(await hasPromiseResolved(navigated), "Listener has resolved"); + + const { currentURI, targetURI } = progressListener; + equal( + currentURI.spec, + TARGET_URI_WITH_HASH.spec, + "Expected current URI has been set" + ); + equal( + targetURI.spec, + TARGET_URI_WITH_HASH.spec, + "Expected target URI has been set" + ); + } +); + +add_task(async function test_ProgressListener_ignoreCacheError() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress); + const navigated = progressListener.start(); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved"); + + await webProgress.sendStartState(); + await webProgress.sendStopState({ + errorFlag: Cr.NS_ERROR_PARSED_DATA_CACHED, + }); + + ok(await hasPromiseResolved(navigated), "Listener has resolved"); +}); + +add_task(async function test_ProgressListener_navigationRejectedOnErrorPage() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: false, + }); + const navigated = progressListener.start(); + + await webProgress.sendStartState(); + await webProgress.sendLocationChange({ + flag: + Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT | + Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE, + }); + + ok( + await hasPromiseRejected(navigated), + "Listener has rejected in location change for error page" + ); +}); + +add_task(async function test_ProgressListener_navigationRejectedOnStopState() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: false, + }); + const navigated = progressListener.start(); + + await webProgress.sendStartState(); + await webProgress.sendStopState({ errorFlag: Cr.NS_BINDING_ABORTED }); + + ok( + await hasPromiseRejected(navigated), + "Listener has rejected in stop state for erroneous navigation" + ); +}); + +add_task(async function test_ProgressListener_stopIfStarted() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress); + const navigated = progressListener.start(); + + progressListener.stopIfStarted(); + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved"); + + await webProgress.sendStartState(); + progressListener.stopIfStarted(); + ok(await hasPromiseResolved(navigated), "Listener has resolved"); +}); + +add_task(async function test_ProgressListener_stopIfStarted_alreadyStarted() { + // Create an already navigating browsing context. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create a progress listener which accepts already ongoing navigations. + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: false, + }); + const navigated = progressListener.start(); + + // stopIfStarted should stop the listener because of the ongoing navigation. + progressListener.stopIfStarted(); + ok(await hasPromiseResolved(navigated), "Listener has resolved"); +}); + +add_task( + async function test_ProgressListener_stopIfStarted_alreadyStarted_waitForExplicitStart() { + // Create an already navigating browsing context. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create a progress listener which rejects already ongoing navigations. + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: true, + }); + const navigated = progressListener.start(); + + // stopIfStarted will not stop the listener for the existing navigation. + progressListener.stopIfStarted(); + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved"); + + // stopIfStarted will stop the listener when called after starting a new + // navigation. + await webProgress.sendStartState(); + progressListener.stopIfStarted(); + ok(await hasPromiseResolved(navigated), "Listener has resolved"); + } +); diff --git a/remote/shared/test/xpcshell/test_Realm.js b/remote/shared/test/xpcshell/test_Realm.js new file mode 100644 index 0000000000..3990cce482 --- /dev/null +++ b/remote/shared/test/xpcshell/test_Realm.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Realm, WindowRealm } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Realm.sys.mjs" +); + +add_task(function test_id() { + const realm1 = new Realm(); + const id1 = realm1.id; + Assert.equal(typeof id1, "string"); + + const realm2 = new Realm(); + const id2 = realm2.id; + Assert.equal(typeof id2, "string"); + + Assert.notEqual(id1, id2, "Ids for different realms are different"); +}); + +add_task(function test_handleObjectMap() { + const realm = new Realm(); + + // Test an unknown handle. + Assert.equal( + realm.getObjectForHandle("unknown"), + undefined, + "Unknown handles return undefined" + ); + + // Test creating a simple handle. + const object = {}; + const handle = realm.getHandleForObject(object); + Assert.equal(typeof handle, "string", "Created a valid handle"); + Assert.equal( + realm.getObjectForHandle(handle), + object, + "Using the handle returned the original object" + ); + + // Test another handle for the same object. + const secondHandle = realm.getHandleForObject(object); + Assert.equal(typeof secondHandle, "string", "Created a valid handle"); + Assert.notEqual(secondHandle, handle, "A different handle was generated"); + Assert.equal( + realm.getObjectForHandle(secondHandle), + object, + "Using the second handle also returned the original object" + ); + + // Test using the handles in another realm. + const otherRealm = new Realm(); + Assert.equal( + otherRealm.getObjectForHandle(handle), + undefined, + "A realm returns undefined for handles from another realm" + ); + + // Removing an unknown handle should not throw or have any side effect on + // existing handles. + realm.removeObjectHandle("unknown"); + Assert.equal(realm.getObjectForHandle(handle), object); + Assert.equal(realm.getObjectForHandle(secondHandle), object); + + // Remove the second handle + realm.removeObjectHandle(secondHandle); + Assert.equal( + realm.getObjectForHandle(handle), + object, + "The first handle is still resolving the object" + ); + Assert.equal( + realm.getObjectForHandle(secondHandle), + undefined, + "The second handle returns undefined after calling removeObjectHandle" + ); + + // Remove the original handle + realm.removeObjectHandle(handle); + Assert.equal( + realm.getObjectForHandle(handle), + undefined, + "The first handle returns undefined as well" + ); +}); + +add_task(async function test_windowRealm_isSandbox() { + const windowlessBrowser = Services.appShell.createWindowlessBrowser(false); + const contentWindow = windowlessBrowser.docShell.domWindow; + + const realm1 = new WindowRealm(contentWindow); + Assert.equal(realm1.isSandbox, false); + + const realm2 = new WindowRealm(contentWindow, { sandboxName: "test" }); + Assert.equal(realm2.isSandbox, true); +}); + +add_task(async function test_windowRealm_userActivationEnabled() { + const windowlessBrowser = Services.appShell.createWindowlessBrowser(false); + const contentWindow = windowlessBrowser.docShell.domWindow; + const userActivation = contentWindow.navigator.userActivation; + + const realm = new WindowRealm(contentWindow); + + Assert.equal(realm.userActivationEnabled, false); + Assert.equal(userActivation.isActive && userActivation.hasBeenActive, false); + + realm.userActivationEnabled = true; + Assert.equal(realm.userActivationEnabled, true); + Assert.equal(userActivation.isActive && userActivation.hasBeenActive, true); + + realm.userActivationEnabled = false; + Assert.equal(realm.userActivationEnabled, false); + Assert.equal(userActivation.isActive && userActivation.hasBeenActive, false); +}); diff --git a/remote/shared/test/xpcshell/test_RecommendedPreferences.js b/remote/shared/test/xpcshell/test_RecommendedPreferences.js new file mode 100644 index 0000000000..20de07a528 --- /dev/null +++ b/remote/shared/test/xpcshell/test_RecommendedPreferences.js @@ -0,0 +1,118 @@ +/* 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/. */ + +const { RecommendedPreferences } = ChromeUtils.importESModule( + "chrome://remote/content/shared/RecommendedPreferences.sys.mjs" +); + +const COMMON_PREF = "toolkit.startup.max_resumed_crashes"; + +const PROTOCOL_1_PREF = "dom.disable_beforeunload"; +const PROTOCOL_1_RECOMMENDED_PREFS = new Map([[PROTOCOL_1_PREF, true]]); + +const PROTOCOL_2_PREF = "browser.contentblocking.features.standard"; +const PROTOCOL_2_RECOMMENDED_PREFS = new Map([ + [PROTOCOL_2_PREF, "-tp,tpPrivate,cookieBehavior0,-cm,-fp"], +]); + +function cleanup() { + info("Restore recommended preferences and test preferences"); + Services.prefs.clearUserPref("remote.prefs.recommended"); + RecommendedPreferences.restoreAllPreferences(); +} + +// cleanup() should be called: +// - explicitly after each test to avoid side effects +// - via registerCleanupFunction in case a test crashes/times out +registerCleanupFunction(cleanup); + +add_task(async function test_multipleClients() { + info("Check initial values for the test preferences"); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + info("Apply recommended preferences for a protocol_1 client"); + RecommendedPreferences.applyPreferences(PROTOCOL_1_RECOMMENDED_PREFS); + checkPreferences({ common: true, protocol_1: true, protocol_2: false }); + + info("Apply recommended preferences for a protocol_2 client"); + RecommendedPreferences.applyPreferences(PROTOCOL_2_RECOMMENDED_PREFS); + checkPreferences({ common: true, protocol_1: true, protocol_2: true }); + + info("Restore protocol_1 preferences"); + RecommendedPreferences.restorePreferences(PROTOCOL_1_RECOMMENDED_PREFS); + checkPreferences({ common: true, protocol_1: false, protocol_2: true }); + + info("Restore protocol_2 preferences"); + RecommendedPreferences.restorePreferences(PROTOCOL_2_RECOMMENDED_PREFS); + checkPreferences({ common: true, protocol_1: false, protocol_2: false }); + + info("Restore all the altered preferences"); + RecommendedPreferences.restoreAllPreferences(); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + info("Attemps to restore again"); + RecommendedPreferences.restoreAllPreferences(); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + cleanup(); +}); + +add_task(async function test_disabled() { + info("Disable RecommendedPreferences"); + Services.prefs.setBoolPref("remote.prefs.recommended", false); + + info("Check initial values for the test preferences"); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + info("Recommended preferences are not applied, applyPreferences is a no-op"); + RecommendedPreferences.applyPreferences(PROTOCOL_1_RECOMMENDED_PREFS); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + cleanup(); +}); + +add_task(async function test_noCustomPreferences() { + info("Applying preferences without any custom preference should not throw"); + RecommendedPreferences.applyPreferences(); + + cleanup(); +}); + +// Check that protocols can override common preferences. +add_task(async function test_override() { + info("Make sure the common preference has no user value"); + Services.prefs.clearUserPref(COMMON_PREF); + + const OVERRIDE_VALUE = 42; + const OVERRIDE_COMMON_PREF = new Map([[COMMON_PREF, OVERRIDE_VALUE]]); + + info("Apply a map of preferences overriding a common preference"); + RecommendedPreferences.applyPreferences(OVERRIDE_COMMON_PREF); + + equal( + Services.prefs.getIntPref(COMMON_PREF), + OVERRIDE_VALUE, + "The common preference was set to the expected value" + ); + + cleanup(); +}); + +function checkPreferences({ common, protocol_1, protocol_2 }) { + checkPreference(COMMON_PREF, { hasValue: common }); + checkPreference(PROTOCOL_1_PREF, { hasValue: protocol_1 }); + checkPreference(PROTOCOL_2_PREF, { hasValue: protocol_2 }); +} + +function checkPreference(pref, { hasValue }) { + equal( + Services.prefs.prefHasUserValue(pref), + hasValue, + hasValue + ? `The preference ${pref} has a user value` + : `The preference ${pref} has no user value` + ); +} diff --git a/remote/shared/test/xpcshell/test_Stack.js b/remote/shared/test/xpcshell/test_Stack.js new file mode 100644 index 0000000000..c41c5f0240 --- /dev/null +++ b/remote/shared/test/xpcshell/test_Stack.js @@ -0,0 +1,120 @@ +/* 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/. */ + +const { getFramesFromStack, isChromeFrame } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Stack.sys.mjs" +); + +const sourceFrames = [ + { + column: 1, + functionDisplayName: "foo", + line: 2, + source: "cheese", + sourceId: 1, + }, + { + column: 3, + functionDisplayName: null, + line: 4, + source: "cake", + sourceId: 2, + }, + { + column: 5, + functionDisplayName: "chrome", + line: 6, + source: "chrome://foo", + sourceId: 3, + }, +]; + +const targetFrames = [ + { + columnNumber: 1, + functionName: "foo", + lineNumber: 2, + filename: "cheese", + sourceId: 1, + }, + { + columnNumber: 3, + functionName: "", + lineNumber: 4, + filename: "cake", + sourceId: 2, + }, + { + columnNumber: 5, + functionName: "chrome", + lineNumber: 6, + filename: "chrome://foo", + sourceId: 3, + }, +]; + +add_task(async function test_getFramesFromStack() { + const stack = buildStack(sourceFrames); + const frames = getFramesFromStack(stack, { includeChrome: false }); + + ok(Array.isArray(frames), "frames is of expected type Array"); + equal(frames.length, 3, "Got expected amount of frames"); + checkFrame(frames.at(0), targetFrames.at(0)); + checkFrame(frames.at(1), targetFrames.at(1)); + checkFrame(frames.at(2), targetFrames.at(2)); +}); + +add_task(async function test_getFramesFromStack_asyncStack() { + const stack = buildStack(sourceFrames, true); + const frames = getFramesFromStack(stack); + + ok(Array.isArray(frames), "frames is of expected type Array"); + equal(frames.length, 3, "Got expected amount of frames"); + checkFrame(frames.at(0), targetFrames.at(0)); + checkFrame(frames.at(1), targetFrames.at(1)); + checkFrame(frames.at(2), targetFrames.at(2)); +}); + +add_task(async function test_isChromeFrame() { + for (const filename of ["chrome://foo/bar", "resource://foo/bar"]) { + ok(isChromeFrame({ filename }), "Frame is of expected chrome scope"); + } + + for (const filename of ["http://foo.bar", "about:blank"]) { + ok(!isChromeFrame({ filename }), "Frame is of expected content scope"); + } +}); + +function buildStack(frames, async = false) { + const parent = async ? "asyncParent" : "parent"; + + let currentFrame, stack; + for (const frame of frames) { + if (currentFrame) { + currentFrame[parent] = Object.assign({}, frame); + currentFrame = currentFrame[parent]; + } else { + stack = Object.assign({}, frame); + currentFrame = stack; + } + } + + return stack; +} + +function checkFrame(frame, expectedFrame) { + equal( + frame.columnNumber, + expectedFrame.columnNumber, + "Got expected column number" + ); + equal( + frame.functionName, + expectedFrame.functionName, + "Got expected function name" + ); + equal(frame.lineNumber, expectedFrame.lineNumber, "Got expected line number"); + equal(frame.filename, expectedFrame.filename, "Got expected filename"); + equal(frame.sourceId, expectedFrame.sourceId, "Got expected source id"); +} diff --git a/remote/shared/test/xpcshell/test_Sync.js b/remote/shared/test/xpcshell/test_Sync.js new file mode 100644 index 0000000000..de4a4d30fe --- /dev/null +++ b/remote/shared/test/xpcshell/test_Sync.js @@ -0,0 +1,436 @@ +/* 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/. */ + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const { AnimationFramePromise, Deferred, EventPromise, PollPromise } = + ChromeUtils.importESModule("chrome://remote/content/shared/Sync.sys.mjs"); + +const { Log } = ChromeUtils.importESModule( + "resource://gre/modules/Log.sys.mjs" +); + +/** + * Mimic a DOM node for listening for events. + */ +class MockElement { + constructor() { + this.capture = false; + this.eventName = null; + this.func = null; + this.mozSystemGroup = false; + this.wantUntrusted = false; + this.untrusted = false; + } + + addEventListener(name, func, options = {}) { + const { capture, mozSystemGroup, wantUntrusted } = options; + + this.eventName = name; + this.func = func; + this.capture = capture ?? false; + this.mozSystemGroup = mozSystemGroup ?? false; + this.wantUntrusted = wantUntrusted ?? false; + } + + click() { + if (this.func) { + const event = { + capture: this.capture, + mozSystemGroup: this.mozSystemGroup, + target: this, + type: this.eventName, + untrusted: this.untrusted, + wantUntrusted: this.wantUntrusted, + }; + this.func(event); + } + } + + dispatchEvent(event) { + if (this.wantUntrusted) { + this.untrusted = true; + } + this.click(); + } + + removeEventListener(name, func) { + this.capture = false; + this.eventName = null; + this.func = null; + this.mozSystemGroup = false; + this.untrusted = false; + this.wantUntrusted = false; + } +} + +class MockAppender extends Log.Appender { + constructor(formatter) { + super(formatter); + this.messages = []; + } + + append(message) { + this.doAppend(message); + } + + doAppend(message) { + this.messages.push(message); + } +} + +add_task(async function test_AnimationFramePromise() { + let called = false; + let win = { + requestAnimationFrame(callback) { + called = true; + callback(); + }, + }; + await AnimationFramePromise(win); + ok(called); +}); + +add_task(async function test_AnimationFramePromiseAbortWhenWindowClosed() { + let win = { + closed: true, + requestAnimationFrame() {}, + }; + await AnimationFramePromise(win); +}); + +add_task(async function test_DeferredPending() { + const deferred = Deferred(); + ok(deferred.pending); + + deferred.resolve(); + await deferred.promise; + ok(!deferred.pending); +}); + +add_task(async function test_DeferredRejected() { + const deferred = Deferred(); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => deferred.reject(new Error("foo")), 100); + + try { + await deferred.promise; + ok(false); + } catch (e) { + ok(!deferred.pending); + + ok(!deferred.fulfilled); + ok(deferred.rejected); + equal(e.message, "foo"); + } +}); + +add_task(async function test_DeferredResolved() { + const deferred = Deferred(); + ok(deferred.pending); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => deferred.resolve("foo"), 100); + + const result = await deferred.promise; + ok(!deferred.pending); + + ok(deferred.fulfilled); + ok(!deferred.rejected); + equal(result, "foo"); +}); + +add_task(async function test_EventPromise_subjectTypes() { + for (const subject of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new EventPromise(subject, "click"), /TypeError/); + } +}); + +add_task(async function test_EventPromise_eventNameTypes() { + const element = new MockElement(); + + for (const eventName of [42, null, undefined, true, [], {}]) { + Assert.throws(() => new EventPromise(element, eventName), /TypeError/); + } +}); + +add_task(async function test_EventPromise_subjectAndEventNameEvent() { + const element = new MockElement(); + + const clicked = new EventPromise(element, "click"); + element.click(); + const event = await clicked; + + equal(element, event.target); +}); + +add_task(async function test_EventPromise_captureTypes() { + const element = new MockElement(); + + for (const capture of [null, "foo", 42, [], {}]) { + Assert.throws( + () => new EventPromise(element, "click", { capture }), + /TypeError/ + ); + } +}); + +add_task(async function test_EventPromise_captureEvent() { + const element = new MockElement(); + + for (const capture of [undefined, false, true]) { + const expectedCapture = capture ?? false; + + const clicked = new EventPromise(element, "click", { capture }); + element.click(); + const event = await clicked; + + equal(element, event.target); + equal(expectedCapture, event.capture); + } +}); + +add_task(async function test_EventPromise_checkFnTypes() { + const element = new MockElement(); + + for (const checkFn of ["foo", 42, true, [], {}]) { + Assert.throws( + () => new EventPromise(element, "click", { checkFn }), + /TypeError/ + ); + } +}); + +add_task(async function test_EventPromise_checkFnCallback() { + const element = new MockElement(); + + let count; + const data = [ + { checkFn: null, expected_count: 0 }, + { checkFn: undefined, expected_count: 0 }, + { + checkFn: event => { + throw new Error("foo"); + }, + expected_count: 0, + }, + { checkFn: event => count++ > 0, expected_count: 2 }, + ]; + + for (const { checkFn, expected_count } of data) { + count = 0; + + const clicked = new EventPromise(element, "click", { checkFn }); + element.click(); + element.click(); + const event = await clicked; + + equal(element, event.target); + equal(expected_count, count); + } +}); + +add_task(async function test_EventPromise_mozSystemGroupTypes() { + const element = new MockElement(); + + for (const mozSystemGroup of [null, "foo", 42, [], {}]) { + Assert.throws( + () => new EventPromise(element, "click", { mozSystemGroup }), + /TypeError/ + ); + } +}); + +add_task(async function test_EventPromise_mozSystemGroupEvent() { + const element = new MockElement(); + + for (const mozSystemGroup of [undefined, false, true]) { + const expectedMozSystemGroup = mozSystemGroup ?? false; + + const clicked = new EventPromise(element, "click", { mozSystemGroup }); + element.click(); + const event = await clicked; + + equal(element, event.target); + equal(expectedMozSystemGroup, event.mozSystemGroup); + } +}); + +add_task(async function test_EventPromise_wantUntrustedTypes() { + const element = new MockElement(); + + for (let wantUntrusted of [null, "foo", 42, [], {}]) { + Assert.throws( + () => new EventPromise(element, "click", { wantUntrusted }), + /TypeError/ + ); + } +}); + +add_task(async function test_EventPromise_wantUntrustedEvent() { + for (const wantUntrusted of [undefined, false, true]) { + let expected_untrusted = wantUntrusted ?? false; + + const element = new MockElement(); + + const clicked = new EventPromise(element, "click", { wantUntrusted }); + element.dispatchEvent(new CustomEvent("click", {})); + const event = await clicked; + + equal(element, event.target); + equal(expected_untrusted, event.untrusted); + } +}); + +add_task(function test_executeSoon_callback() { + // executeSoon() is already defined for xpcshell in head.js. As such import + // our implementation into a custom namespace. + let sync = ChromeUtils.importESModule( + "chrome://remote/content/shared/Sync.sys.mjs" + ); + + for (let func of ["foo", null, true, [], {}]) { + Assert.throws(() => sync.executeSoon(func), /TypeError/); + } + + let a; + sync.executeSoon(() => { + a = 1; + }); + executeSoon(() => equal(1, a)); +}); + +add_task(function test_PollPromise_funcTypes() { + for (let type of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new PollPromise(type), /TypeError/); + } + new PollPromise(() => {}); + new PollPromise(function () {}); +}); + +add_task(function test_PollPromise_timeoutTypes() { + for (let timeout of ["foo", true, [], {}]) { + Assert.throws(() => new PollPromise(() => {}, { timeout }), /TypeError/); + } + for (let timeout of [1.2, -1]) { + Assert.throws(() => new PollPromise(() => {}, { timeout }), /RangeError/); + } + for (let timeout of [null, undefined, 42]) { + new PollPromise(resolve => resolve(1), { timeout }); + } +}); + +add_task(function test_PollPromise_intervalTypes() { + for (let interval of ["foo", null, true, [], {}]) { + Assert.throws(() => new PollPromise(() => {}, { interval }), /TypeError/); + } + for (let interval of [1.2, -1]) { + Assert.throws(() => new PollPromise(() => {}, { interval }), /RangeError/); + } + new PollPromise(() => {}, { interval: 42 }); +}); + +add_task(async function test_PollPromise_retvalTypes() { + for (let typ of [true, false, "foo", 42, [], {}]) { + strictEqual(typ, await new PollPromise(resolve => resolve(typ))); + } +}); + +add_task(async function test_PollPromise_rethrowError() { + let nevals = 0; + let err; + try { + await PollPromise(() => { + ++nevals; + throw new Error(); + }); + } catch (e) { + err = e; + } + equal(1, nevals); + ok(err instanceof Error); +}); + +add_task(async function test_PollPromise_noTimeout() { + let nevals = 0; + await new PollPromise((resolve, reject) => { + ++nevals; + nevals < 100 ? reject() : resolve(); + }); + equal(100, nevals); +}); + +add_task(async function test_PollPromise_zeroTimeout() { + // run at least once when timeout is 0 + let nevals = 0; + let start = new Date().getTime(); + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 0 } + ); + let end = new Date().getTime(); + equal(1, nevals); + less(end - start, 500); +}); + +add_task(async function test_PollPromise_timeoutElapse() { + let nevals = 0; + let start = new Date().getTime(); + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 100 } + ); + let end = new Date().getTime(); + lessOrEqual(nevals, 11); + greaterOrEqual(end - start, 100); +}); + +add_task(async function test_PollPromise_interval() { + let nevals = 0; + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 100, interval: 100 } + ); + equal(2, nevals); +}); + +add_task(async function test_PollPromise_resolve() { + const log = Log.repository.getLogger("RemoteAgent"); + const appender = new MockAppender(new Log.BasicFormatter()); + appender.level = Log.Level.Info; + log.addAppender(appender); + + const errorMessage = "PollingFailed"; + const timeout = 100; + + await new PollPromise( + (resolve, reject) => { + resolve(); + }, + { timeout, errorMessage } + ); + Assert.equal(appender.messages.length, 0); + + await new PollPromise( + (resolve, reject) => { + reject(); + }, + { timeout, errorMessage: "PollingFailed" } + ); + Assert.equal(appender.messages.length, 1); + Assert.equal(appender.messages[0].level, Log.Level.Warn); + Assert.equal(appender.messages[0].message, "PollingFailed after 100 ms"); +}); diff --git a/remote/shared/test/xpcshell/test_TabManager.js b/remote/shared/test/xpcshell/test_TabManager.js new file mode 100644 index 0000000000..e9da02c861 --- /dev/null +++ b/remote/shared/test/xpcshell/test_TabManager.js @@ -0,0 +1,56 @@ +/* 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/. */ + +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +class MockTopBrowsingContext { + constructor() { + this.embedderElement = { permanentKey: {} }; + this.id = 1; + this.top = this; + } +} + +class MockBrowsingContext { + constructor() { + this.id = 2; + + const topContext = new MockTopBrowsingContext(); + this.parent = topContext; + this.top = topContext; + } +} + +const mockTopBrowsingContext = new MockTopBrowsingContext(); +const mockBrowsingContext = new MockBrowsingContext(); + +add_task(async function test_getIdForBrowsingContext() { + // Browsing context not set. + equal(TabManager.getIdForBrowsingContext(null), null); + equal(TabManager.getIdForBrowsingContext(undefined), null); + + // Child browsing context. + equal( + TabManager.getIdForBrowsingContext(mockBrowsingContext), + mockBrowsingContext.id + ); + + const browser = mockTopBrowsingContext.embedderElement; + equal( + TabManager.getIdForBrowsingContext(mockTopBrowsingContext), + TabManager.getIdForBrowser(browser) + ); +}); + +add_task(async function test_removeTab() { + // Tab not defined. + await TabManager.removeTab(null); +}); + +add_task(async function test_selectTab() { + // Tab not defined. + await TabManager.selectTab(null); +}); diff --git a/remote/shared/test/xpcshell/test_UUID.js b/remote/shared/test/xpcshell/test_UUID.js new file mode 100644 index 0000000000..e929a9e0a8 --- /dev/null +++ b/remote/shared/test/xpcshell/test_UUID.js @@ -0,0 +1,21 @@ +/* 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/. */ + +const { generateUUID } = ChromeUtils.importESModule( + "chrome://remote/content/shared/UUID.sys.mjs" +); + +add_task(function test_UUID_valid() { + const uuid = generateUUID(); + const regExp = new RegExp( + /^[a-f|0-9]{8}-[a-f|0-9]{4}-[a-f|0-9]{4}-[a-f|0-9]{4}-[a-f|0-9]{12}$/g + ); + ok(regExp.test(uuid)); +}); + +add_task(function test_UUID_unique() { + const uuid1 = generateUUID(); + const uuid2 = generateUUID(); + notEqual(uuid1, uuid2); +}); diff --git a/remote/shared/test/xpcshell/xpcshell.toml b/remote/shared/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..ebb6c77950 --- /dev/null +++ b/remote/shared/test/xpcshell/xpcshell.toml @@ -0,0 +1,24 @@ +[DEFAULT] +head = "head.js" + +["test_AppInfo.js"] + +["test_ChallengeHeaderParser.js"] + +["test_DOM.js"] + +["test_Format.js"] + +["test_Navigate.js"] + +["test_Realm.js"] + +["test_RecommendedPreferences.js"] + +["test_Stack.js"] + +["test_Sync.js"] + +["test_TabManager.js"] + +["test_UUID.js"] diff --git a/remote/shared/webdriver/Actions.sys.mjs b/remote/shared/webdriver/Actions.sys.mjs new file mode 100644 index 0000000000..4f5a41a421 --- /dev/null +++ b/remote/shared/webdriver/Actions.sys.mjs @@ -0,0 +1,2376 @@ +/* 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/. */ + +/* eslint no-dupe-keys:off */ +/* eslint-disable no-restricted-globals */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + event: "chrome://remote/content/marionette/event.sys.mjs", + keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + Sleep: "chrome://remote/content/marionette/sync.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// TODO? With ES 2016 and Symbol you can make a safer approximation +// to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689 +/** + * Implements WebDriver Actions API: a low-level interface for providing + * virtualised device input to the web browser. + * + * Typical usage is to construct an action chain and then dispatch it: + * const state = new action.State(); + * const chain = new action.Chain.fromJSON(state, protocolData); + * await chain.dispatch(state, window); + * + * @namespace + */ +export const action = {}; + +// Max interval between two clicks that should result in a dblclick or a tripleclick (in ms) +export const CLICK_INTERVAL = 640; + +/** Map from normalized key value to UI Events modifier key name */ +const MODIFIER_NAME_LOOKUP = { + Alt: "alt", + Shift: "shift", + Control: "ctrl", + Meta: "meta", +}; + +/** + * State associated with actions + * + * Typically each top-level browsing context in a session should have a single State object + */ +action.State = class { + constructor() { + this.clickTracker = new ClickTracker(); + /** + * A map between input ID and the device state for that input + * source, with one entry for each active input source. + * + * Maps string => InputSource + */ + this.inputStateMap = new Map(); + + /** + * List of {@link Action} associated with current session. Used to + * manage dispatching events when resetting the state of the input sources. + * Reset operations are assumed to be idempotent. + */ + this.inputsToCancel = new TickActions(); + + /** + * Map between string input id and numeric pointer id + */ + this.pointerIdMap = new Map(); + } + + toString() { + return `[object ${this.constructor.name} ${JSON.stringify(this)}]`; + } + + /** + * Reset state stored in this object. + * It is an error to use the State object after calling release(). + * + * @param {WindowProxy} win Current window global. + */ + async release(win) { + this.inputsToCancel.reverse(); + await this.inputsToCancel.dispatch(this, win); + } + + /** + * Get the state for a given input source. + * + * @param {string} id Input source id. + * @returns {InputSource} Input source state. + */ + getInputSource(id) { + return this.inputStateMap.get(id); + } + + /** + * Find or add state for an input source. The caller should verify + * that the returned state is the expected type. + * + * @param {string} id Input source id. + * @param {InputSource} newInputSource Input source state. + */ + getOrAddInputSource(id, newInputSource) { + let inputSource = this.getInputSource(id); + + if (inputSource === undefined) { + this.inputStateMap.set(id, newInputSource); + inputSource = newInputSource; + } + + return inputSource; + } + + /** + * Iterate over all input states of a given type + * + * @param {string} type Input source type name (e.g. "pointer"). + * @returns {Iterator} Iterator over [id, input source]. + */ + *inputSourcesByType(type) { + for (const [id, inputSource] of this.inputStateMap) { + if (inputSource.type === type) { + yield [id, inputSource]; + } + } + } + + /** + * Get a numerical pointer id for a given pointer + * + * Pointer ids are positive integers. Mouse pointers are typically + * ids 0 or 1. Non-mouse pointers never get assigned id < 2. Each + * pointer gets a unique id. + * + * @param {string} id Pointer id. + * @param {string} type Pointer type. + * @returns {number} Numerical pointer id. + */ + getPointerId(id, type) { + let pointerId = this.pointerIdMap.get(id); + + if (pointerId === undefined) { + // Reserve pointer ids 0 and 1 for mouse pointers + const idValues = Array.from(this.pointerIdMap.values()); + + if (type === "mouse") { + for (const mouseId of [0, 1]) { + if (!idValues.includes(mouseId)) { + pointerId = mouseId; + break; + } + } + } + + if (pointerId === undefined) { + pointerId = Math.max(1, ...idValues) + 1; + } + this.pointerIdMap.set(id, pointerId); + } + + return pointerId; + } +}; + +export class ClickTracker { + #count; + #lastButtonClicked; + #timer; + + constructor() { + this.#count = 0; + this.#lastButtonClicked = null; + } + + get count() { + return this.#count; + } + + #cancelTimer() { + lazy.clearTimeout(this.#timer); + } + + #startTimer() { + this.#timer = lazy.setTimeout(this.reset.bind(this), CLICK_INTERVAL); + } + + /** + * Reset tracking mouse click counter. + */ + reset() { + this.#cancelTimer(); + this.#count = 0; + this.#lastButtonClicked = null; + } + + /** + * Track |button| click to identify possible double or triple click. + * + * @param {number} button + * A positive integer that refers to a mouse button. + */ + setClick(button) { + this.#cancelTimer(); + + if ( + this.#lastButtonClicked === null || + this.#lastButtonClicked === button + ) { + this.#count++; + } else { + this.#count = 1; + } + + this.#lastButtonClicked = button; + this.#startTimer(); + } +} + +/** + * Device state for an input source. + */ +class InputSource { + #id; + static type = null; + + constructor(id) { + this.#id = id; + this.type = this.constructor.type; + } + + toString() { + return `[object ${this.constructor.name} id: ${this.#id} type: ${ + this.type + }]`; + } + + /** + * @param {State} state Actions state. + * @param {Sequence} actionSequence Actions for a specific input source. + * + * @returns {InputSource} + * An {@link InputSource} object for the type of the + * {@link actionSequence}. + * + * @throws {InvalidArgumentError} + * If {@link actionSequence.type} is not valid. + */ + static fromJSON(state, actionSequence) { + const { id, type } = actionSequence; + + lazy.assert.string( + id, + lazy.pprint`Expected "id" to be a string, got ${id}` + ); + + const cls = inputSourceTypes.get(type); + if (cls === undefined) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected known action type, got ${type}` + ); + } + + const sequenceInputSource = cls.fromJSON(state, actionSequence); + const inputSource = state.getOrAddInputSource(id, sequenceInputSource); + + if (inputSource.type !== type) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected input source ${id} to be ` + + `type ${inputSource.type}, got ${type}` + ); + } + } +} + +/** + * Input state not associated with a specific physical device. + */ +class NullInputSource extends InputSource { + static type = "none"; + + static fromJSON(state, actionSequence) { + const { id } = actionSequence; + + return new this(id); + } +} + +/** + * Input state associated with a keyboard-type device. + */ +class KeyInputSource extends InputSource { + static type = "key"; + + constructor(id) { + super(id); + + this.pressed = new Set(); + this.alt = false; + this.shift = false; + this.ctrl = false; + this.meta = false; + } + + static fromJSON(state, actionSequence) { + const { id } = actionSequence; + + return new this(id); + } + + /** + * Update modifier state according to |key|. + * + * @param {string} key + * Normalized key value of a modifier key. + * @param {boolean} value + * Value to set the modifier attribute to. + * + * @throws {InvalidArgumentError} + * If |key| is not a modifier. + */ + setModState(key, value) { + if (key in MODIFIER_NAME_LOOKUP) { + this[MODIFIER_NAME_LOOKUP[key]] = value; + } else { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected "key" to be one of ${Object.keys( + MODIFIER_NAME_LOOKUP + )}, got ${key}` + ); + } + } + + /** + * Check whether |key| is pressed. + * + * @param {string} key + * Normalized key value. + * + * @returns {boolean} + * True if |key| is in set of pressed keys. + */ + isPressed(key) { + return this.pressed.has(key); + } + + /** + * Add |key| to the set of pressed keys. + * + * @param {string} key + * Normalized key value. + * + * @returns {boolean} + * True if |key| is in list of pressed keys. + */ + press(key) { + return this.pressed.add(key); + } + + /** + * Remove |key| from the set of pressed keys. + * + * @param {string} key + * Normalized key value. + * + * @returns {boolean} + * True if |key| was present before removal, false otherwise. + */ + release(key) { + return this.pressed.delete(key); + } +} + +/** + * Input state associated with a pointer-type device. + */ +class PointerInputSource extends InputSource { + static type = "pointer"; + + /** + * @param {string} id InputSource id. + * @param {Pointer} pointer Object representing the specific pointer + * type associated with this input source. + */ + constructor(id, pointer) { + super(id); + + this.pointer = pointer; + this.x = 0; + this.y = 0; + this.pressed = new Set(); + } + + /** + * Check whether |button| is pressed. + * + * @param {number} button + * Positive integer that refers to a mouse button. + * + * @returns {boolean} + * True if |button| is in set of pressed buttons. + */ + isPressed(button) { + lazy.assert.positiveInteger(button); + return this.pressed.has(button); + } + + /** + * Add |button| to the set of pressed keys. + * + * @param {number} button + * Positive integer that refers to a mouse button. + * + * @returns {Set} + * Set of pressed buttons. + */ + press(button) { + lazy.assert.positiveInteger(button); + this.pressed.add(button); + } + + /** + * Remove |button| from the set of pressed buttons. + * + * @param {number} button + * A positive integer that refers to a mouse button. + * + * @returns {boolean} + * True if |button| was present before removals, false otherwise. + */ + release(button) { + lazy.assert.positiveInteger(button); + return this.pressed.delete(button); + } + + static fromJSON(state, actionSequence) { + const { id, parameters } = actionSequence; + let pointerType = "mouse"; + + if (parameters !== undefined) { + lazy.assert.object( + parameters, + lazy.pprint`Expected "parameters" to be an object, got ${parameters}` + ); + + if (parameters.pointerType !== undefined) { + pointerType = lazy.assert.string( + parameters.pointerType, + lazy.pprint( + `Expected "pointerType" to be a string, got ${parameters.pointerType}` + ) + ); + + if (!["mouse", "pen", "touch"].includes(pointerType)) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected "pointerType" to be one of "mouse", "pen", or "touch"` + ); + } + } + } + + const pointerId = state.getPointerId(id, pointerType); + const pointer = Pointer.fromJSON(pointerId, pointerType); + + return new this(id, pointer); + } +} + +/** + * Input state associated with a wheel-type device. + */ +class WheelInputSource extends InputSource { + static type = "wheel"; + + static fromJSON(state, actionSequence) { + const { id } = actionSequence; + + return new this(id); + } +} + +const inputSourceTypes = new Map(); +for (const cls of [ + NullInputSource, + KeyInputSource, + PointerInputSource, + WheelInputSource, +]) { + inputSourceTypes.set(cls.type, cls); +} + +/** + * Representation of a coordinate origin + */ +class Origin { + /** + * Viewport coordinates of the origin of this coordinate system. + * + * This is overridden in subclasses to provide a class-specific origin. + * + * @param {InputSource} inputSource - State of current input device. + * @param {WindowProxy} win - Current window global + */ + getOriginCoordinates(inputSource, win) { + throw new Error( + `originCoordinates not defined for ${this.constructor.name}` + ); + } + + /** + * Convert [x, y] coordinates to viewport coordinates + * + * @param {InputSource} inputSource - State of the current input device + * @param {Array<number>} coords - [x, y] coordinate of target relative to origin + * @param {WindowProxy} win - Current window global + */ + getTargetCoordinates(inputSource, coords, win) { + const [x, y] = coords; + const origin = this.getOriginCoordinates(inputSource, win); + + return [origin.x + x, origin.y + y]; + } + + /** + * @param {Element|string=} origin - Type of orgin, one of "viewport", "pointer", element or undefined. + * + * @returns {Origin} - An origin object representing the origin. + * + * @throws {InvalidArgumentError} + * If <code>origin</code> isn't a valid origin. + */ + static fromJSON(origin) { + if (origin === undefined || origin === "viewport") { + return new ViewportOrigin(); + } + if (origin === "pointer") { + return new PointerOrigin(); + } + if (lazy.dom.isElement(origin)) { + return new ElementOrigin(origin); + } + + throw new lazy.error.InvalidArgumentError( + `Expected "origin" to be undefined, "viewport", "pointer", ` + + lazy.pprint`or an element, got: ${origin}` + ); + } +} + +class ViewportOrigin extends Origin { + getOriginCoordinates(inputSource, win) { + return { x: 0, y: 0 }; + } +} + +class PointerOrigin extends Origin { + getOriginCoordinates(inputSource, win) { + return { x: inputSource.x, y: inputSource.y }; + } +} + +class ElementOrigin extends Origin { + /** + * @param {Element} element - The element providing the coordinate origin. + */ + constructor(element) { + super(); + + this.element = element; + } + + getOriginCoordinates(inputSource, win) { + const clientRects = this.element.getClientRects(); + + // The spec doesn't handle this case; https://github.com/w3c/webdriver/issues/1642 + if (!clientRects.length) { + throw new lazy.error.MoveTargetOutOfBoundsError( + lazy.pprint`Origin element ${this.element} is not displayed` + ); + } + + return lazy.dom.getInViewCentrePoint(clientRects[0], win); + } +} + +/** + * Repesents the behaviour of a single input source at a single + * point in time. + * + * @param {string} id - Input source ID. + */ +class Action { + /** Type of the input source associated with this action */ + static type = null; + /** Type of action specific to the input source */ + static subtype = null; + /** Whether this kind of action affects the overall duration of a tick */ + affectsWallClockTime = false; + + constructor(id) { + this.id = id; + this.type = this.constructor.type; + this.subtype = this.constructor.subtype; + } + + toString() { + return `[${this.constructor.name} ${this.type}:${this.subtype}]`; + } + + /** + * Dispatch the action to the relevant window. + * + * This is overridden by subclasses to implement the type-specific + * dispatch of the action. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + throw new Error( + `Action subclass ${this.constructor.name} must override dispatch()` + ); + } + + /** + * @param {string} type - Input source type. + * @param {string} id - Input source id. + * @param {object} actionItem - Object representing a single action. + * + * @returns {Action} - An action that can be dispatched. + * + * @throws {InvalidArgumentError} + * If any <code>actionSequence</code> or <code>actionItem</code> + * attributes are invalid. + */ + static fromJSON(type, id, actionItem) { + lazy.assert.object( + actionItem, + lazy.pprint`Expected "action" to be an object, got ${actionItem}` + ); + + const subtype = actionItem.type; + const subtypeMap = actionTypes.get(type); + + if (subtypeMap === undefined) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected known action type, got ${type}` + ); + } + + let cls = subtypeMap.get(subtype); + // Non-device specific actions can happen for any action type + if (cls === undefined) { + cls = actionTypes.get("none").get(subtype); + } + if (cls === undefined) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected known subtype for type ${type}, got ${subtype}` + ); + } + + return cls.fromJSON(id, actionItem); + } +} + +/** + * Action not associated with a specific input device. + */ +class NullAction extends Action { + static type = "none"; +} + +/** + * Action that waits for a given duration. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.duration - Time to pause, in ms. + */ +class PauseAction extends NullAction { + static subtype = "pause"; + affectsWallClockTime = true; + + constructor(id, options) { + super(id); + + const { duration } = options; + this.duration = duration; + } + + /** + * Dispatch pause action + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + const ms = this.duration ?? tickDuration; + + lazy.logger.trace( + ` Dispatch ${this.constructor.name} with ${this.id} ${ms}` + ); + + return lazy.Sleep(ms); + } + + static fromJSON(id, actionItem) { + const { duration } = actionItem; + + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` + ); + } + + return new this(id, { duration }); + } +} + +/** + * Action associated with a keyboard input device + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {string} options.value - Key character. + */ +class KeyAction extends Action { + static type = "key"; + + constructor(id, options) { + super(id); + + const { value } = options; + this.value = value; + } + + getEventData(inputSource) { + let value = this.value; + + if (inputSource.shift) { + value = lazy.keyData.getShiftedKey(value); + } + + return new KeyEventData(value); + } + + static fromJSON(id, actionItem) { + const { value } = actionItem; + + // TODO countGraphemes + // TODO key.value could be a single code point like "\uE012" + // (see rawKey) or "grapheme cluster" + // https://bugzilla.mozilla.org/show_bug.cgi?id=1496323 + + lazy.assert.string( + value, + 'Expected "value" to be a string that represents single code point ' + + lazy.pprint`or grapheme cluster, got ${value}` + ); + + return new this(id, { value }); + } +} + +/** + * Action equivalent to pressing a key on a keyboard. + * + * @param {string} id - Input source ID. + * @param {string} value - Key character. + */ +class KeyDownAction extends KeyAction { + static subtype = "keyDown"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${this.id} ${this.value}` + ); + + return new Promise(resolve => { + const keyEvent = this.getEventData(inputSource); + keyEvent.repeat = inputSource.isPressed(keyEvent.key); + inputSource.press(keyEvent.key); + + if (keyEvent.key in MODIFIER_NAME_LOOKUP) { + inputSource.setModState(keyEvent.key, true); + } + + // Append a copy of |a| with keyUp subtype + state.inputsToCancel.push(new KeyUpAction(this.id, this)); + keyEvent.update(state, inputSource); + lazy.event.sendKeyDown(keyEvent, win); + + resolve(); + }); + } +} + +/** + * Action equivalent to releasing a key on a keyboard. + * + * @param {string} id - Input source ID. + * @param {string} value - Key character. + */ +class KeyUpAction extends KeyAction { + static subtype = "keyUp"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${this.id} ${this.value}` + ); + + return new Promise(resolve => { + const keyEvent = this.getEventData(inputSource); + + if (!inputSource.isPressed(keyEvent.key)) { + resolve(); + return; + } + + if (keyEvent.key in MODIFIER_NAME_LOOKUP) { + inputSource.setModState(keyEvent.key, false); + } + + inputSource.release(keyEvent.key); + keyEvent.update(state, inputSource); + + lazy.event.sendKeyUp(keyEvent, win); + resolve(); + }); + } +} + +/** + * Action associated with a pointer input device + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerAction extends Action { + static type = "pointer"; + + constructor(id, options) { + super(id); + const { + width, + height, + pressure, + tangentialPressure, + tiltX, + tiltY, + twist, + altitudeAngle, + azimuthAngle, + } = options; + this.width = width; + this.height = height; + this.pressure = pressure; + this.tangentialPressure = tangentialPressure; + this.tiltX = tiltX; + this.tiltY = tiltY; + this.twist = twist; + this.altitudeAngle = altitudeAngle; + this.azimuthAngle = azimuthAngle; + } + + /** + * Validate properties common to all pointer types + * + * @param {object} actionItem - Object representing a single action. + */ + static validateCommon(actionItem) { + const { + width, + height, + pressure, + tangentialPressure, + tiltX, + tiltY, + twist, + altitudeAngle, + azimuthAngle, + } = actionItem; + if (width !== undefined) { + lazy.assert.positiveInteger( + width, + lazy.pprint`Expected "width" to be a positive integer, got ${width}` + ); + } + if (height !== undefined) { + lazy.assert.positiveInteger( + height, + lazy.pprint`Expected "height" to be a positive integer, got ${height}` + ); + } + if (pressure !== undefined) { + lazy.assert.numberInRange( + pressure, + [0, 1], + lazy.pprint`Expected "pressure" to be in range 0 to 1, got ${pressure}` + ); + } + if (tangentialPressure !== undefined) { + lazy.assert.numberInRange( + tangentialPressure, + [-1, 1], + 'Expected "tangentialPressure" to be in range -1 to 1, ' + + lazy.pprint`got ${tangentialPressure}` + ); + } + if (tiltX !== undefined) { + lazy.assert.integerInRange( + tiltX, + [-90, 90], + lazy.pprint`Expected "tiltX" to be in range -90 to 90, got ${tiltX}` + ); + } + if (tiltY !== undefined) { + lazy.assert.integerInRange( + tiltY, + [-90, 90], + lazy.pprint`Expected "tiltY" to be in range -90 to 90, got ${tiltY}` + ); + } + if (twist !== undefined) { + lazy.assert.integerInRange( + twist, + [0, 359], + lazy.pprint`Expected "twist" to be in range 0 to 359, got ${twist}` + ); + } + if (altitudeAngle !== undefined) { + lazy.assert.numberInRange( + altitudeAngle, + [0, Math.PI / 2], + 'Expected "altitudeAngle" to be in range 0 to ${Math.PI / 2}, ' + + lazy.pprint`got ${altitudeAngle}` + ); + } + if (azimuthAngle !== undefined) { + lazy.assert.numberInRange( + azimuthAngle, + [0, 2 * Math.PI], + 'Expected "azimuthAngle" to be in range 0 to ${2 * Math.PI}, ' + + lazy.pprint`got ${azimuthAngle}` + ); + } + + return { + width, + height, + pressure, + tangentialPressure, + tiltX, + tiltY, + twist, + altitudeAngle, + azimuthAngle, + }; + } +} + +/** + * Action associated with a pointer input device being depressed. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.button - Button being pressed. For devices without buttons (e.g. touch), this should be 0. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerDownAction extends PointerAction { + static subtype = "pointerDown"; + + constructor(id, options) { + super(id, options); + + const { button } = options; + this.button = button; + } + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}` + ); + + return new Promise(resolve => { + if (inputSource.isPressed(this.button)) { + resolve(); + return; + } + + inputSource.press(this.button); + // Append a copy of |a| with pointerUp subtype + state.inputsToCancel.push(new PointerUpAction(this.id, this)); + inputSource.pointer.pointerDown(state, inputSource, this, win); + resolve(); + }); + } + + static fromJSON(id, actionItem) { + const { button } = actionItem; + const props = PointerAction.validateCommon(actionItem); + + lazy.assert.positiveInteger( + button, + lazy.pprint`Expected "button" to be a positive integer, got ${button}` + ); + + props.button = button; + + return new this(id, props); + } +} + +/** + * Action associated with a pointer input device being released. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.button - Button being released. For devices without buttons (e.g. touch), this should be 0. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerUpAction extends PointerAction { + static subtype = "pointerUp"; + + constructor(id, options) { + super(id, options); + + const { button } = options; + this.button = button; + } + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}` + ); + + return new Promise(resolve => { + if (!inputSource.isPressed(this.button)) { + resolve(); + return; + } + + inputSource.release(this.button); + inputSource.pointer.pointerUp(state, inputSource, this, win); + + resolve(); + }); + } + + static fromJSON(id, actionItem) { + const { button } = actionItem; + const props = PointerAction.validateCommon(actionItem); + + lazy.assert.positiveInteger( + button, + lazy.pprint`Expected "button" to be a positive integer, got ${button}` + ); + + props.button = button; + + return new this(id, props); + } +} + +/** + * Action associated with a pointer input device being moved. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + * @param {number=} options.duration - Duration of move in ms. + * @param {Origin} options.origin - Origin of target coordinates. + * @param {number} options.x - X value of target coordinates. + * @param {number} options.y - Y value of target coordinates. + */ +class PointerMoveAction extends PointerAction { + static subtype = "pointerMove"; + affectsWallClockTime = true; + + constructor(id, options) { + super(id, options); + + const { duration, origin, x, y } = options; + this.duration = duration; + this.origin = origin; + this.x = x; + this.y = y; + } + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} x: ${this.x} y: ${this.y}` + ); + + const target = this.origin.getTargetCoordinates( + inputSource, + [this.x, this.y], + win + ); + + assertInViewPort(target, win); + + return moveOverTime( + [[inputSource.x, inputSource.y]], + [target], + this.duration ?? tickDuration, + target => this.performPointerMoveStep(state, inputSource, target, win) + ); + } + + /** + * Perform one part of a pointer move corresponding to a specific emitted event. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Array<Array<number>>} targets - Array of [x, y] arrays + * specifying the viewport coordinates to move to. + * @param {WindowProxy} win - Current window global. + */ + performPointerMoveStep(state, inputSource, targets, win) { + if (targets.length !== 1) { + throw new Error( + "PointerMoveAction.performPointerMoveStep requires a single target" + ); + } + + const target = targets[0]; + lazy.logger.trace( + `PointerMoveAction.performPointerMoveStep ${JSON.stringify(target)}` + ); + if (target[0] == inputSource.x && target[1] == inputSource.y) { + return; + } + + inputSource.pointer.pointerMove( + state, + inputSource, + this, + target[0], + target[1], + win + ); + + inputSource.x = target[0]; + inputSource.y = target[1]; + } + + static fromJSON(id, actionItem) { + const { duration, origin, x, y } = actionItem; + + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` + ); + } + + const originObject = Origin.fromJSON(origin); + lazy.assert.integer( + x, + lazy.pprint`Expected "x" to be an integer, got ${x}` + ); + lazy.assert.integer( + y, + lazy.pprint`Expected "y" to be an integer, got ${y}` + ); + const props = PointerAction.validateCommon(actionItem); + + props.duration = duration; + props.origin = originObject; + props.x = x; + props.y = y; + + return new this(id, props); + } +} + +/** + * Action associated with a wheel input device + * + */ +class WheelAction extends Action { + static type = "wheel"; +} + +/** + * Action associated with scrolling a scroll wheel + * + * @param {number} duration - Duration of scroll in ms. + * @param {Origin} origin - Origin of target coordinates. + * @param {number} x - X value of scroll coordinates. + * @param {number} y - Y value of scroll coordinates. + * @param {number} deltaX - Number of CSS pixels to scroll in X direction. + * @param {number} deltaY - Number of CSS pixels to scroll in Y direction + */ +class WheelScrollAction extends WheelAction { + static subtype = "scroll"; + affectsWallClockTime = true; + + constructor(id, { duration, origin, x, y, deltaX, deltaY }) { + super(id); + + this.duration = duration; + this.origin = origin; + this.x = x; + this.y = y; + this.deltaX = deltaX; + this.deltaY = deltaY; + } + + static fromJSON(id, actionItem) { + const { duration, origin, x, y, deltaX, deltaY } = actionItem; + + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` + ); + } + + const originObject = Origin.fromJSON(origin); + if (originObject instanceof PointerOrigin) { + throw new lazy.error.InvalidArgumentError( + `"pointer" origin not supported for "wheel" input source.` + ); + } + + lazy.assert.integer( + x, + lazy.pprint`Expected "x" to be an Integer, got ${x}` + ); + lazy.assert.integer( + y, + lazy.pprint`Expected "y" to be an Integer, got ${y}` + ); + lazy.assert.integer( + deltaX, + lazy.pprint`Expected "deltaX" to be an Integer, got ${deltaX}` + ); + lazy.assert.integer( + deltaY, + lazy.pprint`Expected "deltaY" to be an Integer, got ${deltaY}` + ); + + return new this(id, { + duration, + origin: originObject, + x, + y, + deltaX, + deltaY, + }); + } + + async dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with id: ${this.id} deltaX: ${this.deltaX} deltaY: ${this.deltaY}` + ); + + const scrollCoordinates = this.origin.getTargetCoordinates( + inputSource, + [this.x, this.y], + win + ); + assertInViewPort(scrollCoordinates, win); + + const startX = 0; + const startY = 0; + // This is an action-local state that holds the amount of scroll completed + const deltaPosition = [startX, startY]; + + await moveOverTime( + [[startX, startY]], + [[this.deltaX, this.deltaY]], + this.duration ?? tickDuration, + deltaTarget => + this.performOneWheelScroll( + scrollCoordinates, + deltaPosition, + deltaTarget, + win + ) + ); + } + + /** + * Perform one part of a wheel scroll corresponding to a specific emitted event. + * + * @param {Array<number>} scrollCoordinates - [x, y] viewport coordinates of the scroll. + * @param {Array<number>} deltaPosition - [deltaX, deltaY] coordinates of the scroll before this event. + * @param {Array<Array<number>>} deltaTargets - Array of [deltaX, deltaY] coordinates to scroll to. + * @param {WindowProxy} win - Current window global. + */ + performOneWheelScroll(scrollCoordinates, deltaPosition, deltaTargets, win) { + if (deltaTargets.length !== 1) { + throw new Error("Can only scroll one wheel at a time"); + } + if (deltaPosition[0] == this.deltaX && deltaPosition[1] == this.deltaY) { + return; + } + + const deltaTarget = deltaTargets[0]; + const deltaX = deltaTarget[0] - deltaPosition[0]; + const deltaY = deltaTarget[1] - deltaPosition[1]; + const eventData = new WheelEventData({ + deltaX, + deltaY, + deltaZ: 0, + }); + + lazy.event.synthesizeWheelAtPoint( + scrollCoordinates[0], + scrollCoordinates[1], + eventData, + win + ); + + // Update the current scroll position for the caller + deltaPosition[0] = deltaTarget[0]; + deltaPosition[1] = deltaTarget[1]; + } +} + +/** + * Group of actions representing behaviour of all touch pointers during a single tick. + * + * For touch pointers, we need to call into the platform once with all + * the actions so that they are regarded as simultaneous. This means + * we don't use the `dispatch()` method on the underlying actions, but + * instead use one on this group object. + */ +class TouchActionGroup { + static type = null; + + constructor() { + this.type = this.constructor.type; + this.actions = new Map(); + } + + static forType(type) { + const cls = touchActionGroupTypes.get(type); + + return new cls(); + } + + /** + * Add action corresponding to a specific pointer to the group. + * + * @param {InputSource} inputSource - State of the current input device. + * @param {Action} action - Action to add to the group + */ + addPointer(inputSource, action) { + if (action.subtype !== this.type) { + throw new Error( + `Added action of unexpected type, got ${action.subtype}, expected ${this.type}` + ); + } + + this.actions.set(action.id, [inputSource, action]); + } + + /** + * Dispatch the action group to the relevant window. + * + * This is overridden by subclasses to implement the type-specific + * dispatch of the action. + * + * @param {State} state - Actions state. + * @param {null} inputSource + * This is always null; the argument only exists for compatibility + * with {@link Action.dispatch}. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + throw new Error( + "TouchActionGroup subclass missing dispatch implementation" + ); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * depressed during a single tick. + */ +class PointerDownTouchActionGroup extends TouchActionGroup { + static type = "pointerDown"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${Array.from( + this.actions.values() + ).map(x => x[1].id)}` + ); + + return new Promise(resolve => { + if (inputSource !== null) { + throw new Error( + "Expected null inputSource for PointerDownTouchActionGroup.dispatch" + ); + } + + // Only include pointers that are not already depressed + const actions = Array.from(this.actions.values()).filter( + ([actionInputSource, action]) => + !actionInputSource.isPressed(action.button) + ); + + if (actions.length) { + const eventData = new MultiTouchEventData("touchstart"); + + for (const [actionInputSource, action] of actions) { + // Skip if already pressed + eventData.addPointerEventData(actionInputSource, action); + actionInputSource.press(action.button); + // Append a copy of |action| with pointerUp subtype + state.inputsToCancel.push(new PointerUpAction(action.id, action)); + eventData.update(state, actionInputSource); + } + + // Touch start events must include all depressed touch pointers + for (const [id, pointerInputSource] of state.inputSourcesByType( + "pointer" + )) { + if ( + pointerInputSource.pointer.type === "touch" && + !this.actions.has(id) && + pointerInputSource.isPressed(0) + ) { + eventData.addPointerEventData(pointerInputSource, {}); + eventData.update(state, pointerInputSource); + } + } + lazy.event.synthesizeMultiTouch(eventData, win); + } + + resolve(); + }); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * released during a single tick. + */ +class PointerUpTouchActionGroup extends TouchActionGroup { + static type = "pointerUp"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${Array.from( + this.actions.values() + ).map(x => x[1].id)}` + ); + + return new Promise(resolve => { + if (inputSource !== null) { + throw new Error( + "Expected null inputSource for PointerUpTouchActionGroup.dispatch" + ); + } + + // Only include pointers that are not already depressed + const actions = Array.from(this.actions.values()).filter( + ([actionInputSource, action]) => + actionInputSource.isPressed(action.button) + ); + + if (actions.length) { + const eventData = new MultiTouchEventData("touchend"); + for (const [actionInputSource, action] of actions) { + eventData.addPointerEventData(actionInputSource, action); + actionInputSource.release(action.button); + eventData.update(state, actionInputSource); + } + lazy.event.synthesizeMultiTouch(eventData, win); + } + + resolve(); + }); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * moved during a single tick. + */ +class PointerMoveTouchActionGroup extends TouchActionGroup { + static type = "pointerMove"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${Array.from(this.actions).map( + x => x[1].id + )}` + ); + if (inputSource !== null) { + throw new Error( + "Expected null inputSource for PointerMoveTouchActionGroup.dispatch" + ); + } + + let startCoords = []; + let targetCoords = []; + + for (const [actionInputSource, action] of this.actions.values()) { + const target = action.origin.getTargetCoordinates( + actionInputSource, + [action.x, action.y], + win + ); + + assertInViewPort(target, win); + startCoords.push([actionInputSource.x, actionInputSource.y]); + targetCoords.push(target); + } + + // Touch move events must include all depressed touch pointers, even if they are static + // This can end up generating pointermove events even for static pointers, but Gecko + // seems to generate a lot of pointermove events anyway, so this seems like the lesser + // problem. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1779206 + const staticTouchPointers = []; + for (const [id, pointerInputSource] of state.inputSourcesByType( + "pointer" + )) { + if ( + pointerInputSource.pointer.type === "touch" && + !this.actions.has(id) && + pointerInputSource.isPressed(0) + ) { + staticTouchPointers.push(pointerInputSource); + } + } + + return moveOverTime( + startCoords, + targetCoords, + this.duration ?? tickDuration, + currentTargetCoords => + this.performPointerMoveStep( + state, + staticTouchPointers, + currentTargetCoords, + win + ) + ); + } + + /** + * Perform one part of a pointer move corresponding to a specific emitted event. + * + * @param {State} state - Actions state. + * @param {Array.<PointerInputSource>} staticTouchPointers + * Array of PointerInputSource objects for pointers that aren't involved in + * the touch move. + * @param {Array.<Array>} targetCoords + * Array of [x, y] arrays specifying the viewport coordinates to move to. + * @param {WindowProxy} win - Current window global. + */ + performPointerMoveStep(state, staticTouchPointers, targetCoords, win) { + if (targetCoords.length !== this.actions.size) { + throw new Error("Expected one target per pointer"); + } + + const perPointerData = Array.from(this.actions.values()).map( + ([inputSource, action], i) => { + const target = targetCoords[i]; + return [inputSource, action, target]; + } + ); + const reachedTarget = perPointerData.every( + ([inputSource, action, target]) => + target[0] === inputSource.x && target[1] === inputSource.y + ); + + if (reachedTarget) { + return; + } + + const eventData = new MultiTouchEventData("touchmove"); + for (const [inputSource, action, target] of perPointerData) { + inputSource.x = target[0]; + inputSource.y = target[1]; + eventData.addPointerEventData(inputSource, action); + eventData.update(state, inputSource); + } + + for (const inputSource of staticTouchPointers) { + eventData.addPointerEventData(inputSource, {}); + eventData.update(state, inputSource); + } + + lazy.event.synthesizeMultiTouch(eventData, win); + } +} + +const touchActionGroupTypes = new Map(); +for (const cls of [ + PointerDownTouchActionGroup, + PointerUpTouchActionGroup, + PointerMoveTouchActionGroup, +]) { + touchActionGroupTypes.set(cls.type, cls); +} + +/** + * Split a transition from startCoord to targetCoord linearly over duration. + * + * startCoords and targetCoords are lists of [x,y] positions in some space + * (e.g. screen position or scroll delta). This function will linearly + * interpolate intermediate positions, sending out roughly one event + * per frame to simulate moving between startCoord and targetCoord in + * a time of tickDuration milliseconds. The callback function is + * responsible for actually emitting the event, given the current + * position in the coordinate space. + * + * @param {Array.<Array>} startCoords + * Array of initial [x, y] coordinates for each input source involved + * in the move. + * @param {Array.<Array>} targetCoords + * Array of target [x, y] coordinates for each input source involved + * in the move. + * @param {number} duration - Time in ms the move will take. + * @param {Function} callback + * Function that actually performs the move. This takes a single parameter + * which is an array of [x, y] coordinates corresponding to the move + * targets. + */ +async function moveOverTime(startCoords, targetCoords, duration, callback) { + lazy.logger.trace( + `moveOverTime start: ${startCoords} target: ${targetCoords} duration: ${duration}` + ); + + if (startCoords.length !== targetCoords.length) { + throw new Error( + "Expected equal number of start coordinates and target coordinates" + ); + } + + if ( + !startCoords.every(item => item.length == 2) || + !targetCoords.every(item => item.length == 2) + ) { + throw new Error( + "Expected start coordinates target coordinates to be Array of multiple [x,y] coordinates." + ); + } + + if (duration === 0) { + // transition to destination in one step + callback(targetCoords); + return; + } + + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + // interval between transitions in ms, based on common vsync + const fps60 = 17; + + const distances = targetCoords.map((targetCoord, i) => { + const startCoord = startCoords[i]; + return [targetCoord[0] - startCoord[0], targetCoord[1] - startCoord[1]]; + }); + const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT; + const startTime = Date.now(); + const transitions = (async () => { + // wait |fps60| ms before performing first incremental transition + await new Promise(resolveTimer => + timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) + ); + + let durationRatio = Math.floor(Date.now() - startTime) / duration; + const epsilon = fps60 / duration / 10; + while (1 - durationRatio > epsilon) { + const intermediateTargets = startCoords.map((startCoord, i) => { + let distance = distances[i]; + return [ + Math.floor(durationRatio * distance[0] + startCoord[0]), + Math.floor(durationRatio * distance[1] + startCoord[1]), + ]; + }); + callback(intermediateTargets); + // wait |fps60| ms before performing next transition + await new Promise(resolveTimer => + timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) + ); + + durationRatio = Math.floor(Date.now() - startTime) / duration; + } + })(); + + await transitions; + + // perform last transitionafter all incremental moves are resolved and + // durationRatio is close enough to 1 + callback(targetCoords); +} + +const actionTypes = new Map(); +for (const cls of [ + KeyDownAction, + KeyUpAction, + PauseAction, + PointerDownAction, + PointerUpAction, + PointerMoveAction, + WheelScrollAction, +]) { + if (!actionTypes.has(cls.type)) { + actionTypes.set(cls.type, new Map()); + } + actionTypes.get(cls.type).set(cls.subtype, cls); +} + +/** + * Implementation of the behaviour of a specific type of pointer + */ +class Pointer { + /** Type of pointer */ + static type = null; + + constructor(id) { + this.id = id; + this.type = this.constructor.type; + } + + /** + * Implementation of depressing the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Action} action - The Action object invoking the pointer + * @param {WindowProxy} win - Current window global. + */ + pointerDown(state, inputSource, action, win) { + throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`); + } + + /** + * Implementation of releasing the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Action} action - The Action object invoking the pointer + * @param {WindowProxy} win - Current window global. + */ + pointerUp(state, inputSource, action, win) { + throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`); + } + + /** + * Implementation of moving the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} targetX - Target X coordinate of the pointer move + * @param {number} targetY - Target Y coordinate of the pointer move + * @param {WindowProxy} win - Current window global. + */ + pointerMove(state, inputSource, targetX, targetY, win) { + throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`); + } + + /** + * @param {number} pointerId - Numeric pointer id. + * @param {string} pointerType - Pointer type. + * @returns {Pointer} - The pointer class for {@link pointerType} + * + * @throws {InvalidArgumentError} - If {@link pointerType} is not a valid pointer type. + */ + static fromJSON(pointerId, pointerType) { + const cls = pointerTypes.get(pointerType); + + if (cls === undefined) { + throw new lazy.error.InvalidArgumentError( + 'Expected "pointerType" type to be one of ' + + lazy.pprint`${pointerTypes}, got ${pointerType}` + ); + } + + return new cls(pointerId); + } +} + +/** + * Implementation of mouse pointer behaviour + */ +class MousePointer extends Pointer { + static type = "mouse"; + + pointerDown(state, inputSource, action, win) { + const mouseEvent = new MouseEventData("mousedown", { + button: action.button, + }); + mouseEvent.update(state, inputSource); + + if (mouseEvent.ctrlKey) { + if (lazy.AppInfo.isMac) { + mouseEvent.button = 2; + state.clickTracker.reset(); + } + } else { + mouseEvent.clickCount = state.clickTracker.count + 1; + } + + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + mouseEvent, + win + ); + + if ( + lazy.event.MouseButton.isSecondary(mouseEvent.button) || + (mouseEvent.ctrlKey && lazy.AppInfo.isMac) + ) { + const contextMenuEvent = { ...mouseEvent, type: "contextmenu" }; + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + contextMenuEvent, + win + ); + } + } + + pointerUp(state, inputSource, action, win) { + const mouseEvent = new MouseEventData("mouseup", { + button: action.button, + }); + mouseEvent.update(state, inputSource); + + state.clickTracker.setClick(action.button); + mouseEvent.clickCount = state.clickTracker.count; + + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + mouseEvent, + win + ); + } + + pointerMove(state, inputSource, action, targetX, targetY, win) { + const mouseEvent = new MouseEventData("mousemove"); + mouseEvent.update(state, inputSource); + + lazy.event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win); + + state.clickTracker.reset(); + } +} + +/* + * The implementation here is empty because touch actions have to go via the + * TouchActionGroup. So if we end up calling these methods that's a bug in + * the code. + */ +class TouchPointer extends Pointer { + static type = "touch"; +} + +/* + * Placeholder for future pen type pointer support. + */ +class PenPointer extends Pointer { + static type = "pen"; +} + +const pointerTypes = new Map(); +for (const cls of [MousePointer, TouchPointer, PenPointer]) { + pointerTypes.set(cls.type, cls); +} + +/** + * Represents a series of ticks, specifying which actions to perform at + * each tick. + */ +action.Chain = class extends Array { + toString() { + return `[chain ${super.toString()}]`; + } + + /** + * Dispatch the action chain to the relevant window. + * + * @param {State} state - Actions state. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action + * chain is complete. + */ + dispatch(state, win) { + let i = 1; + + const chainEvents = (async () => { + for (const tickActions of this) { + lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`); + await tickActions.dispatch(state, win); + } + })(); + + // Reset the current click tracker counter. We shouldn't be able to simulate + // a double click with multiple action chains. + state.clickTracker.reset(); + + return chainEvents; + } + + /** + * @param {State} state - Actions state. + * @param {Array.<object>} actions - Array of objects that each + * represent an action sequence. + * @returns {action.Chain} - Object that allows dispatching a chain + * of actions. + * @throws {InvalidArgumentError} - If actions doesn't correspond to + * a valid action chain. + */ + static fromJSON(state, actions) { + lazy.assert.array( + actions, + lazy.pprint`Expected "actions" to be an array, got ${actions}` + ); + + const actionsByTick = new this(); + for (const actionSequence of actions) { + lazy.assert.object( + actionSequence, + 'Expected "actions" item to be an object, ' + + lazy.pprint`got ${actionSequence}` + ); + + const inputSourceActions = Sequence.fromJSON(state, actionSequence); + + for (let i = 0; i < inputSourceActions.length; i++) { + // new tick + if (actionsByTick.length < i + 1) { + actionsByTick.push(new TickActions()); + } + actionsByTick[i].push(inputSourceActions[i]); + } + } + + return actionsByTick; + } +}; + +/** + * Represents the action for each input device to perform in a single tick. + */ +class TickActions extends Array { + /** + * Tick duration in milliseconds. + * + * @returns {number} - Longest action duration in |tickActions| if any, or 0. + */ + getDuration() { + let max = 0; + + for (const action of this) { + if (action.affectsWallClockTime && action.duration) { + max = Math.max(action.duration, max); + } + } + + return max; + } + + /** + * Dispatch sequence of actions for this tick. + * + * This creates a Promise for one tick that resolves once the Promise + * for each tick-action is resolved, which takes at least |tickDuration| + * milliseconds. The resolved set of events for each tick is followed by + * firing of pending DOM events. + * + * Note that the tick-actions are dispatched in order, but they may have + * different durations and therefore may not end in the same order. + * + * @param {State} state - Actions state. + * @param {WindowProxy} win - Current window global. + * + * @returns {Promise} - Promise that resolves when tick is complete. + */ + dispatch(state, win) { + const tickDuration = this.getDuration(); + const tickActions = this.groupTickActions(state); + const pendingEvents = tickActions.map(([inputSource, action]) => + action.dispatch(state, inputSource, tickDuration, win) + ); + + return Promise.all(pendingEvents); + } + + /** + * Group together actions from input sources that have to be + * dispatched together. + * + * The actual transformation here is to group together touch pointer + * actions into {@link TouchActionGroup} instances. + * + * @param {State} state - Actions state. + * @returns {Array.<Array.<InputSource?,Action|TouchActionGroup>>} + * Array of pairs. For ungrouped actions each element is + * [InputSource, Action] For touch actions there are multiple + * pointers handled at once, so the first item of the array is + * null, meaning the group has to perform its own handling of the + * relevant state, and the second element is a TouuchActionGroup. + */ + groupTickActions(state) { + const touchActions = new Map(); + const actions = []; + + for (const action of this) { + const inputSource = state.getInputSource(action.id); + if (action.type == "pointer" && inputSource.pointer.type === "touch") { + lazy.logger.debug( + `Grouping action ${action.type} ${action.id} ${action.subtype}` + ); + let group = touchActions.get(action.subtype); + if (group === undefined) { + group = TouchActionGroup.forType(action.subtype); + touchActions.set(action.subtype, group); + actions.push([null, group]); + } + group.addPointer(inputSource, action); + } else { + actions.push([inputSource, action]); + } + } + + return actions; + } +} + +/** + * Represents one input source action sequence; this is essentially an + * |Array.<Action>|. + * + * This is a temporary object only used when constructing an {@link + * action.Chain}. + */ +class Sequence extends Array { + toString() { + return `[sequence ${super.toString()}]`; + } + + /** + * @param {State} state - Actions state. + * @param {object} actionSequence + * Protocol representation of the actions for a specific input source. + * @returns {Array.<Array>} - Array of [InputSource?,Action|TouchActionGroup] + */ + static fromJSON(state, actionSequence) { + // used here to validate 'type' in addition to InputSource type below + const { id, type, actions } = actionSequence; + + // type and id get validated in InputSource.fromJSON + lazy.assert.array( + actions, + 'Expected "actionSequence.actions" to be an array, ' + + lazy.pprint`got ${actionSequence.actions}` + ); + + // This sets the input state in the global state map, if it's new + InputSource.fromJSON(state, actionSequence); + + const sequence = new this(); + for (const actionItem of actions) { + sequence.push(Action.fromJSON(type, id, actionItem)); + } + + return sequence; + } +} + +/** + * Representation of an input event + */ +class InputEventData { + constructor() { + this.altKey = false; + this.shiftKey = false; + this.ctrlKey = false; + this.metaKey = false; + } + + /** + * Update the input data based on global and input state + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + */ + update(state, inputSource) {} + + toString() { + return `${this.constructor.name} ${JSON.stringify(this)}`; + } +} + +/** + * Representation of a key input event + * + * @param {string} rawKey - Key value. + */ +class KeyEventData extends InputEventData { + constructor(rawKey) { + super(); + const { key, code, location, printable } = lazy.keyData.getData(rawKey); + + this.key = key; + this.code = code; + this.location = location; + this.printable = printable; + this.repeat = false; + // keyCode will be computed by event.sendKeyDown + } + + update(state, inputSource) { + this.altKey = inputSource.alt; + this.shiftKey = inputSource.shift; + this.ctrlKey = inputSource.ctrl; + this.metaKey = inputSource.meta; + } +} + +/** + * Representation of a pointer input event + * + * @param {string} type - Event type. + */ +class PointerEventData extends InputEventData { + constructor(type) { + super(); + + this.type = type; + this.buttons = 0; + } + + update(state, inputSource) { + // set modifier properties based on whether any corresponding keys are + // pressed on any key input source + for (const [, otherInputSource] of state.inputSourcesByType("key")) { + this.altKey = otherInputSource.alt || this.altKey; + this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; + this.metaKey = otherInputSource.meta || this.metaKey; + this.shiftKey = otherInputSource.shift || this.shiftKey; + } + const allButtons = Array.from(inputSource.pressed); + this.buttons = allButtons.reduce( + (a, i) => a + PointerEventData.getButtonFlag(i), + 0 + ); + } + + /** + * Return a flag for buttons which indicates a button is pressed. + * + * @param {integer} button - Mouse button number. + */ + static getButtonFlag(button) { + switch (button) { + case 1: + return 4; + case 2: + return 2; + default: + return Math.pow(2, button); + } + } +} + +/** + * Representation of a mouse input event + * + * @param {string} type - Event type. + * @param {object=} options + * @param {number} options.button - Mouse button number. + */ +class MouseEventData extends PointerEventData { + constructor(type, options = {}) { + super(type); + + const { button = 0 } = options; + + this.button = button; + this.buttons = 0; + + // Some WPTs try to synthesize DnD only with mouse events. However, + // Gecko waits DnD events directly and non-WPT-tests use Gecko specific + // test API to synthesize DnD. Therefore, we want new path only for + // synthesized events coming from the webdriver. + this.allowToHandleDragDrop = true; + } + + update(state, inputSource) { + super.update(state, inputSource); + + this.id = inputSource.pointer.id; + } +} + +/** + * Representation of a wheel scroll event + * + * @param {object} options + * @param {number} options.deltaX - Scroll delta X. + * @param {number} options.deltaY - Scroll delta Y. + * @param {number} options.deltaY - Scroll delta Z (current always 0). + * @param {number=} options.deltaMode - Scroll delta mode (current always 0). + */ +class WheelEventData extends InputEventData { + constructor(options) { + super(); + + const { deltaX, deltaY, deltaZ, deltaMode = 0 } = options; + + this.deltaX = deltaX; + this.deltaY = deltaY; + this.deltaZ = deltaZ; + this.deltaMode = deltaMode; + } +} + +/** + * Representation of a multitouch event + * + * @param {string} type - Event type. + */ +class MultiTouchEventData extends PointerEventData { + #setGlobalState; + + constructor(type) { + super(type); + + this.id = []; + this.x = []; + this.y = []; + this.rx = []; + this.ry = []; + this.angle = []; + this.force = []; + this.tiltx = []; + this.tilty = []; + this.twist = []; + this.#setGlobalState = false; + } + + /** + * Add the data from one pointer to the event. + * + * @param {InputSource} inputSource - State of the pointer. + * @param {PointerAction} action - Action for the pointer. + */ + addPointerEventData(inputSource, action) { + this.x.push(inputSource.x); + this.y.push(inputSource.y); + this.id.push(inputSource.pointer.id); + this.rx.push(action.width || 1); + this.ry.push(action.height || 1); + this.angle.push(0); + this.force.push(action.pressure || (this.type === "touchend" ? 0 : 1)); + this.tiltx.push(action.tiltX || 0); + this.tilty.push(action.tiltY || 0); + this.twist.push(action.twist || 0); + } + + update(state, inputSource) { + // We call update once per input source, but only want to update global state once. + // Instead of introducing a new lifecycle method, or changing the API to allow multiple + // input sources in a single call, use a small bit of state to avoid repeatedly setting + // global state. + if (!this.#setGlobalState) { + // set modifier properties based on whether any corresponding keys are + // pressed on any key input source + for (const [, otherInputSource] of state.inputSourcesByType("key")) { + this.altKey = otherInputSource.alt || this.altKey; + this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; + this.metaKey = otherInputSource.meta || this.metaKey; + this.shiftKey = otherInputSource.shift || this.shiftKey; + } + this.#setGlobalState = true; + } + + // Note that we currently emit Touch events that don't have this property + // but pointer events should have a `buttons` property, so we'll compute it + // anyway. + const allButtons = Array.from(inputSource.pressed); + this.buttons = + this.buttons | + allButtons.reduce((a, i) => a + PointerEventData.getButtonFlag(i), 0); + } +} + +// helpers + +/** + * Assert that target is in the viewport of win. + * + * @param {Array.<number>} target - [x, y] coordinates of target + * relative to viewport. + * @param {WindowProxy} win - target window. + * @throws {MoveTargetOutOfBoundsError} - If target is outside the + * viewport. + */ +function assertInViewPort(target, win) { + const [x, y] = target; + + lazy.assert.number( + x, + lazy.pprint`Expected "x" to be finite number, got ${x}` + ); + lazy.assert.number( + y, + lazy.pprint`Expected "y" to be finite number, got ${y}` + ); + + // Viewport includes scrollbars if rendered. + if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) { + throw new lazy.error.MoveTargetOutOfBoundsError( + `Move target (${x}, ${y}) is out of bounds of viewport dimensions ` + + `(${win.innerWidth}, ${win.innerHeight})` + ); + } +} diff --git a/remote/shared/webdriver/Assert.sys.mjs b/remote/shared/webdriver/Assert.sys.mjs new file mode 100644 index 0000000000..6c254173aa --- /dev/null +++ b/remote/shared/webdriver/Assert.sys.mjs @@ -0,0 +1,489 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +/** + * Shorthands for common assertions made in WebDriver. + * + * @namespace + */ +export const assert = {}; + +/** + * Asserts that WebDriver has an active session. + * + * @param {WebDriverSession} session + * WebDriver session instance. + * @param {string=} msg + * Custom error message. + * + * @throws {InvalidSessionIDError} + * If session does not exist, or has an invalid id. + */ +assert.session = function (session, msg = "") { + msg = msg || "WebDriver session does not exist, or is not active"; + assert.that( + session => session && typeof session.id == "string", + msg, + lazy.error.InvalidSessionIDError + )(session); +}; + +/** + * Asserts that the current browser is Firefox Desktop. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current browser is not Firefox. + */ +assert.firefox = function (msg = "") { + msg = msg || "Only supported in Firefox"; + assert.that( + isFirefox => isFirefox, + msg, + lazy.error.UnsupportedOperationError + )(lazy.AppInfo.isFirefox); +}; + +/** + * Asserts that the current application is Firefox Desktop or Thunderbird. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current application is not running on desktop. + */ +assert.desktop = function (msg = "") { + msg = msg || "Only supported in desktop applications"; + assert.that( + isDesktop => isDesktop, + msg, + lazy.error.UnsupportedOperationError + )(!lazy.AppInfo.isAndroid); +}; + +/** + * Asserts that the current application runs on Android. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current application is not running on Android. + */ +assert.mobile = function (msg = "") { + msg = msg || "Only supported on Android"; + assert.that( + isAndroid => isAndroid, + msg, + lazy.error.UnsupportedOperationError + )(lazy.AppInfo.isAndroid); +}; + +/** + * Asserts that the current <var>context</var> is content. + * + * @param {string} context + * Context to test. + * @param {string=} msg + * Custom error message. + * + * @returns {string} + * <var>context</var> is returned unaltered. + * + * @throws {UnsupportedOperationError} + * If <var>context</var> is not content. + */ +assert.content = function (context, msg = "") { + msg = msg || "Only supported in content context"; + assert.that( + c => c.toString() == "content", + msg, + lazy.error.UnsupportedOperationError + )(context); +}; + +/** + * Asserts that the {@link CanonicalBrowsingContext} is open. + * + * @param {CanonicalBrowsingContext} browsingContext + * Canonical browsing context to check. + * @param {string=} msg + * Custom error message. + * + * @returns {CanonicalBrowsingContext} + * <var>browsingContext</var> is returned unaltered. + * + * @throws {NoSuchWindowError} + * If <var>browsingContext</var> is no longer open. + */ +assert.open = function (browsingContext, msg = "") { + msg = msg || "Browsing context has been discarded"; + return assert.that( + browsingContext => { + if (!browsingContext?.currentWindowGlobal) { + return false; + } + + if (browsingContext.isContent && !browsingContext.top.embedderElement) { + return false; + } + + return true; + }, + msg, + lazy.error.NoSuchWindowError + )(browsingContext); +}; + +/** + * Asserts that there is no current user prompt. + * + * @param {modal.Dialog} dialog + * Reference to current dialogue. + * @param {string=} msg + * Custom error message. + * + * @throws {UnexpectedAlertOpenError} + * If there is a user prompt. + */ +assert.noUserPrompt = function (dialog, msg = "") { + assert.that( + d => d === null || typeof d == "undefined", + msg, + lazy.error.UnexpectedAlertOpenError + )(dialog); +}; + +/** + * Asserts that <var>obj</var> is defined. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {?} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not defined. + */ +assert.defined = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be defined`; + return assert.that(o => typeof o != "undefined", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a finite number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number. + */ +assert.number = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be finite number`; + return assert.that(Number.isFinite, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a positive number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a positive integer. + */ +assert.positiveNumber = function (obj, msg = "") { + assert.number(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= 0`; + return assert.that(n => n >= 0, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a number in the inclusive range <var>lower</var> to <var>upper</var>. + * + * @param {?} obj + * Value to test. + * @param {Array<number>} range + * Array range [lower, upper] + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number in the specified range. + */ +assert.numberInRange = function (obj, range, msg = "") { + const [lower, upper] = range; + assert.number(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= ${lower} and <= ${upper}`; + return assert.that(n => n >= lower && n <= upper, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is callable. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {Function} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not callable. + */ +assert.callable = function (obj, msg = "") { + msg = msg || lazy.pprint`${obj} is not callable`; + return assert.that(o => typeof o == "function", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an unsigned short number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an unsigned short. + */ +assert.unsignedShort = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be >= 0 and < 65536`; + return assert.that(n => n >= 0 && n < 65536, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an integer. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an integer. + */ +assert.integer = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an integer`; + return assert.that(Number.isSafeInteger, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a positive integer. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a positive integer. + */ +assert.positiveInteger = function (obj, msg = "") { + assert.integer(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= 0`; + return assert.that(n => n >= 0, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an integer in the inclusive range <var>lower</var> to <var>upper</var>. + * + * @param {?} obj + * Value to test. + * @param {Array<number>} range + * Array range [lower, upper] + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number in the specified range. + */ +assert.integerInRange = function (obj, range, msg = "") { + const [lower, upper] = range; + assert.integer(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= ${lower} and <= ${upper}`; + return assert.that(n => n >= lower && n <= upper, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a boolean. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {boolean} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a boolean. + */ +assert.boolean = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be boolean`; + return assert.that(b => typeof b == "boolean", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a string. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {string} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a string. + */ +assert.string = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be a string`; + return assert.that(s => typeof s == "string", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an object. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {object} + * obj| is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an object. + */ +assert.object = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an object`; + return assert.that(o => { + // unable to use instanceof because LHS and RHS may come from + // different globals + let s = Object.prototype.toString.call(o); + return s == "[object Object]" || s == "[object nsJSIID]"; + }, msg)(obj); +}; + +/** + * Asserts that <var>prop</var> is in <var>obj</var>. + * + * @param {?} prop + * An array element or own property to test if is in <var>obj</var>. + * @param {?} obj + * An array or an Object that is being tested. + * @param {string=} msg + * Custom error message. + * + * @returns {?} + * The array element, or the value of <var>obj</var>'s own property + * <var>prop</var>. + * + * @throws {InvalidArgumentError} + * If the <var>obj</var> was an array and did not contain <var>prop</var>. + * Otherwise if <var>prop</var> is not in <var>obj</var>, or <var>obj</var> + * is not an object. + */ +assert.in = function (prop, obj, msg = "") { + if (Array.isArray(obj)) { + assert.that(p => obj.includes(p), msg)(prop); + return prop; + } + assert.object(obj, msg); + msg = msg || lazy.pprint`Expected ${prop} in ${obj}`; + assert.that(p => obj.hasOwnProperty(p), msg)(prop); + return obj[prop]; +}; + +/** + * Asserts that <var>obj</var> is an Array. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {object} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an Array. + */ +assert.array = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an Array`; + return assert.that(Array.isArray, msg)(obj); +}; + +/** + * Returns a function that is used to assert the |predicate|. + * + * @param {function(?): boolean} predicate + * Evaluated on calling the return value of this function. If its + * return value of the inner function is false, <var>error</var> + * is thrown with <var>message</var>. + * @param {string=} message + * Custom error message. + * @param {Error=} err + * Custom error type by its class. + * + * @returns {function(?): ?} + * Function that takes and returns the passed in value unaltered, + * and which may throw <var>error</var> with <var>message</var> + * if <var>predicate</var> evaluates to false. + */ +assert.that = function ( + predicate, + message = "", + err = lazy.error.InvalidArgumentError +) { + return obj => { + if (!predicate(obj)) { + throw new err(message); + } + return obj; + }; +}; diff --git a/remote/shared/webdriver/Capabilities.sys.mjs b/remote/shared/webdriver/Capabilities.sys.mjs new file mode 100644 index 0000000000..e3761315f2 --- /dev/null +++ b/remote/shared/webdriver/Capabilities.sys.mjs @@ -0,0 +1,1061 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", +}); + +ChromeUtils.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() { + // disabled + this.implicit = 0; + // five minutes + this.pageLoad = 300000; + // 30 seconds + this.script = 30000; + } + + toString() { + return "[object Timeouts]"; + } + + /** Marshals timeout durations to a JSON Object. */ + toJSON() { + return { + implicit: this.implicit, + pageLoad: this.pageLoad, + script: this.script, + }; + } + + static fromJSON(json) { + lazy.assert.object( + json, + lazy.pprint`Expected "timeouts" to be an object, got ${json}` + ); + let t = new Timeouts(); + + for (let [type, ms] of Object.entries(json)) { + switch (type) { + case "implicit": + t.implicit = lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + break; + + case "script": + if (ms !== null) { + lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + } + t.script = ms; + break; + + case "pageLoad": + t.pageLoad = lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + break; + + default: + throw new lazy.error.InvalidArgumentError( + "Unrecognised timeout: " + type + ); + } + } + + return t; + } +} + +/** + * Enum of page loading strategies. + * + * @enum + */ +export const PageLoadStrategy = { + /** No page load strategy. Navigation will return immediately. */ + None: "none", + /** + * Eager, causing navigation to complete when the document reaches + * the <code>interactive</code> ready state. + */ + Eager: "eager", + /** + * Normal, causing navigation to return when the document reaches the + * <code>complete</code> ready state. + */ + Normal: "normal", +}; + +/** Proxy configuration object representation. */ +export class Proxy { + /** @class */ + constructor() { + this.proxyType = null; + this.httpProxy = null; + this.httpProxyPort = null; + this.noProxy = null; + this.sslProxy = null; + this.sslProxyPort = null; + this.socksProxy = null; + this.socksProxyPort = null; + this.socksVersion = null; + this.proxyAutoconfigUrl = null; + } + + /** + * Sets Firefox proxy settings. + * + * @returns {boolean} + * True if proxy settings were updated as a result of calling this + * function, or false indicating that this function acted as + * a no-op. + */ + init() { + switch (this.proxyType) { + case "autodetect": + Services.prefs.setIntPref("network.proxy.type", 4); + return true; + + case "direct": + Services.prefs.setIntPref("network.proxy.type", 0); + return true; + + case "manual": + Services.prefs.setIntPref("network.proxy.type", 1); + + if (this.httpProxy) { + Services.prefs.setStringPref("network.proxy.http", this.httpProxy); + if (Number.isInteger(this.httpProxyPort)) { + Services.prefs.setIntPref( + "network.proxy.http_port", + this.httpProxyPort + ); + } + } + + if (this.sslProxy) { + Services.prefs.setStringPref("network.proxy.ssl", this.sslProxy); + if (Number.isInteger(this.sslProxyPort)) { + Services.prefs.setIntPref( + "network.proxy.ssl_port", + this.sslProxyPort + ); + } + } + + if (this.socksProxy) { + Services.prefs.setStringPref("network.proxy.socks", this.socksProxy); + if (Number.isInteger(this.socksProxyPort)) { + Services.prefs.setIntPref( + "network.proxy.socks_port", + this.socksProxyPort + ); + } + if (this.socksVersion) { + Services.prefs.setIntPref( + "network.proxy.socks_version", + this.socksVersion + ); + } + } + + if (this.noProxy) { + Services.prefs.setStringPref( + "network.proxy.no_proxies_on", + this.noProxy.join(", ") + ); + } + return true; + + case "pac": + Services.prefs.setIntPref("network.proxy.type", 2); + Services.prefs.setStringPref( + "network.proxy.autoconfig_url", + this.proxyAutoconfigUrl + ); + return true; + + case "system": + Services.prefs.setIntPref("network.proxy.type", 5); + return true; + + default: + return false; + } + } + + /** + * @param {Object<string, ?>} json + * JSON Object to unmarshal. + * + * @throws {InvalidArgumentError} + * When proxy configuration is invalid. + */ + static fromJSON(json) { + function stripBracketsFromIpv6Hostname(hostname) { + return hostname.includes(":") + ? hostname.replace(/[\[\]]/g, "") + : hostname; + } + + // Parse hostname and optional port from host + function fromHost(scheme, host) { + lazy.assert.string( + host, + lazy.pprint`Expected proxy "host" to be a string, got ${host}` + ); + + if (host.includes("://")) { + throw new lazy.error.InvalidArgumentError(`${host} contains a scheme`); + } + + let url; + try { + // To parse the host a scheme has to be added temporarily. + // If the returned value for the port is an empty string it + // could mean no port or the default port for this scheme was + // specified. In such a case parse again with a different + // scheme to ensure we filter out the default port. + url = new URL("http://" + host); + if (url.port == "") { + url = new URL("https://" + host); + } + } catch (e) { + throw new lazy.error.InvalidArgumentError(e.message); + } + + let hostname = stripBracketsFromIpv6Hostname(url.hostname); + + // If the port hasn't been set, use the default port of + // the selected scheme (except for socks which doesn't have one). + let port = parseInt(url.port); + if (!Number.isInteger(port)) { + if (scheme === "socks") { + port = null; + } else { + port = Services.io.getDefaultPort(scheme); + } + } + + if ( + url.username != "" || + url.password != "" || + url.pathname != "/" || + url.search != "" || + url.hash != "" + ) { + throw new lazy.error.InvalidArgumentError( + `${host} was not of the form host[:port]` + ); + } + + return [hostname, port]; + } + + let p = new Proxy(); + if (typeof json == "undefined" || json === null) { + return p; + } + + lazy.assert.object( + json, + lazy.pprint`Expected "proxy" to be an object, got ${json}` + ); + + lazy.assert.in( + "proxyType", + json, + lazy.pprint`Expected "proxyType" in "proxy" object, got ${json}` + ); + p.proxyType = lazy.assert.string( + json.proxyType, + lazy.pprint`Expected "proxyType" to be a string, got ${json.proxyType}` + ); + + switch (p.proxyType) { + case "autodetect": + case "direct": + case "system": + break; + + case "pac": + p.proxyAutoconfigUrl = lazy.assert.string( + json.proxyAutoconfigUrl, + `Expected "proxyAutoconfigUrl" to be a string, ` + + lazy.pprint`got ${json.proxyAutoconfigUrl}` + ); + break; + + case "manual": + if (typeof json.ftpProxy != "undefined") { + throw new lazy.error.InvalidArgumentError( + "Since Firefox 90 'ftpProxy' is no longer supported" + ); + } + if (typeof json.httpProxy != "undefined") { + [p.httpProxy, p.httpProxyPort] = fromHost("http", json.httpProxy); + } + if (typeof json.sslProxy != "undefined") { + [p.sslProxy, p.sslProxyPort] = fromHost("https", json.sslProxy); + } + if (typeof json.socksProxy != "undefined") { + [p.socksProxy, p.socksProxyPort] = fromHost("socks", json.socksProxy); + p.socksVersion = lazy.assert.positiveInteger( + json.socksVersion, + lazy.pprint`Expected "socksVersion" to be a positive integer, got ${json.socksVersion}` + ); + } + if (typeof json.noProxy != "undefined") { + let entries = lazy.assert.array( + json.noProxy, + lazy.pprint`Expected "noProxy" to be an array, got ${json.noProxy}` + ); + p.noProxy = entries.map(entry => { + lazy.assert.string( + entry, + lazy.pprint`Expected "noProxy" entry to be a string, got ${entry}` + ); + return stripBracketsFromIpv6Hostname(entry); + }); + } + break; + + default: + throw new lazy.error.InvalidArgumentError( + `Invalid type of proxy: ${p.proxyType}` + ); + } + + return p; + } + + /** + * @returns {Object<string, (number | string)>} + * JSON serialisation of proxy object. + */ + toJSON() { + function addBracketsToIpv6Hostname(hostname) { + return hostname.includes(":") ? `[${hostname}]` : hostname; + } + + function toHost(hostname, port) { + if (!hostname) { + return null; + } + + // Add brackets around IPv6 addresses + hostname = addBracketsToIpv6Hostname(hostname); + + if (port != null) { + return `${hostname}:${port}`; + } + + return hostname; + } + + let excludes = this.noProxy; + if (excludes) { + excludes = excludes.map(addBracketsToIpv6Hostname); + } + + return marshal({ + proxyType: this.proxyType, + httpProxy: toHost(this.httpProxy, this.httpProxyPort), + noProxy: excludes, + sslProxy: toHost(this.sslProxy, this.sslProxyPort), + socksProxy: toHost(this.socksProxy, this.socksProxyPort), + socksVersion: this.socksVersion, + proxyAutoconfigUrl: this.proxyAutoconfigUrl, + }); + } + + toString() { + return "[object Proxy]"; + } +} + +/** + * Enum of unhandled prompt behavior. + * + * @enum + */ +export const UnhandledPromptBehavior = { + /** All simple dialogs encountered should be accepted. */ + Accept: "accept", + /** + * All simple dialogs encountered should be accepted, and an error + * returned that the dialog was handled. + */ + AcceptAndNotify: "accept and notify", + /** All simple dialogs encountered should be dismissed. */ + Dismiss: "dismiss", + /** + * All simple dialogs encountered should be dismissed, and an error + * returned that the dialog was handled. + */ + DismissAndNotify: "dismiss and notify", + /** All simple dialogs encountered should be left to the user to handle. */ + Ignore: "ignore", +}; + +/** WebDriver session capabilities representation. */ +export class Capabilities extends Map { + /** @class */ + constructor() { + super([ + // webdriver + ["browserName", getWebDriverBrowserName()], + ["browserVersion", lazy.AppInfo.version], + ["platformName", getWebDriverPlatformName()], + ["acceptInsecureCerts", false], + ["pageLoadStrategy", PageLoadStrategy.Normal], + ["proxy", new Proxy()], + ["setWindowRect", !lazy.AppInfo.isAndroid], + ["timeouts", new Timeouts()], + ["strictFileInteractability", false], + ["unhandledPromptBehavior", UnhandledPromptBehavior.DismissAndNotify], + ["webSocketUrl", null], + + // proprietary + ["moz:accessibilityChecks", false], + ["moz:buildID", lazy.AppInfo.appBuildID], + [ + "moz:debuggerAddress", + // With bug 1715481 fixed always use the Remote Agent instance + lazy.RemoteAgent.running && lazy.RemoteAgent.cdp + ? lazy.remoteAgent.debuggerAddress + : null, + ], + [ + "moz:headless", + Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless, + ], + ["moz:platformVersion", Services.sysinfo.getProperty("version")], + ["moz:processID", lazy.AppInfo.processID], + ["moz:profile", maybeProfile()], + [ + "moz:shutdownTimeout", + Services.prefs.getIntPref("toolkit.asyncshutdown.crash_timeout"), + ], + ["moz:webdriverClick", true], + ["moz:windowless", false], + ]); + } + + /** + * @param {string} key + * Capability key. + * @param {(string|number|boolean)} value + * JSON-safe capability value. + */ + set(key, value) { + if (key === "timeouts" && !(value instanceof Timeouts)) { + throw new TypeError(); + } else if (key === "proxy" && !(value instanceof Proxy)) { + throw new TypeError(); + } + + return super.set(key, value); + } + + toString() { + return "[object Capabilities]"; + } + + /** + * JSON serialisation of capabilities object. + * + * @returns {Object<string, ?>} + */ + toJSON() { + let marshalled = marshal(this); + + // Always return the proxy capability even if it's empty + if (!("proxy" in marshalled)) { + marshalled.proxy = {}; + } + + marshalled.timeouts = super.get("timeouts"); + + return marshalled; + } + + /** + * Unmarshal a JSON object representation of WebDriver capabilities. + * + * @param {Object<string, *>=} json + * WebDriver capabilities. + * + * @returns {Capabilities} + * Internal representation of WebDriver capabilities. + */ + static fromJSON(json) { + if (typeof json == "undefined" || json === null) { + json = {}; + } + lazy.assert.object( + json, + lazy.pprint`Expected "capabilities" to be an object, got ${json}"` + ); + + 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": + 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 "webauthn:virtualAuthenticators": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "webauthn:extension:uvm": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "webauthn:extension:prf": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "webauthn:extension:largeBlob": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "webauthn:extension:credBlob": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, 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": + if (v !== undefined) { + throw new lazy.error.InvalidArgumentError( + `Since Firefox 116 the capability ${k} is no longer supported` + ); + } + 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; + } + capabilities.set(k, v); + } + + 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 a boolean, got ${value}` + ); + + if (!value) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected ${name} to be true, got ${value}` + ); + } + return value; + + case "webauthn:virtualAuthenticators": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + return value; + + case "webauthn:extension:uvm": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + return value; + + case "webauthn:extension:largeBlob": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, 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: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` + ); + } + } +} + +function getWebDriverBrowserName() { + // Similar to chromedriver which reports "chrome" as browser name for all + // WebView apps, we will report "firefox" for all GeckoView apps. + if (lazy.AppInfo.isAndroid) { + return "firefox"; + } + + return lazy.AppInfo.name?.toLowerCase(); +} + +function getWebDriverPlatformName() { + let name = Services.sysinfo.getProperty("name"); + + if (lazy.AppInfo.isAndroid) { + return "android"; + } + + switch (name) { + case "Windows_NT": + return "windows"; + + case "Darwin": + return "mac"; + + default: + return name.toLowerCase(); + } +} + +// Specialisation of |JSON.stringify| that produces JSON-safe object +// literals, dropping empty objects and entries which values are undefined +// or null. Objects are allowed to produce their own JSON representations +// by implementing a |toJSON| function. +function marshal(obj) { + let rv = Object.create(null); + + function* iter(mapOrObject) { + if (mapOrObject instanceof Map) { + for (const [k, v] of mapOrObject) { + yield [k, v]; + } + } else { + for (const k of Object.keys(mapOrObject)) { + yield [k, mapOrObject[k]]; + } + } + } + + for (let [k, v] of iter(obj)) { + // Skip empty values when serialising to JSON. + if (typeof v == "undefined" || v === null) { + continue; + } + + // Recursively marshal objects that are able to produce their own + // JSON representation. + if (typeof v.toJSON == "function") { + v = marshal(v.toJSON()); + + // Or do the same for object literals. + } else if (isObject(v)) { + v = marshal(v); + } + + // And finally drop (possibly marshaled) objects which have no + // entries. + if (!isObjectEmpty(v)) { + rv[k] = v; + } + } + + return rv; +} + +function isObject(obj) { + return Object.prototype.toString.call(obj) == "[object Object]"; +} + +function isObjectEmpty(obj) { + return isObject(obj) && Object.keys(obj).length === 0; +} + +// Services.dirsvc is not accessible from JSWindowActor child, +// but we should not panic about that. +function maybeProfile() { + try { + return Services.dirsvc.get("ProfD", Ci.nsIFile).path; + } catch (e) { + 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} Merged 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) { + // 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 <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 {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; +} diff --git a/remote/shared/webdriver/Errors.sys.mjs b/remote/shared/webdriver/Errors.sys.mjs new file mode 100644 index 0000000000..53b9d4426b --- /dev/null +++ b/remote/shared/webdriver/Errors.sys.mjs @@ -0,0 +1,881 @@ +/* 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/. */ + +import { RemoteError } from "chrome://remote/content/shared/RemoteError.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +const ERRORS = new Set([ + "DetachedShadowRootError", + "ElementClickInterceptedError", + "ElementNotAccessibleError", + "ElementNotInteractableError", + "InsecureCertificateError", + "InvalidArgumentError", + "InvalidCookieDomainError", + "InvalidElementStateError", + "InvalidSelectorError", + "InvalidSessionIDError", + "JavaScriptError", + "MoveTargetOutOfBoundsError", + "NoSuchAlertError", + "NoSuchElementError", + "NoSuchFrameError", + "NoSuchHandleError", + "NoSuchHistoryEntryError", + "NoSuchInterceptError", + "NoSuchNodeError", + "NoSuchRequestError", + "NoSuchScriptError", + "NoSuchShadowRootError", + "NoSuchUserContextError", + "NoSuchWindowError", + "ScriptTimeoutError", + "SessionNotCreatedError", + "StaleElementReferenceError", + "TimeoutError", + "UnableToCaptureScreen", + "UnableToSetCookieError", + "UnexpectedAlertOpenError", + "UnknownCommandError", + "UnknownError", + "UnsupportedOperationError", + "WebDriverError", +]); + +const BUILTIN_ERRORS = new Set([ + "Error", + "EvalError", + "InternalError", + "RangeError", + "ReferenceError", + "SyntaxError", + "TypeError", + "URIError", +]); + +/** @namespace */ +export const error = { + /** + * Check if ``val`` is an instance of the ``Error`` prototype. + * + * Because error objects may originate from different globals, comparing + * the prototype of the left hand side with the prototype property from + * the right hand side, which is what ``instanceof`` does, will not work. + * If the LHS and RHS come from different globals, this check will always + * fail because the two objects will not have the same identity. + * + * Therefore it is not safe to use ``instanceof`` in any multi-global + * situation, e.g. in content across multiple ``Window`` objects or anywhere + * in chrome scope. + * + * This function also contains a special check if ``val`` is an XPCOM + * ``nsIException`` because they are special snowflakes and may indeed + * cause Firefox to crash if used with ``instanceof``. + * + * @param {*} val + * Any value that should be undergo the test for errorness. + * @returns {boolean} + * True if error, false otherwise. + */ + isError(val) { + if (val === null || typeof val != "object") { + return false; + } else if (val instanceof Ci.nsIException) { + return true; + } + + // DOMRectList errors on string comparison + try { + let proto = Object.getPrototypeOf(val); + return BUILTIN_ERRORS.has(proto.toString()); + } catch (e) { + return false; + } + }, + + /** + * Checks if ``obj`` is an object in the :js:class:`WebDriverError` + * prototypal chain. + * + * @param {*} obj + * Arbitrary object to test. + * + * @returns {boolean} + * True if ``obj`` is of the WebDriverError prototype chain, + * false otherwise. + */ + isWebDriverError(obj) { + // Don't use "instanceof" to compare error objects because of possible + // problems when the other instance was created in a different global and + // as such won't have the same prototype object. + return error.isError(obj) && "name" in obj && ERRORS.has(obj.name); + }, + + /** + * Ensures error instance is a :js:class:`WebDriverError`. + * + * If the given error is already in the WebDriverError prototype + * chain, ``err`` is returned unmodified. If it is not, it is wrapped + * in :js:class:`UnknownError`. + * + * @param {Error} err + * Error to conditionally turn into a WebDriverError. + * + * @returns {WebDriverError} + * If ``err`` is a WebDriverError, it is returned unmodified. + * Otherwise an UnknownError type is returned. + */ + wrap(err) { + if (error.isWebDriverError(err)) { + return err; + } + return new UnknownError(err); + }, + + /** + * Unhandled error reporter. Dumps the error and its stacktrace to console, + * and reports error to the Browser Console. + */ + report(err) { + let msg = "Marionette threw an error: " + error.stringify(err); + dump(msg + "\n"); + console.error(msg); + }, + + /** + * Prettifies an instance of Error and its stacktrace to a string. + */ + stringify(err) { + try { + let s = err.toString(); + if ("stack" in err) { + s += "\n" + err.stack; + } + return s; + } catch (e) { + return "<unprintable error>"; + } + }, + + /** Create a stacktrace to the current line in the program. */ + stack() { + let trace = new Error().stack; + let sa = trace.split("\n"); + sa = sa.slice(1); + let rv = "stacktrace:\n" + sa.join("\n"); + return rv.trimEnd(); + }, +}; + +/** + * WebDriverError is the prototypal parent of all WebDriver errors. + * It should not be used directly, as it does not correspond to a real + * error in the specification. + */ +class WebDriverError extends RemoteError { + /** + * Base error for WebDriver protocols. + * + * @param {(string|Error)=} obj + * Optional string describing error situation or Error instance + * to propagate. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ + constructor(obj, data = {}) { + super(obj); + + this.name = this.constructor.name; + this.status = "webdriver error"; + this.data = data; + + // Error's ctor does not preserve x' stack + if (error.isError(obj)) { + this.stack = obj.stack; + } + + if (error.isWebDriverError(obj)) { + this.message = obj.message; + this.data = obj.data; + } + } + + /** + * @returns {Object<string, string>} + * JSON serialisation of error prototype. + */ + toJSON() { + const result = { + error: this.status, + message: this.message || "", + stacktrace: this.stack || "", + }; + + // Only add the field if additional data has been specified. + if (Object.keys(this.data).length) { + result.data = this.data; + } + + return result; + } + + /** + * Unmarshals a JSON error representation to the appropriate Marionette + * error type. + * + * @param {Object<string, string>} json + * Error object. + * + * @returns {Error} + * Error prototype. + */ + static fromJSON(json) { + if (typeof json.error == "undefined") { + let s = JSON.stringify(json); + throw new TypeError("Undeserialisable error type: " + s); + } + if (!STATUSES.has(json.error)) { + throw new TypeError("Not of WebDriverError descent: " + json.error); + } + + let cls = STATUSES.get(json.error); + let err = new cls(); + if ("message" in json) { + err.message = json.message; + } + if ("stacktrace" in json) { + err.stack = json.stacktrace; + } + if ("data" in json) { + err.data = json.data; + } + + return err; + } +} + +/** + * The Gecko a11y API indicates that the element is not accessible. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ElementNotAccessibleError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "element not accessible"; + } +} + +/** + * An element click could not be completed because the element receiving + * the events is obscuring the element that was requested clicked. + * + * @param {string=} message + * Optional string describing error situation. Will be replaced if both + * `data.obscuredEl` and `data.coords` are provided. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + * @param {Element=} obscuredEl + * Element obscuring the element receiving the click. Providing this + * is not required, but will produce a nicer error message. + * @param {Map.<string, number>=} coords + * Original click location. Providing this is not required, but + * will produce a nicer error message. + */ +class ElementClickInterceptedError extends WebDriverError { + constructor(message, data = {}, obscuredEl = undefined, coords = undefined) { + let obscuredElDetails = null; + let overlayingElDetails = null; + + if (obscuredEl && coords) { + const doc = obscuredEl.ownerDocument; + const overlayingEl = doc.elementFromPoint(coords.x, coords.y); + + obscuredElDetails = lazy.pprint`${obscuredEl}`; + overlayingElDetails = lazy.pprint`${overlayingEl}`; + + switch (obscuredEl.style.pointerEvents) { + case "none": + message = + `Element ${obscuredElDetails} is not clickable ` + + `at point (${coords.x},${coords.y}) ` + + `because it does not have pointer events enabled, ` + + `and element ${overlayingElDetails} ` + + `would receive the click instead`; + break; + + default: + message = + `Element ${obscuredElDetails} is not clickable ` + + `at point (${coords.x},${coords.y}) ` + + `because another element ${overlayingElDetails} ` + + `obscures it`; + break; + } + } + + if (coords) { + data.coords = coords; + } + if (obscuredElDetails) { + data.obscuredElement = obscuredElDetails; + } + if (overlayingElDetails) { + data.overlayingElement = overlayingElDetails; + } + + super(message, data); + this.status = "element click intercepted"; + } +} + +/** + * A command could not be completed because the element is not pointer- + * or keyboard interactable. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ElementNotInteractableError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "element not interactable"; + } +} + +/** + * Navigation caused the user agent to hit a certificate warning, which + * is usually the result of an expired or invalid TLS certificate. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InsecureCertificateError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "insecure certificate"; + } +} + +/** + * The arguments passed to a command are either invalid or malformed. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidArgumentError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid argument"; + } +} + +/** + * An illegal attempt was made to set a cookie under a different + * domain than the current page. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidCookieDomainError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid cookie domain"; + } +} + +/** + * A command could not be completed because the element is in an + * invalid state, e.g. attempting to clear an element that isn't both + * editable and resettable. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidElementStateError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid element state"; + } +} + +/** + * Argument was an invalid selector. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidSelectorError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid selector"; + } +} + +/** + * Occurs if the given session ID is not in the list of active sessions, + * meaning the session either does not exist or that it's not active. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidSessionIDError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid session id"; + } +} + +/** + * An error occurred whilst executing JavaScript supplied by the user. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class JavaScriptError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "javascript error"; + } +} + +/** + * The target for mouse interaction is not in the browser's viewport + * and cannot be brought into that viewport. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class MoveTargetOutOfBoundsError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "move target out of bounds"; + } +} + +/** + * An attempt was made to operate on a modal dialog when one was + * not open. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchAlertError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such alert"; + } +} + +/** + * An element could not be located on the page using the given + * search parameters. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchElementError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such element"; + } +} + +/** + * A command tried to remove an unknown preload script. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchScriptError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such script"; + } +} + +/** + * A shadow root was not attached to the element. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchShadowRootError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such shadow root"; + } +} + +/** + * A shadow root is no longer attached to the document. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class DetachedShadowRootError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "detached shadow root"; + } +} + +/** + * A command to switch to a frame could not be satisfied because + * the frame could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchFrameError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such frame"; + } +} + +/** + * The handle of a strong object reference could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchHandleError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such handle"; + } +} + +/** + * The entry of the history could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchHistoryEntryError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such history entry"; + } +} + +/** + * Tried to remove an unknown network intercept. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchInterceptError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such intercept"; + } +} + +/** + * A node as given by its unique shared id could not be found within the cache + * of known nodes. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchNodeError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such node"; + } +} + +/** + * Tried to continue an unknown request. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchRequestError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such request"; + } +} + +/** + * A command tried to reference an unknown user context (containers in Firefox). + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchUserContextError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such user context"; + } +} + +/** + * A command to switch to a window could not be satisfied because + * the window could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchWindowError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such window"; + } +} + +/** + * A script did not complete before its timeout expired. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ScriptTimeoutError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "script timeout"; + } +} + +/** + * A new session could not be created. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class SessionNotCreatedError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "session not created"; + } +} + +/** + * A command failed because the referenced element is no longer + * attached to the DOM. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class StaleElementReferenceError extends WebDriverError { + constructor(message, options = {}) { + super(message, options); + this.status = "stale element reference"; + } +} + +/** + * An operation did not complete before its timeout expired. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class TimeoutError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "timeout"; + } +} + +/** + * A command to set a cookie's value could not be satisfied. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnableToSetCookieError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unable to set cookie"; + } +} + +/** + * A command to capture a screenshot could not be satisfied. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnableToCaptureScreen extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unable to capture screen"; + } +} + +/** + * A modal dialog was open, blocking this operation. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnexpectedAlertOpenError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unexpected alert open"; + } +} + +/** + * A command could not be executed because the remote end is not + * aware of it. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnknownCommandError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unknown command"; + } +} + +/** + * An unknown error occurred in the remote end while processing + * the command. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnknownError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unknown error"; + } +} + +/** + * Indicates that a command that should have executed properly + * cannot be supported for some reason. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnsupportedOperationError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unsupported operation"; + } +} + +const STATUSES = new Map([ + ["detached shadow root", DetachedShadowRootError], + ["element click intercepted", ElementClickInterceptedError], + ["element not accessible", ElementNotAccessibleError], + ["element not interactable", ElementNotInteractableError], + ["insecure certificate", InsecureCertificateError], + ["invalid argument", InvalidArgumentError], + ["invalid cookie domain", InvalidCookieDomainError], + ["invalid element state", InvalidElementStateError], + ["invalid selector", InvalidSelectorError], + ["invalid session id", InvalidSessionIDError], + ["javascript error", JavaScriptError], + ["move target out of bounds", MoveTargetOutOfBoundsError], + ["no such alert", NoSuchAlertError], + ["no such element", NoSuchElementError], + ["no such frame", NoSuchFrameError], + ["no such handle", NoSuchHandleError], + ["no such history entry", NoSuchHistoryEntryError], + ["no such intercept", NoSuchInterceptError], + ["no such node", NoSuchNodeError], + ["no such request", NoSuchRequestError], + ["no such script", NoSuchScriptError], + ["no such shadow root", NoSuchShadowRootError], + ["no such user context", NoSuchUserContextError], + ["no such window", NoSuchWindowError], + ["script timeout", ScriptTimeoutError], + ["session not created", SessionNotCreatedError], + ["stale element reference", StaleElementReferenceError], + ["timeout", TimeoutError], + ["unable to capture screen", UnableToCaptureScreen], + ["unable to set cookie", UnableToSetCookieError], + ["unexpected alert open", UnexpectedAlertOpenError], + ["unknown command", UnknownCommandError], + ["unknown error", UnknownError], + ["unsupported operation", UnsupportedOperationError], + ["webdriver error", WebDriverError], +]); + +// Errors must be expored on the local this scope so that the +// EXPORTED_SYMBOLS and the ChromeUtils.import("foo") machinery sees them. +// We could assign each error definition directly to |this|, but +// because they are Error prototypes this would mess up their names. +for (let cls of STATUSES.values()) { + error[cls.name] = cls; +} diff --git a/remote/shared/webdriver/KeyData.sys.mjs b/remote/shared/webdriver/KeyData.sys.mjs new file mode 100644 index 0000000000..dc19d19f35 --- /dev/null +++ b/remote/shared/webdriver/KeyData.sys.mjs @@ -0,0 +1,338 @@ +/* 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/. */ + +const KEY_DATA = { + " ": { code: "Space" }, + "!": { code: "Digit1", shifted: true }, + "#": { code: "Digit3", shifted: true }, + $: { code: "Digit4", shifted: true }, + "%": { code: "Digit5", shifted: true }, + "&": { code: "Digit7", shifted: true }, + "'": { code: "Quote" }, + "(": { code: "Digit9", shifted: true }, + ")": { code: "Digit0", shifted: true }, + "*": { code: "Digit8", shifted: true }, + "+": { code: "Equal", shifted: true }, + ",": { code: "Comma" }, + "-": { code: "Minus" }, + ".": { code: "Period" }, + "/": { code: "Slash" }, + 0: { code: "Digit0" }, + 1: { code: "Digit1" }, + 2: { code: "Digit2" }, + 3: { code: "Digit3" }, + 4: { code: "Digit4" }, + 5: { code: "Digit5" }, + 6: { code: "Digit6" }, + 7: { code: "Digit7" }, + 8: { code: "Digit8" }, + 9: { code: "Digit9" }, + ":": { code: "Semicolon", shifted: true }, + ";": { code: "Semicolon" }, + "<": { code: "Comma", shifted: true }, + "=": { code: "Equal" }, + ">": { code: "Period", shifted: true }, + "?": { code: "Slash", shifted: true }, + "@": { code: "Digit2", shifted: true }, + A: { code: "KeyA", shifted: true }, + B: { code: "KeyB", shifted: true }, + C: { code: "KeyC", shifted: true }, + D: { code: "KeyD", shifted: true }, + E: { code: "KeyE", shifted: true }, + F: { code: "KeyF", shifted: true }, + G: { code: "KeyG", shifted: true }, + H: { code: "KeyH", shifted: true }, + I: { code: "KeyI", shifted: true }, + J: { code: "KeyJ", shifted: true }, + K: { code: "KeyK", shifted: true }, + L: { code: "KeyL", shifted: true }, + M: { code: "KeyM", shifted: true }, + N: { code: "KeyN", shifted: true }, + O: { code: "KeyO", shifted: true }, + P: { code: "KeyP", shifted: true }, + Q: { code: "KeyQ", shifted: true }, + R: { code: "KeyR", shifted: true }, + S: { code: "KeyS", shifted: true }, + T: { code: "KeyT", shifted: true }, + U: { code: "KeyU", shifted: true }, + V: { code: "KeyV", shifted: true }, + W: { code: "KeyW", shifted: true }, + X: { code: "KeyX", shifted: true }, + Y: { code: "KeyY", shifted: true }, + Z: { code: "KeyZ", shifted: true }, + "[": { code: "BracketLeft" }, + '"': { code: "Quote", shifted: true }, + "\\": { code: "Backslash" }, + "]": { code: "BracketRight" }, + "^": { code: "Digit6", shifted: true }, + _: { code: "Minus", shifted: true }, + "`": { code: "Backquote" }, + a: { code: "KeyA" }, + b: { code: "KeyB" }, + c: { code: "KeyC" }, + d: { code: "KeyD" }, + e: { code: "KeyE" }, + f: { code: "KeyF" }, + g: { code: "KeyG" }, + h: { code: "KeyH" }, + i: { code: "KeyI" }, + j: { code: "KeyJ" }, + k: { code: "KeyK" }, + l: { code: "KeyL" }, + m: { code: "KeyM" }, + n: { code: "KeyN" }, + o: { code: "KeyO" }, + p: { code: "KeyP" }, + q: { code: "KeyQ" }, + r: { code: "KeyR" }, + s: { code: "KeyS" }, + t: { code: "KeyT" }, + u: { code: "KeyU" }, + v: { code: "KeyV" }, + w: { code: "KeyW" }, + x: { code: "KeyX" }, + y: { code: "KeyY" }, + z: { code: "KeyZ" }, + "{": { code: "BracketLeft", shifted: true }, + "|": { code: "Backslash", shifted: true }, + "}": { code: "BracketRight", shifted: true }, + "~": { code: "Backquote", shifted: true }, + "\uE000": { key: "Unidentified", printable: false }, + "\uE001": { key: "Cancel", printable: false }, + "\uE002": { code: "Help", key: "Help", printable: false }, + "\uE003": { code: "Backspace", key: "Backspace", printable: false }, + "\uE004": { code: "Tab", key: "Tab", printable: false }, + "\uE005": { code: "", key: "Clear", printable: false }, + "\uE006": { code: "Enter", key: "Enter", printable: false }, + "\uE007": { + code: "NumpadEnter", + key: "Enter", + location: 1, + printable: false, + }, + "\uE008": { + code: "ShiftLeft", + key: "Shift", + location: 1, + modifier: "shiftKey", + printable: false, + }, + "\uE009": { + code: "ControlLeft", + key: "Control", + location: 1, + modifier: "ctrlKey", + printable: false, + }, + "\uE00A": { + code: "AltLeft", + key: "Alt", + location: 1, + modifier: "altKey", + printable: false, + }, + "\uE00B": { code: "Pause", key: "Pause", printable: false }, + "\uE00C": { code: "Escape", key: "Escape", printable: false }, + "\uE00D": { code: "Space", key: " ", shifted: true }, + "\uE00E": { code: "PageUp", key: "PageUp", printable: false }, + "\uE00F": { code: "PageDown", key: "PageDown", printable: false }, + "\uE010": { code: "End", key: "End", printable: false }, + "\uE011": { code: "Home", key: "Home", printable: false }, + "\uE012": { code: "ArrowLeft", key: "ArrowLeft", printable: false }, + "\uE013": { code: "ArrowUp", key: "ArrowUp", printable: false }, + "\uE014": { code: "ArrowRight", key: "ArrowRight", printable: false }, + "\uE015": { code: "ArrowDown", key: "ArrowDown", printable: false }, + "\uE016": { code: "Insert", key: "Insert", printable: false }, + "\uE017": { code: "Delete", key: "Delete", printable: false }, + "\uE018": { code: "", key: ";" }, + "\uE019": { code: "NumpadEqual", key: "=", location: 3 }, + "\uE01A": { code: "Numpad0", key: "0", location: 3 }, + "\uE01B": { code: "Numpad1", key: "1", location: 3 }, + "\uE01C": { code: "Numpad2", key: "2", location: 3 }, + "\uE01D": { code: "Numpad3", key: "3", location: 3 }, + "\uE01E": { code: "Numpad4", key: "4", location: 3 }, + "\uE01F": { code: "Numpad5", key: "5", location: 3 }, + "\uE020": { code: "Numpad6", key: "6", location: 3 }, + "\uE021": { code: "Numpad7", key: "7", location: 3 }, + "\uE022": { code: "Numpad8", key: "8", location: 3 }, + "\uE023": { code: "Numpad9", key: "9", location: 3 }, + "\uE024": { code: "NumpadMultiply", key: "*", location: 3 }, + "\uE025": { code: "NumpadAdd", key: "+", location: 3 }, + "\uE026": { code: "NumpadComma", key: ",", location: 3 }, + "\uE027": { code: "NumpadSubtract", key: "-", location: 3 }, + "\uE028": { code: "NumpadDecimal", key: ".", location: 3 }, + "\uE029": { code: "NumpadDivide", key: "/", location: 3 }, + "\uE031": { code: "F1", key: "F1", printable: false }, + "\uE032": { code: "F2", key: "F2", printable: false }, + "\uE033": { code: "F3", key: "F3", printable: false }, + "\uE034": { code: "F4", key: "F4", printable: false }, + "\uE035": { code: "F5", key: "F5", printable: false }, + "\uE036": { code: "F6", key: "F6", printable: false }, + "\uE037": { code: "F7", key: "F7", printable: false }, + "\uE038": { code: "F8", key: "F8", printable: false }, + "\uE039": { code: "F9", key: "F9", printable: false }, + "\uE03A": { code: "F10", key: "F10", printable: false }, + "\uE03B": { code: "F11", key: "F11", printable: false }, + "\uE03C": { code: "F12", key: "F12", printable: false }, + "\uE03D": { + code: "MetaLeft", + key: "Meta", + location: 1, + modifier: "metaKey", + printable: false, + }, + "\uE040": { code: "", key: "ZenkakuHankaku", printable: false }, + "\uE050": { + code: "ShiftRight", + key: "Shift", + location: 2, + modifier: "shiftKey", + printable: false, + }, + "\uE051": { + code: "ControlRight", + key: "Control", + location: 2, + modifier: "ctrlKey", + printable: false, + }, + "\uE052": { + code: "AltRight", + key: "Alt", + location: 2, + modifier: "altKey", + printable: false, + }, + "\uE053": { + code: "MetaRight", + key: "Meta", + location: 2, + modifier: "metaKey", + printable: false, + }, + "\uE054": { + code: "Numpad9", + key: "PageUp", + location: 3, + printable: false, + shifted: true, + }, + "\uE055": { + code: "Numpad3", + key: "PageDown", + location: 3, + printable: false, + shifted: true, + }, + "\uE056": { + code: "Numpad1", + key: "End", + location: 3, + printable: false, + shifted: true, + }, + "\uE057": { + code: "Numpad7", + key: "Home", + location: 3, + printable: false, + shifted: true, + }, + "\uE058": { + code: "Numpad4", + key: "ArrowLeft", + location: 3, + printable: false, + shifted: true, + }, + "\uE059": { + code: "Numpad8", + key: "ArrowUp", + location: 3, + printable: false, + shifted: true, + }, + "\uE05A": { + code: "Numpad6", + key: "ArrowRight", + location: 3, + printable: false, + shifted: true, + }, + "\uE05B": { + code: "Numpad2", + key: "ArrowDown", + location: 3, + printable: false, + shifted: true, + }, + "\uE05C": { + code: "Numpad0", + key: "Insert", + location: 3, + printable: false, + shifted: true, + }, + "\uE05D": { + code: "NumpadDecimal", + key: "Delete", + location: 3, + printable: false, + shifted: true, + }, +}; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "SHIFT_DATA", () => { + // Initalize the shift mapping + const shiftData = new Map(); + const byCode = new Map(); + for (let [key, props] of Object.entries(KEY_DATA)) { + if (props.code) { + if (!byCode.has(props.code)) { + byCode.set(props.code, [null, null]); + } + byCode.get(props.code)[props.shifted ? 1 : 0] = key; + } + } + for (let [unshifted, shifted] of byCode.values()) { + if (unshifted !== null && shifted !== null) { + shiftData.set(unshifted, shifted); + } + } + return shiftData; +}); + +export const keyData = { + /** + * Get key event data for a given key character. + * + * @param {string} rawKey + * Key for which to get data. This can either be the key codepoint + * itself or one of the codepoints in the range U+E000-U+E05D that + * WebDriver uses to represent keys not corresponding directly to + * a codepoint. + * @returns {object} Key event data object. + */ + getData(rawKey) { + let keyData = { key: rawKey, location: 0, printable: true, shifted: false }; + if (KEY_DATA.hasOwnProperty(rawKey)) { + keyData = { ...keyData, ...KEY_DATA[rawKey] }; + } + return keyData; + }, + + /** + * Get shifted key character for a given key character. + * + * For characters unaffected by the shift key, this returns the input. + * + * @param {string} rawKey Key for which to get shifted key. + * @returns {string} Key string to use when the shift modifier is set. + */ + getShiftedKey(rawKey) { + return lazy.SHIFT_DATA.get(rawKey) ?? rawKey; + }, +}; diff --git a/remote/shared/webdriver/NodeCache.sys.mjs b/remote/shared/webdriver/NodeCache.sys.mjs new file mode 100644 index 0000000000..032eae2543 --- /dev/null +++ b/remote/shared/webdriver/NodeCache.sys.mjs @@ -0,0 +1,179 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +/** + * @typedef {object} NodeReferenceDetails + * @property {number} browserId + * @property {number} browsingContextGroupId + * @property {number} browsingContextId + * @property {boolean} isTopBrowsingContext + * @property {WeakRef} nodeWeakRef + */ + +/** + * The class provides a mapping between DOM nodes and a unique node references. + * Supported types of nodes are Element and ShadowRoot. + */ +export class NodeCache { + #nodeIdMap; + #seenNodesMap; + + constructor() { + // node => node id + this.#nodeIdMap = new WeakMap(); + + // Reverse map for faster lookup requests of node references. Values do + // not only contain the resolved DOM node but also further details like + // browsing context information. + // + // node id => node details + this.#seenNodesMap = new Map(); + } + + /** + * Get the number of nodes in the cache. + */ + get size() { + return this.#seenNodesMap.size; + } + + /** + * Get or if not yet existent create a unique reference for an Element or + * ShadowRoot node. + * + * @param {Node} node + * The node to be added. + * @param {Map<BrowsingContext, Array<string>>} seenNodeIds + * Map of browsing contexts to their seen node ids during the current + * serialization. + * + * @returns {string} + * The unique node reference for the DOM node. + */ + getOrCreateNodeReference(node, seenNodeIds) { + if (!Node.isInstance(node)) { + throw new TypeError(`Failed to create node reference for ${node}`); + } + + let nodeId; + if (this.#nodeIdMap.has(node)) { + // For already known nodes return the cached node id. + nodeId = this.#nodeIdMap.get(node); + } else { + // Bug 1820734: For some Node types like `CDATA` no `ownerGlobal` + // property is available, and as such they cannot be deserialized + // right now. + const browsingContext = node.ownerGlobal?.browsingContext; + + // For not yet cached nodes generate a unique id without curly braces. + nodeId = lazy.generateUUID(); + + const details = { + browserId: browsingContext?.browserId, + browsingContextGroupId: browsingContext?.group.id, + browsingContextId: browsingContext?.id, + isTopBrowsingContext: browsingContext?.parent === null, + nodeWeakRef: Cu.getWeakReference(node), + }; + + this.#nodeIdMap.set(node, nodeId); + this.#seenNodesMap.set(nodeId, details); + + // Also add the information for the node id and its correlated browsing + // context to allow the parent process to update the seen nodes. + if (!seenNodeIds.has(browsingContext)) { + seenNodeIds.set(browsingContext, []); + } + seenNodeIds.get(browsingContext).push(nodeId); + } + + return nodeId; + } + + /** + * Clear known DOM nodes. + * + * @param {object=} options + * @param {boolean=} options.all + * Clear all references from any browsing context. Defaults to false. + * @param {BrowsingContext=} options.browsingContext + * Clear all references living in that browsing context. + */ + clear(options = {}) { + const { all = false, browsingContext } = options; + + if (all) { + this.#nodeIdMap = new WeakMap(); + this.#seenNodesMap.clear(); + return; + } + + if (browsingContext) { + for (const [nodeId, identifier] of this.#seenNodesMap.entries()) { + const { browsingContextId, nodeWeakRef } = identifier; + const node = nodeWeakRef.get(); + + if (browsingContextId === browsingContext.id) { + this.#nodeIdMap.delete(node); + this.#seenNodesMap.delete(nodeId); + } + } + + return; + } + + throw new Error(`Requires "browsingContext" or "all" to be set.`); + } + + /** + * Get a DOM node by its unique reference. + * + * @param {BrowsingContext} browsingContext + * The browsing context the node should be part of. + * @param {string} nodeId + * The unique node reference of the DOM node. + * + * @returns {Node|null} + * The DOM node that the unique identifier was generated for or + * `null` if the node does not exist anymore. + */ + getNode(browsingContext, nodeId) { + const nodeDetails = this.getReferenceDetails(nodeId); + + // Check that the node reference is known, and is associated with a + // browsing context that shares the same browsing context group. + if ( + nodeDetails === null || + nodeDetails.browsingContextGroupId !== browsingContext.group.id + ) { + return null; + } + + if (nodeDetails.nodeWeakRef) { + return nodeDetails.nodeWeakRef.get(); + } + + return null; + } + + /** + * Get detailed information for the node reference. + * + * @param {string} nodeId + * + * @returns {NodeReferenceDetails} + * Node details like: browsingContextId + */ + getReferenceDetails(nodeId) { + const details = this.#seenNodesMap.get(nodeId); + + return details !== undefined ? details : null; + } +} diff --git a/remote/shared/webdriver/Session.sys.mjs b/remote/shared/webdriver/Session.sys.mjs new file mode 100644 index 0000000000..edffeea7b6 --- /dev/null +++ b/remote/shared/webdriver/Session.sys.mjs @@ -0,0 +1,418 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs", + allowAllCerts: "chrome://remote/content/marionette/cert.sys.mjs", + Capabilities: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + registerProcessDataActor: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + RootMessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + unregisterProcessDataActor: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs", + WebDriverBiDiConnection: + "chrome://remote/content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs", + WebSocketHandshake: + "chrome://remote/content/server/WebSocketHandshake.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +// Global singleton that holds active WebDriver sessions +const webDriverSessions = new Map(); + +/** + * Representation of WebDriver session. + */ +export class WebDriverSession { + /** + * Construct a new WebDriver session. + * + * It is expected that the caller performs the necessary checks on + * the requested capabilities to be WebDriver conforming. The WebDriver + * service offered by Marionette does not match or negotiate capabilities + * beyond type- and bounds checks. + * + * <h3>Capabilities</h3> + * + * <dl> + * <dt><code>acceptInsecureCerts</code> (boolean) + * <dd>Indicates whether untrusted and self-signed TLS certificates + * are implicitly trusted on navigation for the duration of the session. + * + * <dt><code>pageLoadStrategy</code> (string) + * <dd>The page load strategy to use for the current session. Must be + * one of "<tt>none</tt>", "<tt>eager</tt>", and "<tt>normal</tt>". + * + * <dt><code>proxy</code> (Proxy object) + * <dd>Defines the proxy configuration. + * + * <dt><code>setWindowRect</code> (boolean) + * <dd>Indicates whether the remote end supports all of the resizing + * and repositioning commands. + * + * <dt><code>timeouts</code> (Timeouts object) + * <dd>Describes the timeouts imposed on certian session operations. + * + * <dt><code>strictFileInteractability</code> (boolean) + * <dd>Defines the current session’s strict file interactability. + * + * <dt><code>unhandledPromptBehavior</code> (string) + * <dd>Describes the current session’s user prompt handler. Must be one of + * "<tt>accept</tt>", "<tt>accept and notify</tt>", "<tt>dismiss</tt>", + * "<tt>dismiss and notify</tt>", and "<tt>ignore</tt>". Defaults to the + * "<tt>dismiss and notify</tt>" state. + * + * <dt><code>moz:accessibilityChecks</code> (boolean) + * <dd>Run a11y checks when clicking elements. + * + * <dt><code>moz:debuggerAddress</code> (boolean) + * <dd>Indicate that the Chrome DevTools Protocol (CDP) has to be enabled. + * + * <dt><code>moz:webdriverClick</code> (boolean) + * <dd>Use a WebDriver conforming <i>WebDriver::ElementClick</i>. + * </dl> + * + * <h4>WebAuthn</h4> + * + * <dl> + * <dt><code>webauthn:virtualAuthenticators</code> (boolean) + * <dd>Indicates whether the endpoint node supports all Virtual + * Authenticators commands. + * + * <dt><code>webauthn:extension:uvm</code> (boolean) + * <dd>Indicates whether the endpoint node WebAuthn WebDriver + * implementation supports the User Verification Method extension. + * + * <dt><code>webauthn:extension:prf</code> (boolean) + * <dd>Indicates whether the endpoint node WebAuthn WebDriver + * implementation supports the prf extension. + * + * <dt><code>webauthn:extension:largeBlob</code> (boolean) + * <dd>Indicates whether the endpoint node WebAuthn WebDriver implementation + * supports the largeBlob extension. + * + * <dt><code>webauthn:extension:credBlob</code> (boolean) + * <dd>Indicates whether the endpoint node WebAuthn WebDriver implementation + * supports the credBlob extension. + * </dl> + * + * <h4>Timeouts object</h4> + * + * <dl> + * <dt><code>script</code> (number) + * <dd>Determines when to interrupt a script that is being evaluates. + * + * <dt><code>pageLoad</code> (number) + * <dd>Provides the timeout limit used to interrupt navigation of the + * browsing context. + * + * <dt><code>implicit</code> (number) + * <dd>Gives the timeout of when to abort when locating an element. + * </dl> + * + * <h4>Proxy object</h4> + * + * <dl> + * <dt><code>proxyType</code> (string) + * <dd>Indicates the type of proxy configuration. Must be one + * of "<tt>pac</tt>", "<tt>direct</tt>", "<tt>autodetect</tt>", + * "<tt>system</tt>", or "<tt>manual</tt>". + * + * <dt><code>proxyAutoconfigUrl</code> (string) + * <dd>Defines the URL for a proxy auto-config file if + * <code>proxyType</code> is equal to "<tt>pac</tt>". + * + * <dt><code>httpProxy</code> (string) + * <dd>Defines the proxy host for HTTP traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>noProxy</code> (string) + * <dd>Lists the adress for which the proxy should be bypassed when + * the <code>proxyType</code> is "<tt>manual</tt>". Must be a JSON + * List containing any number of any of domains, IPv4 addresses, or IPv6 + * addresses. + * + * <dt><code>sslProxy</code> (string) + * <dd>Defines the proxy host for encrypted TLS traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>socksProxy</code> (string) + * <dd>Defines the proxy host for a SOCKS proxy traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>socksVersion</code> (string) + * <dd>Defines the SOCKS proxy version when the <code>proxyType</code> is + * "<tt>manual</tt>". It must be any integer between 0 and 255 + * inclusive. + * </dl> + * + * <h3>Example</h3> + * + * Input: + * + * <pre><code> + * {"capabilities": {"acceptInsecureCerts": true}} + * </code></pre> + * + * @param {Object<string, *>=} capabilities + * JSON Object containing any of the recognised capabilities listed + * above. + * + * @param {WebDriverBiDiConnection=} connection + * An optional existing WebDriver BiDi connection to associate with the + * new session. + * + * @throws {SessionNotCreatedError} + * If, for whatever reason, a session could not be created. + */ + constructor(capabilities, connection) { + // WebSocket connections that use this session. This also accounts for + // possible disconnects due to network outages, which require clients + // to reconnect. + this._connections = new Set(); + + this.id = lazy.generateUUID(); + + // Define the HTTP path to query this session via WebDriver BiDi + this.path = `/session/${this.id}`; + + try { + this.capabilities = lazy.Capabilities.fromJSON(capabilities, this.path); + } catch (e) { + throw new lazy.error.SessionNotCreatedError(e); + } + + if (this.capabilities.get("acceptInsecureCerts")) { + lazy.logger.warn( + "TLS certificate errors will be ignored for this session" + ); + lazy.allowAllCerts.enable(); + } + + if (this.proxy.init()) { + lazy.logger.info( + `Proxy settings initialised: ${JSON.stringify(this.proxy)}` + ); + } + + // If we are testing accessibility with marionette, start a11y service in + // chrome first. This will ensure that we do not have any content-only + // services hanging around. + if (this.a11yChecks && lazy.accessibility.service) { + lazy.logger.info("Preemptively starting accessibility service in Chrome"); + } + + // If a connection without an associated session has been specified + // immediately register the newly created session for it. + if (connection) { + connection.registerSession(this); + this._connections.add(connection); + } + + // Maps a Navigable (browsing context or content browser for top-level + // browsing contexts) to a Set of nodeId's. + this.navigableSeenNodes = new WeakMap(); + + lazy.registerProcessDataActor(); + + webDriverSessions.set(this.id, this); + } + + destroy() { + webDriverSessions.delete(this.id); + + lazy.unregisterProcessDataActor(); + + this.navigableSeenNodes = null; + + lazy.allowAllCerts.disable(); + + // Close all open connections which unregister themselves. + this._connections.forEach(connection => connection.close()); + if (this._connections.size > 0) { + lazy.logger.warn( + `Failed to close ${this._connections.size} WebSocket connections` + ); + } + + // Destroy the dedicated MessageHandler instance if we created one. + if (this._messageHandler) { + this._messageHandler.off( + "message-handler-protocol-event", + this._onMessageHandlerProtocolEvent + ); + this._messageHandler.destroy(); + } + } + + 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() { + return this.capabilities.get("moz:accessibilityChecks"); + } + + get messageHandler() { + if (!this._messageHandler) { + this._messageHandler = + lazy.RootMessageHandlerRegistry.getOrCreateMessageHandler(this.id); + this._onMessageHandlerProtocolEvent = + this._onMessageHandlerProtocolEvent.bind(this); + this._messageHandler.on( + "message-handler-protocol-event", + this._onMessageHandlerProtocolEvent + ); + } + + return this._messageHandler; + } + + get pageLoadStrategy() { + return this.capabilities.get("pageLoadStrategy"); + } + + get proxy() { + return this.capabilities.get("proxy"); + } + + get strictFileInteractability() { + return this.capabilities.get("strictFileInteractability"); + } + + get timeouts() { + return this.capabilities.get("timeouts"); + } + + set timeouts(timeouts) { + this.capabilities.set("timeouts", timeouts); + } + + get unhandledPromptBehavior() { + return this.capabilities.get("unhandledPromptBehavior"); + } + + /** + * Remove the specified WebDriver BiDi connection. + * + * @param {WebDriverBiDiConnection} connection + */ + removeConnection(connection) { + if (this._connections.has(connection)) { + this._connections.delete(connection); + } else { + lazy.logger.warn("Trying to remove a connection that doesn't exist."); + } + } + + toString() { + return `[object ${this.constructor.name} ${this.id}]`; + } + + // nsIHttpRequestHandler + + /** + * Handle new WebSocket connection requests. + * + * WebSocket clients will attempt to connect to this session at + * `/session/:id`. Hereby a WebSocket upgrade will automatically + * be performed. + * + * @param {Request} request + * HTTP request (httpd.js) + * @param {Response} response + * Response to an HTTP request (httpd.js) + */ + async handle(request, response) { + const webSocket = await lazy.WebSocketHandshake.upgrade(request, response); + const conn = new lazy.WebDriverBiDiConnection( + webSocket, + response._connection + ); + conn.registerSession(this); + this._connections.add(conn); + } + + _onMessageHandlerProtocolEvent(eventName, messageHandlerEvent) { + const { name, data } = messageHandlerEvent; + this._connections.forEach(connection => connection.sendEvent(name, data)); + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIHttpRequestHandler"]); + } +} + +/** + * Get the list of seen nodes for the given browsing context unique to a + * WebDriver session. + * + * @param {string} sessionId + * The id of the WebDriver session to use. + * @param {BrowsingContext} browsingContext + * Browsing context the node is part of. + * + * @returns {Set} + * The list of seen nodes. + */ +export function getSeenNodesForBrowsingContext(sessionId, browsingContext) { + if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) { + // If browsingContext is not a valid Browsing Context, return an empty set. + return new Set(); + } + + const navigable = + lazy.TabManager.getNavigableForBrowsingContext(browsingContext); + const session = getWebDriverSessionById(sessionId); + + if (!session.navigableSeenNodes.has(navigable)) { + // The navigable hasn't been seen yet. + session.navigableSeenNodes.set(navigable, new Set()); + } + + return session.navigableSeenNodes.get(navigable); +} + +/** + * + * @param {string} sessionId + * The ID of the WebDriver session to retrieve. + * + * @returns {WebDriverSession|undefined} + * The WebDriver session or undefined if the id is not known. + */ +export function getWebDriverSessionById(sessionId) { + return webDriverSessions.get(sessionId); +} diff --git a/remote/shared/webdriver/URLPattern.sys.mjs b/remote/shared/webdriver/URLPattern.sys.mjs new file mode 100644 index 0000000000..0033cced66 --- /dev/null +++ b/remote/shared/webdriver/URLPattern.sys.mjs @@ -0,0 +1,521 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +/** + * Parsed pattern to use for URL matching. + * + * @typedef {object} ParsedURLPattern + * @property {string|null} protocol + * The protocol, for instance "https". + * @property {string|null} hostname + * The hostname, for instance "example.com". + * @property {string|null} port + * The serialized port. Empty string for default ports of special schemes. + * @property {string|null} path + * The path, starting with "/". + * @property {string|null} search + * The search query string, without the leading "?" + */ + +/** + * Subset of properties extracted from a parsed URL. + * + * @typedef {object} ParsedURL + * @property {string=} host + * @property {string|Array<string>} path + * Either a string if the path is an opaque path, or an array of strings + * (path segments). + * @property {number=} port + * @property {string=} query + * @property {string=} scheme + */ + +/** + * Enum of URLPattern types. + * + * @readonly + * @enum {URLPatternType} + */ +const URLPatternType = { + Pattern: "pattern", + String: "string", +}; + +const supportedURLPatternTypes = Object.values(URLPatternType); + +const SPECIAL_SCHEMES = ["file", "http", "https", "ws", "wss"]; +const DEFAULT_PORTS = { + file: null, + http: 80, + https: 443, + ws: 80, + wss: 443, +}; + +/** + * Check if a given URL pattern is compatible with the provided URL. + * + * Implements https://w3c.github.io/webdriver-bidi/#match-url-pattern + * + * @param {ParsedURLPattern} urlPattern + * The URL pattern to match. + * @param {string} url + * The string representation of a URL to test against the pattern. + * + * @returns {boolean} + * True if the pattern is compatible with the provided URL, false otherwise. + */ +export function matchURLPattern(urlPattern, url) { + const parsedURL = parseURL(url); + + if (urlPattern.protocol !== null && urlPattern.protocol != parsedURL.scheme) { + return false; + } + + if (urlPattern.hostname !== null && urlPattern.hostname != parsedURL.host) { + return false; + } + + if (urlPattern.port !== null && urlPattern.port != serializePort(parsedURL)) { + return false; + } + + if ( + urlPattern.pathname !== null && + urlPattern.pathname != serializePath(parsedURL) + ) { + return false; + } + + if (urlPattern.search !== null) { + const urlQuery = parsedURL.query === null ? "" : parsedURL.query; + if (urlPattern.search != urlQuery) { + return false; + } + } + + return true; +} + +/** + * Parse a URLPattern into a parsed pattern object which can be used to match + * URLs using `matchURLPattern`. + * + * Implements https://w3c.github.io/webdriver-bidi/#parse-url-pattern + * + * @param {URLPattern} pattern + * The pattern to parse. + * + * @returns {ParsedURLPattern} + * The parsed URL pattern. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {UnsupportedOperationError} + * Raised if the pattern uses a protocol not supported by Firefox. + */ +export function parseURLPattern(pattern) { + lazy.assert.object( + pattern, + `Expected url pattern to be an object, got ${pattern}` + ); + + let hasProtocol = true; + let hasHostname = true; + let hasPort = true; + let hasPathname = true; + let hasSearch = true; + + let patternUrl; + switch (pattern.type) { + case URLPatternType.Pattern: + patternUrl = ""; + if ("protocol" in pattern) { + patternUrl += parseProtocol(pattern.protocol); + } else { + hasProtocol = false; + patternUrl += "http"; + } + + const scheme = patternUrl.toLowerCase(); + patternUrl += ":"; + if (SPECIAL_SCHEMES.includes(scheme)) { + patternUrl += "//"; + } + + if ("hostname" in pattern) { + patternUrl += parseHostname(pattern.hostname, scheme); + } else { + if (scheme != "file") { + patternUrl += "placeholder"; + } + hasHostname = false; + } + + if ("port" in pattern) { + patternUrl += parsePort(pattern.port); + } else { + hasPort = false; + } + + if ("pathname" in pattern) { + patternUrl += parsePathname(pattern.pathname); + } else { + hasPathname = false; + } + + if ("search" in pattern) { + patternUrl += parseSearch(pattern.search); + } else { + hasSearch = false; + } + break; + case URLPatternType.String: + lazy.assert.string( + pattern.pattern, + `Expected "urlPattern" of type "string" to have a string "pattern" property, got ${pattern.pattern}` + ); + patternUrl = unescapeUrlPattern(pattern.pattern); + break; + default: + throw new lazy.error.InvalidArgumentError( + `Expected "urlPattern" type to be one of ${supportedURLPatternTypes}, got ${pattern.type}` + ); + } + + if (!URL.canParse(patternUrl)) { + throw new lazy.error.InvalidArgumentError( + `Unable to parse URL "${patternUrl}"` + ); + } + + let parsedURL; + try { + parsedURL = parseURL(patternUrl); + } catch (e) { + throw new lazy.error.InvalidArgumentError( + `Failed to parse URL "${patternUrl}"` + ); + } + + if (hasProtocol && !SPECIAL_SCHEMES.includes(parsedURL.scheme)) { + throw new lazy.error.UnsupportedOperationError( + `URL pattern did not specify a supported protocol (one of ${SPECIAL_SCHEMES}), got ${parsedURL.scheme}` + ); + } + + return { + protocol: hasProtocol ? parsedURL.scheme : null, + hostname: hasHostname ? parsedURL.host : null, + port: hasPort ? serializePort(parsedURL) : null, + pathname: + hasPathname && parsedURL.path.length ? serializePath(parsedURL) : null, + search: hasSearch ? parsedURL.query || "" : null, + }; +} + +/** + * Parse the hostname property of a URLPatternPattern. + * + * @param {string} hostname + * A hostname property. + * @param {string} scheme + * The scheme for the URLPatternPattern. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parseHostname(hostname, scheme) { + if (typeof hostname != "string" || hostname == "") { + throw new lazy.error.InvalidArgumentError( + `Expected URLPattern "hostname" to be a non-empty string, got ${hostname}` + ); + } + + if (scheme == "file") { + throw new lazy.error.InvalidArgumentError( + `URLPattern with "file" scheme cannot specify a hostname, got ${hostname}` + ); + } + + hostname = unescapeUrlPattern(hostname); + + const forbiddenHostnameCharacters = ["/", "?", "#"]; + let insideBrackets = false; + for (const codepoint of hostname) { + if ( + forbiddenHostnameCharacters.includes(codepoint) || + (!insideBrackets && codepoint == ":") + ) { + throw new lazy.error.InvalidArgumentError( + `URL pattern "hostname" contained a forbidden character, got "${hostname}"` + ); + } + + if (codepoint == "[") { + insideBrackets = true; + } else if (codepoint == "]") { + insideBrackets = false; + } + } + + return hostname; +} + +/** + * Parse the pathname property of a URLPatternPattern. + * + * @param {string} pathname + * A pathname property. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parsePathname(pathname) { + lazy.assert.string( + pathname, + `Expected URLPattern "pathname" to be a string, got ${pathname}` + ); + + pathname = unescapeUrlPattern(pathname); + if (!pathname.startsWith("/")) { + pathname = `/${pathname}`; + } + + if (pathname.includes("?") || pathname.includes("#")) { + throw new lazy.error.InvalidArgumentError( + `URL pattern "pathname" contained a forbidden character, got "${pathname}"` + ); + } + + return pathname; +} + +/** + * Parse the port property of a URLPatternPattern. + * + * @param {string} port + * A port property. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parsePort(port) { + if (typeof port != "string" || port == "") { + throw new lazy.error.InvalidArgumentError( + `Expected URLPattern "port" to be a non-empty string, got ${port}` + ); + } + + port = unescapeUrlPattern(port); + + const isNumber = /^\d*$/.test(port); + if (!isNumber) { + throw new lazy.error.InvalidArgumentError( + `URL pattern "port" is not a valid number, got "${port}"` + ); + } + + return `:${port}`; +} + +/** + * Parse the protocol property of a URLPatternPattern. + * + * @param {string} protocol + * A protocol property. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parseProtocol(protocol) { + if (typeof protocol != "string" || protocol == "") { + throw new lazy.error.InvalidArgumentError( + `Expected URLPattern "protocol" to be a non-empty string, got ${protocol}` + ); + } + + protocol = unescapeUrlPattern(protocol); + if (!/^[a-zA-Z0-9+-.]*$/.test(protocol)) { + throw new lazy.error.InvalidArgumentError( + `URL pattern "protocol" contained a forbidden character, got "${protocol}"` + ); + } + + return protocol; +} + +/** + * Parse the search property of a URLPatternPattern. + * + * @param {string} search + * A search property. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parseSearch(search) { + lazy.assert.string( + search, + `Expected URLPattern "search" to be a string, got ${search}` + ); + + search = unescapeUrlPattern(search); + if (!search.startsWith("?")) { + search = `?${search}`; + } + + if (search.includes("#")) { + throw new lazy.error.InvalidArgumentError( + `Expected URLPattern "search" to never contain "#", got ${search}` + ); + } + + return search; +} + +/** + * Parse a string URL. This tries to be close to Basic URL Parser, however since + * this is not currently implemented in Firefox and URL parsing has many edge + * cases, it does not try to be a faithful implementation. + * + * Edge cases which are not supported are mostly about non-special URLs, which + * in practice should not be observable in automation. + * + * @param {string} url + * The string based URL to parse. + * @returns {ParsedURL} + * The parsed URL. + */ +function parseURL(url) { + const urlObj = new URL(url); + const uri = urlObj.URI; + + return { + scheme: uri.scheme, + // Note: Use urlObj instead of uri for hostname: + // nsIURI removes brackets from ipv6 hostnames (eg [::1] becomes ::1). + host: urlObj.hostname, + path: uri.filePath, + // Note: Use urlObj instead of uri for port: + // nsIURI throws on the port getter for non-special schemes. + port: urlObj.port != "" ? Number(uri.port) : null, + query: uri.hasQuery ? uri.query : null, + }; +} + +/** + * Serialize the path of a parsed URL. + * + * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#parse-url-pattern + * + * @param {ParsedURL} url + * A parsed url. + * + * @returns {string} + * The serialized path + */ +function serializePath(url) { + // Check for opaque path + if (typeof url.path == "string") { + return url.path; + } + + let serialized = ""; + for (const segment of url.path) { + serialized += `/${segment}`; + } + + return serialized; +} + +/** + * Serialize the port of a parsed URL. + * + * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#parse-url-pattern + * + * @param {ParsedURL} url + * A parsed url. + * + * @returns {string} + * The serialized port + */ +function serializePort(url) { + let port = null; + if ( + SPECIAL_SCHEMES.includes(url.scheme) && + DEFAULT_PORTS[url.scheme] !== null && + (url.port === null || url.port == DEFAULT_PORTS[url.scheme]) + ) { + port = ""; + } else if (url.port !== null) { + port = `${url.port}`; + } + + return port; +} + +/** + * Unescape and check a pattern string against common forbidden characters. + * + * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#unescape-url-pattern + * + * @param {string} pattern + * Either a full URLPatternString pattern or a property of a URLPatternPattern. + * + * @returns {string} + * The unescaped pattern + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function unescapeUrlPattern(pattern) { + const forbiddenCharacters = ["(", ")", "*", "{", "}"]; + const escapeCharacter = "\\"; + + let isEscaped = false; + let result = ""; + + for (const codepoint of Array.from(pattern)) { + if (!isEscaped) { + if (forbiddenCharacters.includes(codepoint)) { + throw new lazy.error.InvalidArgumentError( + `URL pattern contained an unescaped forbidden character ${codepoint}` + ); + } + + if (codepoint == escapeCharacter) { + isEscaped = true; + continue; + } + } + + result += codepoint; + isEscaped = false; + } + + return result; +} diff --git a/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs new file mode 100644 index 0000000000..39db9d939e --- /dev/null +++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs @@ -0,0 +1,93 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + NodeCache: "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +// Observer to clean-up element references for closed browsing contexts. +class BrowsingContextObserver { + constructor(actor) { + this.actor = actor; + } + + async observe(subject, topic, data) { + if (topic === "browsing-context-discarded") { + this.actor.cleanUp({ browsingContext: subject }); + } + } +} + +export class WebDriverProcessDataChild extends JSProcessActorChild { + #browsingContextObserver; + #nodeCache; + + constructor() { + super(); + + // For now have a single reference store only. Once multiple WebDriver + // sessions are supported, it needs to be hashed by the session id. + this.#nodeCache = new lazy.NodeCache(); + + // Register observer to cleanup element references when a browsing context + // gets destroyed. + this.#browsingContextObserver = new BrowsingContextObserver(this); + Services.obs.addObserver( + this.#browsingContextObserver, + "browsing-context-discarded" + ); + } + + actorCreated() { + lazy.logger.trace( + `WebDriverProcessData actor created for PID ${Services.appinfo.processID}` + ); + } + + didDestroy() { + Services.obs.removeObserver( + this.#browsingContextObserver, + "browsing-context-discarded" + ); + } + + /** + * Clean up all the process specific data. + * + * @param {object=} options + * @param {BrowsingContext=} options.browsingContext + * If specified only clear data living in that browsing context. + */ + cleanUp(options = {}) { + const { browsingContext = null } = options; + + this.#nodeCache.clear({ browsingContext }); + } + + /** + * Get the node cache. + * + * @returns {NodeCache} + * The cache containing DOM node references. + */ + getNodeCache() { + return this.#nodeCache; + } + + async receiveMessage(msg) { + switch (msg.name) { + case "WebDriverProcessDataParent:CleanUp": + return this.cleanUp(msg.data); + default: + return Promise.reject( + new Error(`Unexpected message received: ${msg.name}`) + ); + } + } +} diff --git a/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs new file mode 100644 index 0000000000..a895106c4b --- /dev/null +++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs @@ -0,0 +1,37 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Register the WebDriverProcessData actor that holds session data. + */ +export function registerProcessDataActor() { + try { + ChromeUtils.registerProcessActor("WebDriverProcessData", { + kind: "JSProcessActor", + child: { + esModuleURI: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs", + }, + includeParent: true, + }); + } catch (e) { + if (e.name === "NotSupportedError") { + lazy.logger.warn(`WebDriverProcessData actor is already registered!`); + } else { + throw e; + } + } +} + +export function unregisterProcessDataActor() { + ChromeUtils.unregisterProcessActor("WebDriverProcessData"); +} diff --git a/remote/shared/webdriver/test/xpcshell/head.js b/remote/shared/webdriver/test/xpcshell/head.js new file mode 100644 index 0000000000..ddc5573d78 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/head.js @@ -0,0 +1,15 @@ +async function doGC() { + // Run GC and CC a few times to make sure that as much as possible is freed. + const numCycles = 3; + for (let i = 0; i < numCycles; i++) { + Cu.forceGC(); + Cu.forceCC(); + await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve)); + } + + const MemoryReporter = Cc[ + "@mozilla.org/memory-reporter-manager;1" + ].getService(Ci.nsIMemoryReporterManager); + + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); +} diff --git a/remote/shared/webdriver/test/xpcshell/test_Actions.js b/remote/shared/webdriver/test/xpcshell/test_Actions.js new file mode 100644 index 0000000000..24eac2e09d --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Actions.js @@ -0,0 +1,758 @@ +/* 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 { action, CLICK_INTERVAL, ClickTracker } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Actions.sys.mjs" +); + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const XHTMLNS = "http://www.w3.org/1999/xhtml"; + +const domEl = { + nodeType: 1, + ELEMENT_NODE: 1, + namespaceURI: XHTMLNS, +}; + +add_task(function test_createInputState() { + for (let type of ["none", "key", "pointer" /*"wheel"*/]) { + const state = new action.State(); + const id = "device"; + const actionSequence = { + type, + id, + actions: [], + }; + action.Chain.fromJSON(state, [actionSequence]); + equal(state.inputStateMap.size, 1); + equal(state.inputStateMap.get(id).constructor.type, type); + } +}); + +add_task(function test_defaultPointerParameters() { + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const pointerAction = chain[0][0]; + equal( + state.getInputSource(pointerAction.id).pointer.constructor.type, + "mouse" + ); +}); + +add_task(function test_processPointerParameters() { + for (let subtype of ["pointerDown", "pointerUp"]) { + for (let pointerType of [2, true, {}, []]) { + const inputTickActions = [ + { + type: "pointer", + parameters: { pointerType }, + subtype, + button: 0, + }, + ]; + let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`; + checkFromJSONErrors( + inputTickActions, + /Expected "pointerType" to be a string/, + message + ); + } + + for (let pointerType of ["", "foo"]) { + const inputTickActions = [ + { + type: "pointer", + parameters: { pointerType }, + subtype, + button: 0, + }, + ]; + let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`; + checkFromJSONErrors( + inputTickActions, + /Expected "pointerType" to be one of/, + message + ); + } + } + + for (let pointerType of ["mouse" /*"touch"*/]) { + let state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + parameters: { pointerType }, + subtype: "pointerDown", + button: 0, + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const pointerAction = chain[0][0]; + equal( + state.getInputSource(pointerAction.id).pointer.constructor.type, + pointerType + ); + } +}); + +add_task(function test_processPointerDownAction() { + for (let button of [-1, "a"]) { + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected "button" to be a positive integer/, + `pointerDown with {button: ${button}}` + ); + } + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button: 5 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(chain[0][0].button, 5); +}); + +add_task(function test_validateActionDurationAndCoordinates() { + for (let [type, subtype] of [ + ["none", "pause"], + ["pointer", "pointerMove"], + ]) { + for (let duration of [-1, "a"]) { + const inputTickActions = [{ type, subtype, duration }]; + checkFromJSONErrors( + inputTickActions, + /Expected "duration" to be a positive integer/, + `{subtype} with {duration: ${duration}}` + ); + } + } + for (let name of ["x", "y"]) { + const actionItem = { + type: "pointer", + subtype: "pointerMove", + duration: 5000, + }; + actionItem[name] = "a"; + checkFromJSONErrors( + [actionItem], + /Expected ".*" to be an integer/, + `${name}: "a", subtype: pointerMove` + ); + } +}); + +add_task(function test_processPointerMoveActionOriginValidation() { + for (let origin of [-1, { a: "blah" }, []]) { + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", origin }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected "origin" to be undefined, "viewport", "pointer", or an element/, + `actionItem.origin: (${getTypeString(origin)})` + ); + } +}); + +add_task(function test_processPointerMoveActionOriginStringValidation() { + for (let origin of ["", "viewports", "pointers"]) { + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", origin }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected "origin" to be undefined, "viewport", "pointer", or an element/, + `actionItem.origin: ${origin}` + ); + } +}); + +add_task(function test_processPointerMoveActionElementOrigin() { + let state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + duration: 5000, + subtype: "pointerMove", + origin: domEl, + x: 0, + y: 0, + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + deepEqual(chain[0][0].origin.element, domEl); +}); + +add_task(function test_processPointerMoveActionDefaultOrigin() { + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", x: 0, y: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + // The default is viewport coordinates which have an origin at [0,0] and don't depend on inputSource + deepEqual(chain[0][0].origin.getOriginCoordinates(null, null), { + x: 0, + y: 0, + }); +}); + +add_task(function test_processPointerMoveAction() { + let state = new action.State(); + const actionItems = [ + { + duration: 5000, + type: "pointerMove", + origin: undefined, + x: 0, + y: 0, + }, + { + duration: undefined, + type: "pointerMove", + origin: domEl, + x: 0, + y: 0, + }, + { + duration: 5000, + type: "pointerMove", + x: 1, + y: 2, + origin: undefined, + }, + ]; + const actionSequence = { + id: "some_id", + type: "pointer", + actions: actionItems, + }; + let chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, actionItems.length); + for (let i = 0; i < actionItems.length; i++) { + let actual = chain[i][0]; + let expected = actionItems[i]; + equal(actual.duration, expected.duration); + equal(actual.x, expected.x); + equal(actual.y, expected.y); + + let originClass; + if (expected.origin === undefined || expected.origin == "viewport") { + originClass = "ViewportOrigin"; + } else if (expected.origin === "pointer") { + originClass = "PointerOrigin"; + } else { + originClass = "ElementOrigin"; + } + deepEqual(actual.origin.constructor.name, originClass); + } +}); + +add_task(function test_computePointerDestinationViewport() { + const state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + subtype: "pointerMove", + x: 100, + y: 200, + origin: "viewport", + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const actionItem = chain[0][0]; + const inputSource = state.getInputSource(actionItem.id); + // these values should not affect the outcome + inputSource.x = "99"; + inputSource.y = "10"; + const target = actionItem.origin.getTargetCoordinates( + inputSource, + [actionItem.x, actionItem.y], + null + ); + equal(actionItem.x, target[0]); + equal(actionItem.y, target[1]); +}); + +add_task(function test_computePointerDestinationPointer() { + const state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + subtype: "pointerMove", + x: 100, + y: 200, + origin: "pointer", + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const actionItem = chain[0][0]; + const inputSource = state.getInputSource(actionItem.id); + inputSource.x = 10; + inputSource.y = 99; + const target = actionItem.origin.getTargetCoordinates( + inputSource, + [actionItem.x, actionItem.y], + null + ); + equal(actionItem.x + inputSource.x, target[0]); + equal(actionItem.y + inputSource.y, target[1]); +}); + +add_task(function test_processPointerAction() { + for (let pointerType of ["mouse", "touch"]) { + const actionItems = [ + { + duration: 2000, + type: "pause", + }, + { + type: "pointerMove", + duration: 2000, + x: 0, + y: 0, + }, + { + type: "pointerUp", + button: 1, + }, + ]; + let actionSequence = { + type: "pointer", + id: "some_id", + parameters: { + pointerType, + }, + actions: actionItems, + }; + const state = new action.State(); + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, actionItems.length); + for (let i = 0; i < actionItems.length; i++) { + const actual = chain[i][0]; + const expected = actionItems[i]; + equal(actual.type, expected.type === "pause" ? "none" : "pointer"); + equal(actual.subtype, expected.type); + equal(actual.id, actionSequence.id); + if (expected.type === "pointerUp") { + equal(actual.button, expected.button); + } else { + equal(actual.duration, expected.duration); + } + if (expected.type !== "pause") { + equal( + state.getInputSource(actual.id).pointer.constructor.type, + pointerType + ); + } + } + } +}); + +add_task(function test_processPauseAction() { + for (let type of ["none", "key", "pointer"]) { + const state = new action.State(); + const actionSequence = { + type, + id: "some_id", + actions: [{ type: "pause", duration: 5000 }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + equal(actionItem.type, "none"); + equal(actionItem.subtype, "pause"); + equal(actionItem.id, "some_id"); + equal(actionItem.duration, 5000); + } + const state = new action.State(); + const actionSequence = { + type: "none", + id: "some_id", + actions: [{ type: "pause" }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + equal(actionItem.duration, undefined); +}); + +add_task(function test_processActionSubtypeValidation() { + for (let type of ["none", "key", "pointer"]) { + const message = `type: ${type}, subtype: dancing`; + const inputTickActions = [{ type, subtype: "dancing" }]; + checkFromJSONErrors( + inputTickActions, + new RegExp(`Expected known subtype for type`), + message + ); + } +}); + +add_task(function test_processKeyActionDown() { + for (let value of [-1, undefined, [], ["a"], { length: 1 }, null]) { + const inputTickActions = [{ type: "key", subtype: "keyDown", value }]; + const message = `actionItem.value: (${getTypeString(value)})`; + checkFromJSONErrors( + inputTickActions, + /Expected "value" to be a string that represents single code point/, + message + ); + } + + const state = new action.State(); + const actionSequence = { + type: "key", + id: "keyboard", + actions: [{ type: "keyDown", value: "\uE004" }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + + equal(actionItem.type, "key"); + equal(actionItem.id, "keyboard"); + equal(actionItem.subtype, "keyDown"); + equal(actionItem.value, "\ue004"); +}); + +add_task(function test_processInputSourceActionSequenceValidation() { + checkFromJSONErrors( + [{ type: "swim", subtype: "pause", id: "some id" }], + /Expected known action type/, + "actionSequence type: swim" + ); + + checkFromJSONErrors( + [{ type: "none", subtype: "pause", id: -1 }], + /Expected "id" to be a string/, + "actionSequence id: -1" + ); + + checkFromJSONErrors( + [{ type: "none", subtype: "pause", id: undefined }], + /Expected "id" to be a string/, + "actionSequence id: undefined" + ); + + const state = new action.State(); + const actionSequence = [ + { type: "none", subtype: "pause", id: "some_id", actions: -1 }, + ]; + const errorRegex = /Expected "actionSequence.actions" to be an array/; + const message = "actionSequence actions: -1"; + + Assert.throws( + () => action.Chain.fromJSON(state, actionSequence), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, actionSequence), + errorRegex, + message + ); +}); + +add_task(function test_processInputSourceActionSequence() { + const state = new action.State(); + const actionItem = { type: "pause", duration: 5 }; + const actionSequence = { + type: "none", + id: "some id", + actions: [actionItem], + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "none"); + equal(tickActions[0].subtype, "pause"); + equal(tickActions[0].duration, 5); + equal(tickActions[0].id, "some id"); +}); + +add_task(function test_processInputSourceActionSequencePointer() { + const state = new action.State(); + const actionItem = { type: "pointerDown", button: 1 }; + const actionSequence = { + type: "pointer", + id: "9", + actions: [actionItem], + parameters: { + pointerType: "mouse", // TODO "pen" + }, + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "pointer"); + equal(tickActions[0].subtype, "pointerDown"); + equal(tickActions[0].button, 1); + equal(tickActions[0].id, "9"); + const inputSource = state.getInputSource(tickActions[0].id); + equal(inputSource.constructor.type, "pointer"); + equal(inputSource.pointer.constructor.type, "mouse"); +}); + +add_task(function test_processInputSourceActionSequenceKey() { + const state = new action.State(); + const actionItem = { type: "keyUp", value: "a" }; + const actionSequence = { + type: "key", + id: "9", + actions: [actionItem], + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "key"); + equal(tickActions[0].subtype, "keyUp"); + equal(tickActions[0].value, "a"); + equal(tickActions[0].id, "9"); +}); + +add_task(function test_processInputSourceActionSequenceInputStateMap() { + const state = new action.State(); + const id = "1"; + const actionItem = { type: "pause", duration: 5000 }; + const actionSequence = { + type: "key", + id, + actions: [actionItem], + }; + action.Chain.fromJSON(state, [actionSequence]); + equal(state.inputStateMap.size, 1); + equal(state.inputStateMap.get(id).constructor.type, "key"); + + // Construct a different state with the same input id + const state1 = new action.State(); + const actionItem1 = { type: "pointerDown", button: 0 }; + const actionSequence1 = { + type: "pointer", + id, + actions: [actionItem1], + }; + action.Chain.fromJSON(state1, [actionSequence1]); + equal(state1.inputStateMap.size, 1); + + // Overwrite the state in the initial map with one of a different type + state.inputStateMap.set(id, state1.inputStateMap.get(id)); + equal(state.inputStateMap.get(id).constructor.type, "pointer"); + + const message = "Wrong state for input id type"; + Assert.throws( + () => action.Chain.fromJSON(state, [actionSequence]), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, [actionSequence]), + /Expected input source \[object String\] "1" to be type pointer/, + message + ); +}); + +add_task(function test_extractActionChainValidation() { + for (let actions of [-1, "a", undefined, null]) { + const state = new action.State(); + let message = `actions: ${getTypeString(actions)}`; + Assert.throws( + () => action.Chain.fromJSON(state, actions), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, actions), + /Expected "actions" to be an array/, + message + ); + } +}); + +add_task(function test_extractActionChainEmpty() { + const state = new action.State(); + deepEqual(action.Chain.fromJSON(state, []), []); +}); + +add_task(function test_extractActionChain_oneTickOneInput() { + const state = new action.State(); + const actionItem = { type: "pause", duration: 5000 }; + const actionSequence = { + type: "none", + id: "some id", + actions: [actionItem], + }; + const actionsByTick = action.Chain.fromJSON(state, [actionSequence]); + equal(1, actionsByTick.length); + equal(1, actionsByTick[0].length); + equal(actionsByTick[0][0].id, actionSequence.id); + equal(actionsByTick[0][0].type, "none"); + equal(actionsByTick[0][0].subtype, "pause"); + equal(actionsByTick[0][0].duration, actionItem.duration); +}); + +add_task(function test_extractActionChain_twoAndThreeTicks() { + const state = new action.State(); + const mouseActionItems = [ + { + type: "pointerDown", + button: 2, + }, + { + type: "pointerUp", + button: 2, + }, + ]; + const mouseActionSequence = { + type: "pointer", + id: "7", + actions: mouseActionItems, + parameters: { + pointerType: "mouse", + }, + }; + const keyActionItems = [ + { + type: "keyDown", + value: "a", + }, + { + type: "pause", + duration: 4, + }, + { + type: "keyUp", + value: "a", + }, + ]; + let keyActionSequence = { + type: "key", + id: "1", + actions: keyActionItems, + }; + let actionsByTick = action.Chain.fromJSON(state, [ + keyActionSequence, + mouseActionSequence, + ]); + // number of ticks is same as longest action sequence + equal(keyActionItems.length, actionsByTick.length); + equal(2, actionsByTick[0].length); + equal(2, actionsByTick[1].length); + equal(1, actionsByTick[2].length); + + equal(actionsByTick[2][0].id, keyActionSequence.id); + equal(actionsByTick[2][0].type, "key"); + equal(actionsByTick[2][0].subtype, "keyUp"); +}); + +add_task(function test_computeTickDuration() { + const state = new action.State(); + const expected = 8000; + const inputTickActions = [ + { type: "none", subtype: "pause", duration: 5000 }, + { type: "key", subtype: "pause", duration: 1000 }, + { type: "pointer", subtype: "pointerMove", duration: 6000, x: 0, y: 0 }, + // invalid because keyDown should not have duration, so duration should be ignored. + { type: "key", subtype: "keyDown", duration: 100000, value: "a" }, + { type: "pointer", subtype: "pause", duration: expected }, + { type: "pointer", subtype: "pointerUp", button: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(1, chain.length); + const tickActions = chain[0]; + equal(expected, tickActions.getDuration()); +}); + +add_task(function test_computeTickDuration_noDurations() { + const state = new action.State(); + const inputTickActions = [ + // invalid because keyDown should not have duration, so duration should be ignored. + { type: "key", subtype: "keyDown", duration: 100000, value: "a" }, + // undefined duration permitted + { type: "none", subtype: "pause" }, + { type: "pointer", subtype: "pointerMove", button: 0, x: 0, y: 0 }, + { type: "pointer", subtype: "pointerDown", button: 0 }, + { type: "key", subtype: "keyUp", value: "a" }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(0, chain[0].getDuration()); +}); + +add_task(function test_ClickTracker_setClick() { + const clickTracker = new ClickTracker(); + const button1 = 1; + const button2 = 2; + + clickTracker.setClick(button1); + equal(1, clickTracker.count); + + // Make sure that clicking different mouse buttons doesn't increase the count. + clickTracker.setClick(button2); + equal(1, clickTracker.count); + + clickTracker.setClick(button2); + equal(2, clickTracker.count); + + clickTracker.reset(); + equal(0, clickTracker.count); +}); + +add_task(function test_ClickTracker_reset_after_timeout() { + const clickTracker = new ClickTracker(); + + clickTracker.setClick(1); + equal(1, clickTracker.count); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => equal(0, clickTracker.count), CLICK_INTERVAL + 10); +}); + +// helpers +function getTypeString(obj) { + return Object.prototype.toString.call(obj); +} + +function checkFromJSONErrors(inputTickActions, regex, message) { + const state = new action.State(); + + if (typeof message == "undefined") { + message = `fromJSON`; + } + Assert.throws( + () => action.Chain.fromJSON(state, chainForTick(inputTickActions)), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, chainForTick(inputTickActions)), + regex, + message + ); +} + +function chainForTick(tickActions) { + const actions = []; + let lastId = 0; + for (let { type, subtype, parameters, ...props } of tickActions) { + let id; + if (!props.hasOwnProperty("id")) { + id = `${type}_${lastId++}`; + } else { + id = props.id; + delete props.id; + } + const inputAction = { type, id, actions: [{ type: subtype, ...props }] }; + if (parameters !== undefined) { + inputAction.parameters = parameters; + } + actions.push(inputAction); + } + return actions; +} diff --git a/remote/shared/webdriver/test/xpcshell/test_Assert.js b/remote/shared/webdriver/test/xpcshell/test_Assert.js new file mode 100644 index 0000000000..cf474868b6 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Assert.js @@ -0,0 +1,183 @@ +/* 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"; +/* eslint-disable no-array-constructor, no-object-constructor */ + +const { assert } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Assert.sys.mjs" +); +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); + +add_task(function test_session() { + assert.session({ id: "foo" }); + + const invalidTypes = [ + null, + undefined, + [], + {}, + { id: undefined }, + { id: null }, + { id: true }, + { id: 1 }, + { id: [] }, + { id: {} }, + ]; + + for (const invalidType of invalidTypes) { + Assert.throws(() => assert.session(invalidType), /InvalidSessionIDError/); + } + + Assert.throws(() => assert.session({ id: null }, "custom"), /custom/); +}); + +add_task(function test_platforms() { + // at least one will fail + let raised; + for (let fn of [assert.desktop, assert.mobile]) { + try { + fn(); + } catch (e) { + raised = e; + } + } + ok(raised instanceof error.UnsupportedOperationError); +}); + +add_task(function test_noUserPrompt() { + assert.noUserPrompt(null); + assert.noUserPrompt(undefined); + Assert.throws(() => assert.noUserPrompt({}), /UnexpectedAlertOpenError/); + Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/); +}); + +add_task(function test_defined() { + assert.defined({}); + Assert.throws(() => assert.defined(undefined), /InvalidArgumentError/); + Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/); +}); + +add_task(function test_number() { + assert.number(1); + assert.number(0); + assert.number(-1); + assert.number(1.2); + for (let i of ["foo", "1", {}, [], NaN, Infinity, undefined]) { + Assert.throws(() => assert.number(i), /InvalidArgumentError/); + } + + Assert.throws(() => assert.number("foo", "custom"), /custom/); +}); + +add_task(function test_callable() { + assert.callable(function () {}); + assert.callable(() => {}); + + for (let typ of [undefined, "", true, {}, []]) { + Assert.throws(() => assert.callable(typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.callable("foo", "custom"), /custom/); +}); + +add_task(function test_integer() { + assert.integer(1); + assert.integer(0); + assert.integer(-1); + Assert.throws(() => assert.integer("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.integer(1.2), /InvalidArgumentError/); + + Assert.throws(() => assert.integer("foo", "custom"), /custom/); +}); + +add_task(function test_positiveInteger() { + assert.positiveInteger(1); + assert.positiveInteger(0); + Assert.throws(() => assert.positiveInteger(-1), /InvalidArgumentError/); + Assert.throws(() => assert.positiveInteger("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.positiveInteger("foo", "custom"), /custom/); +}); + +add_task(function test_positiveNumber() { + assert.positiveNumber(1); + assert.positiveNumber(0); + assert.positiveNumber(1.1); + assert.positiveNumber(Number.MAX_VALUE); + // eslint-disable-next-line no-loss-of-precision + Assert.throws(() => assert.positiveNumber(1.8e308), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber(-1), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber(Infinity), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber("foo", "custom"), /custom/); +}); + +add_task(function test_boolean() { + assert.boolean(true); + assert.boolean(false); + Assert.throws(() => assert.boolean("false"), /InvalidArgumentError/); + Assert.throws(() => assert.boolean(undefined), /InvalidArgumentError/); + Assert.throws(() => assert.boolean(undefined, "custom"), /custom/); +}); + +add_task(function test_string() { + assert.string("foo"); + assert.string(`bar`); + Assert.throws(() => assert.string(42), /InvalidArgumentError/); + Assert.throws(() => assert.string(42, "custom"), /custom/); +}); + +add_task(function test_open() { + assert.open({ currentWindowGlobal: {} }); + + for (let typ of [null, undefined, { currentWindowGlobal: null }]) { + Assert.throws(() => assert.open(typ), /NoSuchWindowError/); + } + + Assert.throws(() => assert.open(null, "custom"), /custom/); +}); + +add_task(function test_object() { + assert.object({}); + assert.object(new Object()); + for (let typ of [42, "foo", true, null, undefined]) { + Assert.throws(() => assert.object(typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.object(null, "custom"), /custom/); +}); + +add_task(function test_in() { + assert.in("foo", { foo: 42 }); + for (let typ of [{}, 42, true, null, undefined]) { + Assert.throws(() => assert.in("foo", typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.in("foo", { bar: 42 }, "custom"), /custom/); +}); + +add_task(function test_array() { + assert.array([]); + assert.array(new Array()); + Assert.throws(() => assert.array(42), /InvalidArgumentError/); + Assert.throws(() => assert.array({}), /InvalidArgumentError/); + + Assert.throws(() => assert.array(42, "custom"), /custom/); +}); + +add_task(function test_that() { + equal(1, assert.that(n => n + 1)(1)); + Assert.throws(() => assert.that(() => false)(), /InvalidArgumentError/); + Assert.throws(() => assert.that(val => val)(false), /InvalidArgumentError/); + Assert.throws( + () => assert.that(val => val, "foo", error.SessionNotCreatedError)(false), + /SessionNotCreatedError/ + ); + + Assert.throws(() => assert.that(() => false, "custom")(), /custom/); +}); + +/* eslint-enable no-array-constructor, no-new-object */ diff --git a/remote/shared/webdriver/test/xpcshell/test_Capabilities.js b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js new file mode 100644 index 0000000000..19401dd463 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js @@ -0,0 +1,700 @@ +/* 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 { AppInfo } = ChromeUtils.importESModule( + "chrome://remote/content/shared/AppInfo.sys.mjs" +); +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); +const { + Capabilities, + mergeCapabilities, + PageLoadStrategy, + processCapabilities, + Proxy, + Timeouts, + UnhandledPromptBehavior, + validateCapabilities, +} = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs" +); + +add_task(function test_Timeouts_ctor() { + let ts = new Timeouts(); + equal(ts.implicit, 0); + equal(ts.pageLoad, 300000); + equal(ts.script, 30000); +}); + +add_task(function test_Timeouts_toString() { + equal(new Timeouts().toString(), "[object Timeouts]"); +}); + +add_task(function test_Timeouts_toJSON() { + let ts = new Timeouts(); + deepEqual(ts.toJSON(), { implicit: 0, pageLoad: 300000, script: 30000 }); +}); + +add_task(function test_Timeouts_fromJSON() { + let json = { + implicit: 0, + pageLoad: 2.0, + script: Number.MAX_SAFE_INTEGER, + }; + let ts = Timeouts.fromJSON(json); + equal(ts.implicit, json.implicit); + equal(ts.pageLoad, json.pageLoad); + equal(ts.script, json.script); +}); + +add_task(function test_Timeouts_fromJSON_unrecognised_field() { + let json = { + sessionId: "foobar", + }; + try { + Timeouts.fromJSON(json); + } catch (e) { + equal(e.name, error.InvalidArgumentError.name); + equal(e.message, "Unrecognised timeout: sessionId"); + } +}); + +add_task(function test_Timeouts_fromJSON_invalid_types() { + for (let value of [null, [], {}, false, "10", 2.5]) { + Assert.throws( + () => Timeouts.fromJSON({ implicit: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(function test_Timeouts_fromJSON_bounds() { + for (let value of [-1, Number.MAX_SAFE_INTEGER + 1]) { + Assert.throws( + () => Timeouts.fromJSON({ script: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(function test_PageLoadStrategy() { + equal(PageLoadStrategy.None, "none"); + equal(PageLoadStrategy.Eager, "eager"); + equal(PageLoadStrategy.Normal, "normal"); +}); + +add_task(function test_Proxy_ctor() { + let p = new Proxy(); + let props = [ + "proxyType", + "httpProxy", + "sslProxy", + "socksProxy", + "socksVersion", + "proxyAutoconfigUrl", + ]; + for (let prop of props) { + ok(prop in p, `${prop} in ${JSON.stringify(props)}`); + equal(p[prop], null); + } +}); + +add_task(function test_Proxy_init() { + let p = new Proxy(); + + // no changed made, and 5 (system) is default + equal(p.init(), false); + equal(Services.prefs.getIntPref("network.proxy.type"), 5); + + // pac + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "http://localhost:1234"; + ok(p.init()); + + equal(Services.prefs.getIntPref("network.proxy.type"), 2); + equal( + Services.prefs.getStringPref("network.proxy.autoconfig_url"), + "http://localhost:1234" + ); + + // direct + p = new Proxy(); + p.proxyType = "direct"; + ok(p.init()); + equal(Services.prefs.getIntPref("network.proxy.type"), 0); + + // autodetect + p = new Proxy(); + p.proxyType = "autodetect"; + ok(p.init()); + equal(Services.prefs.getIntPref("network.proxy.type"), 4); + + // system + p = new Proxy(); + p.proxyType = "system"; + ok(p.init()); + equal(Services.prefs.getIntPref("network.proxy.type"), 5); + + // manual + for (let proxy of ["http", "ssl", "socks"]) { + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["foo", "bar"]; + p[`${proxy}Proxy`] = "foo"; + p[`${proxy}ProxyPort`] = 42; + if (proxy === "socks") { + p[`${proxy}Version`] = 4; + } + + ok(p.init()); + equal(Services.prefs.getIntPref("network.proxy.type"), 1); + equal( + Services.prefs.getStringPref("network.proxy.no_proxies_on"), + "foo, bar" + ); + equal(Services.prefs.getStringPref(`network.proxy.${proxy}`), "foo"); + equal(Services.prefs.getIntPref(`network.proxy.${proxy}_port`), 42); + if (proxy === "socks") { + equal(Services.prefs.getIntPref(`network.proxy.${proxy}_version`), 4); + } + } + + // empty no proxy should reset default exclustions + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = []; + ok(p.init()); + equal(Services.prefs.getStringPref("network.proxy.no_proxies_on"), ""); +}); + +add_task(function test_Proxy_toString() { + equal(new Proxy().toString(), "[object Proxy]"); +}); + +add_task(function test_Proxy_toJSON() { + let p = new Proxy(); + deepEqual(p.toJSON(), {}); + + // autoconfig url + p = new Proxy(); + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "foo"; + deepEqual(p.toJSON(), { proxyType: "pac", proxyAutoconfigUrl: "foo" }); + + // manual proxy + p = new Proxy(); + p.proxyType = "manual"; + deepEqual(p.toJSON(), { proxyType: "manual" }); + + for (let proxy of ["httpProxy", "sslProxy", "socksProxy"]) { + let expected = { proxyType: "manual" }; + + p = new Proxy(); + p.proxyType = "manual"; + + if (proxy == "socksProxy") { + p.socksVersion = 5; + expected.socksVersion = 5; + } + + // without port + p[proxy] = "foo"; + expected[proxy] = "foo"; + deepEqual(p.toJSON(), expected); + + // with port + p[proxy] = "foo"; + p[`${proxy}Port`] = 0; + expected[proxy] = "foo:0"; + deepEqual(p.toJSON(), expected); + + p[`${proxy}Port`] = 42; + expected[proxy] = "foo:42"; + deepEqual(p.toJSON(), expected); + + // add brackets for IPv6 address as proxy hostname + p[proxy] = "2001:db8::1"; + p[`${proxy}Port`] = 42; + expected[proxy] = "foo:42"; + expected[proxy] = "[2001:db8::1]:42"; + deepEqual(p.toJSON(), expected); + } + + // noProxy: add brackets for IPv6 address + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["2001:db8::1"]; + let expected = { proxyType: "manual", noProxy: "[2001:db8::1]" }; + deepEqual(p.toJSON(), expected); +}); + +add_task(function test_Proxy_fromJSON() { + let p = new Proxy(); + deepEqual(p, Proxy.fromJSON(undefined)); + deepEqual(p, Proxy.fromJSON(null)); + + for (let typ of [true, 42, "foo", []]) { + Assert.throws(() => Proxy.fromJSON(typ), /InvalidArgumentError/); + } + + // must contain a valid proxyType + Assert.throws(() => Proxy.fromJSON({}), /InvalidArgumentError/); + Assert.throws( + () => Proxy.fromJSON({ proxyType: "foo" }), + /InvalidArgumentError/ + ); + + // autoconfig url + for (let url of [true, 42, [], {}]) { + Assert.throws( + () => Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: url }), + /InvalidArgumentError/ + ); + } + + p = new Proxy(); + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "foo"; + deepEqual(p, Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: "foo" })); + + // manual proxy + p = new Proxy(); + p.proxyType = "manual"; + deepEqual(p, Proxy.fromJSON({ proxyType: "manual" })); + + for (let proxy of ["httpProxy", "sslProxy", "socksProxy"]) { + let manual = { proxyType: "manual" }; + + // invalid hosts + for (let host of [ + true, + 42, + [], + {}, + null, + "http://foo", + "foo:-1", + "foo:65536", + "foo/test", + "foo#42", + "foo?foo=bar", + "2001:db8::1", + ]) { + manual[proxy] = host; + Assert.throws(() => Proxy.fromJSON(manual), /InvalidArgumentError/); + } + + p = new Proxy(); + p.proxyType = "manual"; + if (proxy == "socksProxy") { + manual.socksVersion = 5; + p.socksVersion = 5; + } + + let host_map = { + "foo:1": { hostname: "foo", port: 1 }, + "foo:21": { hostname: "foo", port: 21 }, + "foo:80": { hostname: "foo", port: 80 }, + "foo:443": { hostname: "foo", port: 443 }, + "foo:65535": { hostname: "foo", port: 65535 }, + "127.0.0.1:42": { hostname: "127.0.0.1", port: 42 }, + "[2001:db8::1]:42": { hostname: "2001:db8::1", port: "42" }, + }; + + // valid proxy hosts with port + for (let host in host_map) { + manual[proxy] = host; + + p[`${proxy}`] = host_map[host].hostname; + p[`${proxy}Port`] = host_map[host].port; + + deepEqual(p, Proxy.fromJSON(manual)); + } + + // Without a port the default port of the scheme is used + for (let host of ["foo", "foo:"]) { + manual[proxy] = host; + + // For socks no default port is available + p[proxy] = `foo`; + if (proxy === "socksProxy") { + p[`${proxy}Port`] = null; + } else { + let default_ports = { httpProxy: 80, sslProxy: 443 }; + + p[`${proxy}Port`] = default_ports[proxy]; + } + + deepEqual(p, Proxy.fromJSON(manual)); + } + } + + // missing required socks version + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", socksProxy: "foo:1234" }), + /InvalidArgumentError/ + ); + + // Bug 1703805: Since Firefox 90 ftpProxy is no longer supported + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", ftpProxy: "foo:21" }), + /InvalidArgumentError/ + ); + + // noProxy: invalid settings + for (let noProxy of [true, 42, {}, null, "foo", [true], [42], [{}], [null]]) { + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", noProxy }), + /InvalidArgumentError/ + ); + } + + // noProxy: valid settings + p = new Proxy(); + p.proxyType = "manual"; + for (let noProxy of [[], ["foo"], ["foo", "bar"], ["127.0.0.1"]]) { + let manual = { proxyType: "manual", noProxy }; + p.noProxy = noProxy; + deepEqual(p, Proxy.fromJSON(manual)); + } + + // noProxy: IPv6 needs brackets removed + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["2001:db8::1"]; + let manual = { proxyType: "manual", noProxy: ["[2001:db8::1]"] }; + deepEqual(p, Proxy.fromJSON(manual)); +}); + +add_task(function test_UnhandledPromptBehavior() { + equal(UnhandledPromptBehavior.Accept, "accept"); + equal(UnhandledPromptBehavior.AcceptAndNotify, "accept and notify"); + equal(UnhandledPromptBehavior.Dismiss, "dismiss"); + equal(UnhandledPromptBehavior.DismissAndNotify, "dismiss and notify"); + equal(UnhandledPromptBehavior.Ignore, "ignore"); +}); + +add_task(function test_Capabilities_ctor() { + let caps = new Capabilities(); + ok(caps.has("browserName")); + ok(caps.has("browserVersion")); + ok(caps.has("platformName")); + ok(["linux", "mac", "windows", "android"].includes(caps.get("platformName"))); + equal(PageLoadStrategy.Normal, caps.get("pageLoadStrategy")); + equal(false, caps.get("acceptInsecureCerts")); + ok(caps.get("timeouts") instanceof Timeouts); + ok(caps.get("proxy") instanceof Proxy); + equal(caps.get("setWindowRect"), !AppInfo.isAndroid); + equal(caps.get("strictFileInteractability"), false); + equal(caps.get("webSocketUrl"), null); + + equal(false, caps.get("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")); + equal(true, caps.get("moz:webdriverClick")); + + // No longer supported capabilities + ok(!caps.has("moz:useNonSpecCompliantPointerOrigin")); +}); + +add_task(function test_Capabilities_toString() { + equal("[object Capabilities]", new Capabilities().toString()); +}); + +add_task(function test_Capabilities_toJSON() { + let caps = new Capabilities(); + let json = caps.toJSON(); + + equal(caps.get("browserName"), json.browserName); + equal(caps.get("browserVersion"), json.browserVersion); + equal(caps.get("platformName"), json.platformName); + equal(caps.get("pageLoadStrategy"), json.pageLoadStrategy); + equal(caps.get("acceptInsecureCerts"), json.acceptInsecureCerts); + deepEqual(caps.get("proxy").toJSON(), json.proxy); + deepEqual(caps.get("timeouts").toJSON(), json.timeouts); + equal(caps.get("setWindowRect"), json.setWindowRect); + equal(caps.get("strictFileInteractability"), json.strictFileInteractability); + equal(caps.get("webSocketUrl"), json.webSocketUrl); + + equal(caps.get("moz:accessibilityChecks"), json["moz:accessibilityChecks"]); + equal(caps.get("moz:buildID"), json["moz:buildID"]); + equal(caps.get("moz:debuggerAddress"), json["moz:debuggerAddress"]); + equal(caps.get("moz:platformVersion"), json["moz:platformVersion"]); + equal(caps.get("moz:processID"), json["moz:processID"]); + equal(caps.get("moz:profile"), json["moz:profile"]); + equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]); +}); + +add_task(function test_Capabilities_fromJSON() { + const { fromJSON } = Capabilities; + + // plain + for (let typ of [{}, null, undefined]) { + ok(fromJSON(typ).has("browserName")); + } + + // matching + let caps = new Capabilities(); + + caps = fromJSON({ acceptInsecureCerts: true }); + equal(true, caps.get("acceptInsecureCerts")); + caps = fromJSON({ acceptInsecureCerts: false }); + equal(false, caps.get("acceptInsecureCerts")); + + for (let strategy of Object.values(PageLoadStrategy)) { + caps = fromJSON({ pageLoadStrategy: strategy }); + equal(strategy, caps.get("pageLoadStrategy")); + } + + let proxyConfig = { proxyType: "manual" }; + caps = fromJSON({ proxy: proxyConfig }); + equal("manual", caps.get("proxy").proxyType); + + let timeoutsConfig = { implicit: 123 }; + caps = fromJSON({ timeouts: timeoutsConfig }); + equal(123, caps.get("timeouts").implicit); + + caps = fromJSON({ strictFileInteractability: false }); + equal(false, caps.get("strictFileInteractability")); + caps = fromJSON({ strictFileInteractability: true }); + equal(true, caps.get("strictFileInteractability")); + + caps = fromJSON({ webSocketUrl: true }); + equal(true, caps.get("webSocketUrl")); + + caps = fromJSON({ "webauthn:virtualAuthenticators": true }); + equal(true, caps.get("webauthn:virtualAuthenticators")); + caps = fromJSON({ "webauthn:virtualAuthenticators": false }); + equal(false, caps.get("webauthn:virtualAuthenticators")); + Assert.throws( + () => fromJSON({ "webauthn:virtualAuthenticators": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "webauthn:extension:uvm": true }); + equal(true, caps.get("webauthn:extension:uvm")); + caps = fromJSON({ "webauthn:extension:uvm": false }); + equal(false, caps.get("webauthn:extension:uvm")); + Assert.throws( + () => fromJSON({ "webauthn:extension:uvm": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "webauthn:extension:prf": true }); + equal(true, caps.get("webauthn:extension:prf")); + caps = fromJSON({ "webauthn:extension:prf": false }); + equal(false, caps.get("webauthn:extension:prf")); + Assert.throws( + () => fromJSON({ "webauthn:extension:prf": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "webauthn:extension:largeBlob": true }); + equal(true, caps.get("webauthn:extension:largeBlob")); + caps = fromJSON({ "webauthn:extension:largeBlob": false }); + equal(false, caps.get("webauthn:extension:largeBlob")); + Assert.throws( + () => fromJSON({ "webauthn:extension:largeBlob": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "webauthn:extension:credBlob": true }); + equal(true, caps.get("webauthn:extension:credBlob")); + caps = fromJSON({ "webauthn:extension:credBlob": false }); + equal(false, caps.get("webauthn:extension:credBlob")); + Assert.throws( + () => fromJSON({ "webauthn:extension:credBlob": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "moz:accessibilityChecks": true }); + equal(true, caps.get("moz:accessibilityChecks")); + caps = fromJSON({ "moz:accessibilityChecks": false }); + equal(false, caps.get("moz:accessibilityChecks")); + + // capability is always populated with null if remote agent is not listening + caps = fromJSON({}); + equal(null, caps.get("moz:debuggerAddress")); + caps = fromJSON({ "moz:debuggerAddress": "foo" }); + equal(null, caps.get("moz:debuggerAddress")); + caps = fromJSON({ "moz:debuggerAddress": true }); + equal(null, caps.get("moz:debuggerAddress")); + + caps = fromJSON({ "moz:webdriverClick": true }); + equal(true, caps.get("moz:webdriverClick")); + caps = fromJSON({ "moz:webdriverClick": false }); + equal(false, caps.get("moz:webdriverClick")); + + // No longer supported capabilities + Assert.throws( + () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": false }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true }), + /InvalidArgumentError/ + ); +}); + +add_task(function test_mergeCapabilities() { + // Shadowed values. + Assert.throws( + () => + mergeCapabilities( + { acceptInsecureCerts: true }, + { acceptInsecureCerts: false } + ), + /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:webdriverClick": "foo" }, + { "moz:webdriverClick": 1 }, + { "moz:useNonSpecCompliantPointerOrigin": false }, + { "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: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" }], + }, + }) + ); +}); + +// use Proxy.toJSON to test marshal +add_task(function test_marshal() { + let proxy = new Proxy(); + + // drop empty fields + deepEqual({}, proxy.toJSON()); + proxy.proxyType = "manual"; + deepEqual({ proxyType: "manual" }, proxy.toJSON()); + proxy.proxyType = null; + deepEqual({}, proxy.toJSON()); + proxy.proxyType = undefined; + deepEqual({}, proxy.toJSON()); + + // iterate over object literals + proxy.proxyType = { foo: "bar" }; + deepEqual({ proxyType: { foo: "bar" } }, proxy.toJSON()); + + // iterate over complex object that implement toJSON + proxy.proxyType = new Proxy(); + deepEqual({}, proxy.toJSON()); + proxy.proxyType.proxyType = "manual"; + deepEqual({ proxyType: { proxyType: "manual" } }, proxy.toJSON()); + + // drop objects with no entries + proxy.proxyType = { foo: {} }; + deepEqual({}, proxy.toJSON()); + proxy.proxyType = { foo: new Proxy() }; + deepEqual({}, proxy.toJSON()); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_Errors.js b/remote/shared/webdriver/test/xpcshell/test_Errors.js new file mode 100644 index 0000000000..22e3526039 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Errors.js @@ -0,0 +1,543 @@ +/* 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/. */ + +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); + +const errors = [ + error.WebDriverError, + + error.DetachedShadowRootError, + error.ElementClickInterceptedError, + error.ElementNotAccessibleError, + error.ElementNotInteractableError, + error.InsecureCertificateError, + error.InvalidArgumentError, + error.InvalidCookieDomainError, + error.InvalidElementStateError, + error.InvalidSelectorError, + error.InvalidSessionIDError, + error.JavaScriptError, + error.MoveTargetOutOfBoundsError, + error.NoSuchAlertError, + error.NoSuchElementError, + error.NoSuchFrameError, + error.NoSuchHandleError, + error.NoSuchInterceptError, + error.NoSuchNodeError, + error.NoSuchRequestError, + error.NoSuchScriptError, + error.NoSuchShadowRootError, + error.NoSuchWindowError, + error.ScriptTimeoutError, + error.SessionNotCreatedError, + error.StaleElementReferenceError, + error.TimeoutError, + error.UnableToSetCookieError, + error.UnexpectedAlertOpenError, + error.UnknownCommandError, + error.UnknownError, + error.UnsupportedOperationError, +]; + +function notok(condition) { + ok(!condition); +} + +add_task(function test_isError() { + notok(error.isError(null)); + notok(error.isError([])); + notok(error.isError(new Date())); + + ok(error.isError(new Components.Exception())); + ok(error.isError(new Error())); + ok(error.isError(new EvalError())); + ok(error.isError(new InternalError())); + ok(error.isError(new RangeError())); + ok(error.isError(new ReferenceError())); + ok(error.isError(new SyntaxError())); + ok(error.isError(new TypeError())); + ok(error.isError(new URIError())); + + errors.forEach(err => ok(error.isError(new err()))); +}); + +add_task(function test_isWebDriverError() { + notok(error.isWebDriverError(new Components.Exception())); + notok(error.isWebDriverError(new Error())); + notok(error.isWebDriverError(new EvalError())); + notok(error.isWebDriverError(new InternalError())); + notok(error.isWebDriverError(new RangeError())); + notok(error.isWebDriverError(new ReferenceError())); + notok(error.isWebDriverError(new SyntaxError())); + notok(error.isWebDriverError(new TypeError())); + notok(error.isWebDriverError(new URIError())); + + errors.forEach(err => ok(error.isWebDriverError(new err()))); +}); + +add_task(function test_wrap() { + // webdriver-derived errors should not be wrapped + errors.forEach(err => { + const unwrappedError = new err("foo"); + const wrappedError = error.wrap(unwrappedError); + + ok(wrappedError instanceof error.WebDriverError); + ok(wrappedError instanceof err); + equal(wrappedError.name, unwrappedError.name); + equal(wrappedError.status, unwrappedError.status); + equal(wrappedError.message, "foo"); + }); + + // JS errors should be wrapped in UnknownError and retain their type + // as part of the message field. + const jsErrors = [ + Error, + EvalError, + InternalError, + RangeError, + ReferenceError, + SyntaxError, + TypeError, + URIError, + ]; + + jsErrors.forEach(err => { + const originalError = new err("foo"); + const wrappedError = error.wrap(originalError); + + ok(wrappedError instanceof error.UnknownError); + equal(wrappedError.name, "UnknownError"); + equal(wrappedError.status, "unknown error"); + equal(wrappedError.message, `${originalError.name}: foo`); + }); +}); + +add_task(function test_stringify() { + equal("<unprintable error>", error.stringify()); + equal("<unprintable error>", error.stringify("foo")); + equal("[object Object]", error.stringify({})); + equal("[object Object]\nfoo", error.stringify({ stack: "foo" })); + equal("Error: foo", error.stringify(new Error("foo")).split("\n")[0]); + + errors.forEach(err => { + const e = new err("foo"); + + equal(`${e.name}: foo`, error.stringify(e).split("\n")[0]); + }); +}); + +add_task(function test_constructor_from_error() { + const data = { a: 3, b: "bar" }; + const origError = new error.WebDriverError("foo", data); + + errors.forEach(err => { + const newError = new err(origError); + + ok(newError instanceof err); + equal(newError.message, origError.message); + equal(newError.stack, origError.stack); + equal(newError.data, origError.data); + }); +}); + +add_task(function test_stack() { + equal("string", typeof error.stack()); + ok(error.stack().includes("test_stack")); + ok(!error.stack().includes("add_task")); +}); + +add_task(function test_toJSON() { + errors.forEach(err => { + const e0 = new err(); + const e0_json = e0.toJSON(); + equal(e0_json.error, e0.status); + equal(e0_json.message, ""); + equal(e0_json.stacktrace, e0.stack); + equal(e0_json.data, undefined); + + // message property + const e1 = new err("a"); + const e1_json = e1.toJSON(); + + equal(e1_json.message, e1.message); + equal(e1_json.stacktrace, e1.stack); + equal(e1_json.data, undefined); + + // message and optional data property + const data = { a: 3, b: "bar" }; + const e2 = new err("foo", data); + const e2_json = e2.toJSON(); + + equal(e2.status, e2_json.error); + equal(e2.message, e2_json.message); + equal(e2_json.data, data); + }); +}); + +add_task(function test_fromJSON() { + errors.forEach(err => { + Assert.throws( + () => err.fromJSON({ error: "foo" }), + /Not of WebDriverError descent/ + ); + Assert.throws( + () => err.fromJSON({ error: "Error" }), + /Not of WebDriverError descent/ + ); + Assert.throws(() => err.fromJSON({}), /Undeserialisable error type/); + Assert.throws(() => err.fromJSON(undefined), /TypeError/); + + // message and stack + const e1 = new err("1"); + const e1_json = { error: e1.status, message: "3", stacktrace: "4" }; + const e1_fromJSON = error.WebDriverError.fromJSON(e1_json); + + ok(e1_fromJSON instanceof error.WebDriverError); + ok(e1_fromJSON instanceof err); + equal(e1_fromJSON.name, e1.name); + equal(e1_fromJSON.status, e1_json.error); + equal(e1_fromJSON.message, e1_json.message); + equal(e1_fromJSON.stack, e1_json.stacktrace); + + // message and optional data + const e2_data = { a: 3, b: "bar" }; + const e2 = new err("1", e2_data); + const e2_json = { error: e1.status, message: "3", data: e2_data }; + const e2_fromJSON = error.WebDriverError.fromJSON(e2_json); + + ok(e2_fromJSON instanceof error.WebDriverError); + ok(e2_fromJSON instanceof err); + equal(e2_fromJSON.name, e2.name); + equal(e2_fromJSON.status, e2_json.error); + equal(e2_fromJSON.message, e2_json.message); + equal(e2_fromJSON.data, e2_json.data); + + // parity with toJSON + const e3_data = { a: 3, b: "bar" }; + const e3 = new err("1", e3_data); + const e3_json = e3.toJSON(); + const e3_fromJSON = error.WebDriverError.fromJSON(e3_json); + + equal(e3_json.error, e3_fromJSON.status); + equal(e3_json.message, e3_fromJSON.message); + equal(e3_json.stacktrace, e3_fromJSON.stack); + }); +}); + +add_task(function test_WebDriverError() { + let err = new error.WebDriverError("foo"); + equal("WebDriverError", err.name); + equal("foo", err.message); + equal("webdriver error", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_DetachedShadowRootError() { + let err = new error.DetachedShadowRootError("foo"); + equal("DetachedShadowRootError", err.name); + equal("foo", err.message); + equal("detached shadow root", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ElementClickInterceptedError() { + let otherEl = { + hasAttribute: attr => attr in otherEl, + getAttribute: attr => (attr in otherEl ? otherEl[attr] : null), + nodeType: 1, + localName: "a", + }; + let obscuredEl = { + hasAttribute: attr => attr in obscuredEl, + getAttribute: attr => (attr in obscuredEl ? obscuredEl[attr] : null), + nodeType: 1, + localName: "b", + ownerDocument: { + elementFromPoint() { + return otherEl; + }, + }, + style: { + pointerEvents: "auto", + }, + }; + + let err1 = new error.ElementClickInterceptedError( + undefined, + undefined, + obscuredEl, + { x: 1, y: 2 } + ); + equal("ElementClickInterceptedError", err1.name); + equal( + "Element <b> is not clickable at point (1,2) " + + "because another element <a> obscures it", + err1.message + ); + equal("element click intercepted", err1.status); + ok(err1 instanceof error.WebDriverError); + + obscuredEl.style.pointerEvents = "none"; + let err2 = new error.ElementClickInterceptedError( + undefined, + undefined, + obscuredEl, + { x: 1, y: 2 } + ); + equal( + "Element <b> is not clickable at point (1,2) " + + "because it does not have pointer events enabled, " + + "and element <a> would receive the click instead", + err2.message + ); +}); + +add_task(function test_ElementNotAccessibleError() { + let err = new error.ElementNotAccessibleError("foo"); + equal("ElementNotAccessibleError", err.name); + equal("foo", err.message); + equal("element not accessible", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ElementNotInteractableError() { + let err = new error.ElementNotInteractableError("foo"); + equal("ElementNotInteractableError", err.name); + equal("foo", err.message); + equal("element not interactable", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InsecureCertificateError() { + let err = new error.InsecureCertificateError("foo"); + equal("InsecureCertificateError", err.name); + equal("foo", err.message); + equal("insecure certificate", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidArgumentError() { + let err = new error.InvalidArgumentError("foo"); + equal("InvalidArgumentError", err.name); + equal("foo", err.message); + equal("invalid argument", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidCookieDomainError() { + let err = new error.InvalidCookieDomainError("foo"); + equal("InvalidCookieDomainError", err.name); + equal("foo", err.message); + equal("invalid cookie domain", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidElementStateError() { + let err = new error.InvalidElementStateError("foo"); + equal("InvalidElementStateError", err.name); + equal("foo", err.message); + equal("invalid element state", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidSelectorError() { + let err = new error.InvalidSelectorError("foo"); + equal("InvalidSelectorError", err.name); + equal("foo", err.message); + equal("invalid selector", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidSessionIDError() { + let err = new error.InvalidSessionIDError("foo"); + equal("InvalidSessionIDError", err.name); + equal("foo", err.message); + equal("invalid session id", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_JavaScriptError() { + let err = new error.JavaScriptError("foo"); + equal("JavaScriptError", err.name); + equal("foo", err.message); + equal("javascript error", err.status); + ok(err instanceof error.WebDriverError); + + equal("", new error.JavaScriptError(undefined).message); + + let superErr = new RangeError("foo"); + let inheritedErr = new error.JavaScriptError(superErr); + equal("RangeError: foo", inheritedErr.message); + equal(superErr.stack, inheritedErr.stack); +}); + +add_task(function test_MoveTargetOutOfBoundsError() { + let err = new error.MoveTargetOutOfBoundsError("foo"); + equal("MoveTargetOutOfBoundsError", err.name); + equal("foo", err.message); + equal("move target out of bounds", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchAlertError() { + let err = new error.NoSuchAlertError("foo"); + equal("NoSuchAlertError", err.name); + equal("foo", err.message); + equal("no such alert", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchElementError() { + let err = new error.NoSuchElementError("foo"); + equal("NoSuchElementError", err.name); + equal("foo", err.message); + equal("no such element", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchFrameError() { + let err = new error.NoSuchFrameError("foo"); + equal("NoSuchFrameError", err.name); + equal("foo", err.message); + equal("no such frame", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchHandleError() { + let err = new error.NoSuchHandleError("foo"); + equal("NoSuchHandleError", err.name); + equal("foo", err.message); + equal("no such handle", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchInterceptError() { + let err = new error.NoSuchInterceptError("foo"); + equal("NoSuchInterceptError", err.name); + equal("foo", err.message); + equal("no such intercept", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchNodeError() { + let err = new error.NoSuchNodeError("foo"); + equal("NoSuchNodeError", err.name); + equal("foo", err.message); + equal("no such node", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchRequestError() { + let err = new error.NoSuchRequestError("foo"); + equal("NoSuchRequestError", err.name); + equal("foo", err.message); + equal("no such request", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchScriptError() { + let err = new error.NoSuchScriptError("foo"); + equal("NoSuchScriptError", err.name); + equal("foo", err.message); + equal("no such script", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchShadowRootError() { + let err = new error.NoSuchShadowRootError("foo"); + equal("NoSuchShadowRootError", err.name); + equal("foo", err.message); + equal("no such shadow root", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchUserContextError() { + let err = new error.NoSuchUserContextError("foo"); + equal("NoSuchUserContextError", err.name); + equal("foo", err.message); + equal("no such user context", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchWindowError() { + let err = new error.NoSuchWindowError("foo"); + equal("NoSuchWindowError", err.name); + equal("foo", err.message); + equal("no such window", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ScriptTimeoutError() { + let err = new error.ScriptTimeoutError("foo"); + equal("ScriptTimeoutError", err.name); + equal("foo", err.message); + equal("script timeout", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_SessionNotCreatedError() { + let err = new error.SessionNotCreatedError("foo"); + equal("SessionNotCreatedError", err.name); + equal("foo", err.message); + equal("session not created", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_StaleElementReferenceError() { + let err = new error.StaleElementReferenceError("foo"); + equal("StaleElementReferenceError", err.name); + equal("foo", err.message); + equal("stale element reference", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_TimeoutError() { + let err = new error.TimeoutError("foo"); + equal("TimeoutError", err.name); + equal("foo", err.message); + equal("timeout", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnableToSetCookieError() { + let err = new error.UnableToSetCookieError("foo"); + equal("UnableToSetCookieError", err.name); + equal("foo", err.message); + equal("unable to set cookie", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnexpectedAlertOpenError() { + let err = new error.UnexpectedAlertOpenError("foo"); + equal("UnexpectedAlertOpenError", err.name); + equal("foo", err.message); + equal("unexpected alert open", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnknownCommandError() { + let err = new error.UnknownCommandError("foo"); + equal("UnknownCommandError", err.name); + equal("foo", err.message); + equal("unknown command", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnknownError() { + let err = new error.UnknownError("foo"); + equal("UnknownError", err.name); + equal("foo", err.message); + equal("unknown error", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnsupportedOperationError() { + let err = new error.UnsupportedOperationError("foo"); + equal("UnsupportedOperationError", err.name); + equal("foo", err.message); + equal("unsupported operation", err.status); + ok(err instanceof error.WebDriverError); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_NodeCache.js b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js new file mode 100644 index 0000000000..4efe9fba3a --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js @@ -0,0 +1,265 @@ +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + + browser.document.body.innerHTML = ` + <div id="foo" style="margin: 50px"> + <iframe></iframe> + <video></video> + <svg xmlns="http://www.w3.org/2000/svg"></svg> + <textarea></textarea> + </div> + <div id="with-comment"><!-- Comment --></div> + `; + + const divEl = browser.document.querySelector("div"); + const svgEl = browser.document.querySelector("svg"); + const textareaEl = browser.document.querySelector("textarea"); + const videoEl = browser.document.querySelector("video"); + + const iframeEl = browser.document.querySelector("iframe"); + const childEl = iframeEl.contentDocument.createElement("div"); + iframeEl.contentDocument.body.appendChild(childEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + return { + browser, + nodeCache: new NodeCache(), + childEl, + divEl, + iframeEl, + shadowRoot, + seenNodeIds: new Map(), + svgEl, + textareaEl, + videoEl, + }; +} + +add_task(function getOrCreateNodeReference_invalid() { + const { nodeCache, seenNodeIds } = setupTest(); + + const invalidValues = [null, undefined, "foo", 42, true, [], {}]; + + for (const value of invalidValues) { + info(`Testing value: ${value}`); + Assert.throws( + () => nodeCache.getOrCreateNodeReference(value, seenNodeIds), + /TypeError/ + ); + } +}); + +add_task(function getOrCreateNodeReference_supportedNodeTypes() { + const { browser, divEl, nodeCache, seenNodeIds } = setupTest(); + + // Bug 1820734: No ownerGlobal is available in XPCShell tests + // const xmlDocument = new DOMParser().parseFromString( + // "<xml></xml>", + // "application/xml" + // ); + + const values = [ + { node: divEl, type: Node.ELEMENT_NODE }, + { node: divEl.attributes[0], type: Node.ATTRIBUTE_NODE }, + { node: browser.document.createTextNode("foo"), type: Node.TEXT_NODE }, + // Bug 1820734: No ownerGlobal is available in XPCShell tests + // { + // node: xmlDocument.createCDATASection("foo"), + // type: Node.CDATA_SECTION_NODE, + // }, + { + node: browser.document.createProcessingInstruction( + "xml-stylesheet", + "href='foo.css'" + ), + type: Node.PROCESSING_INSTRUCTION_NODE_NODE, + }, + { node: browser.document.createComment("foo"), type: Node.COMMENT_NODE }, + { node: browser.document, type: Node.Document_NODE }, + { + node: browser.document.implementation.createDocumentType( + "foo", + "bar", + "dtd" + ), + type: Node.DOCUMENT_TYPE_NODE_NODE, + }, + { + node: browser.document.createDocumentFragment(), + type: Node.DOCUMENT_FRAGMENT_NODE, + }, + ]; + + values.forEach((value, index) => { + info(`Testing value: ${value.type}`); + const nodeRef = nodeCache.getOrCreateNodeReference(value.node, seenNodeIds); + equal(nodeCache.size, index + 1); + equal(typeof nodeRef, "string"); + ok(seenNodeIds.get(browser.browsingContext).includes(nodeRef)); + }); +}); + +add_task(function getOrCreateNodeReference_referenceAlreadyCreated() { + const { browser, divEl, nodeCache, seenNodeIds } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + const divElRefOther = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + + equal(divElRefOther, divElRef); + equal(nodeCache.size, 1); + equal(seenNodeIds.size, 1); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef)); +}); + +add_task(function getOrCreateNodeReference_differentReference() { + const { browser, divEl, nodeCache, seenNodeIds, shadowRoot } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + equal(nodeCache.size, 1); + equal(seenNodeIds.size, 1); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef)); + + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + equal(nodeCache.size, 2); + equal(seenNodeIds.size, 1); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef)); + ok(seenNodeIds.get(browser.browsingContext).includes(shadowRootRef)); + + notEqual(divElRef, shadowRootRef); +}); + +add_task(function getOrCreateNodeReference_differentReferencePerNodeCache() { + const { browser, divEl, nodeCache, seenNodeIds } = setupTest(); + const nodeCache2 = new NodeCache(); + + const divElRef1 = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + const divElRef2 = nodeCache2.getOrCreateNodeReference(divEl, seenNodeIds); + + notEqual(divElRef1, divElRef2); + equal( + nodeCache.getNode(browser.browsingContext, divElRef1), + nodeCache2.getNode(browser.browsingContext, divElRef2) + ); + + equal(seenNodeIds.size, 1); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef1)); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef2)); + + equal(nodeCache.getNode(browser.browsingContext, divElRef2), null); +}); + +add_task(function clear() { + const { browser, divEl, nodeCache, seenNodeIds, svgEl } = setupTest(); + + nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + nodeCache.getOrCreateNodeReference(svgEl, seenNodeIds); + equal(nodeCache.size, 2); + equal(seenNodeIds.size, 1); + + // Clear requires explicit arguments. + Assert.throws(() => nodeCache.clear(), /Error/); + + // Clear references for a different browsing context + const browser2 = Services.appShell.createWindowlessBrowser(false); + const imgEl = browser2.document.createElement("img"); + const imgElRef = nodeCache.getOrCreateNodeReference(imgEl, seenNodeIds); + equal(nodeCache.size, 3); + equal(seenNodeIds.size, 2); + + nodeCache.clear({ browsingContext: browser.browsingContext }); + equal(nodeCache.size, 1); + equal(nodeCache.getNode(browser2.browsingContext, imgElRef), imgEl); + + // Clear all references + nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + equal(nodeCache.size, 2); + equal(seenNodeIds.size, 2); + + nodeCache.clear({ all: true }); + equal(nodeCache.size, 0); +}); + +add_task(function getNode_multiple_nodes() { + const { browser, divEl, nodeCache, seenNodeIds, svgEl } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + const svgElRef = nodeCache.getOrCreateNodeReference(svgEl, seenNodeIds); + + equal(nodeCache.getNode(browser.browsingContext, svgElRef), svgEl); + equal(nodeCache.getNode(browser.browsingContext, divElRef), divEl); +}); + +add_task(function getNode_differentBrowsingContextInSameGroup() { + const { iframeEl, divEl, nodeCache, seenNodeIds } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + equal(nodeCache.size, 1); + + equal( + nodeCache.getNode(iframeEl.contentWindow.browsingContext, divElRef), + divEl + ); +}); + +add_task(function getNode_differentBrowsingContextInOtherGroup() { + const { divEl, nodeCache, seenNodeIds } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + equal(nodeCache.size, 1); + + const browser2 = Services.appShell.createWindowlessBrowser(false); + equal(nodeCache.getNode(browser2.browsingContext, divElRef), null); +}); + +add_task(async function getNode_nodeDeleted() { + const { browser, nodeCache, seenNodeIds } = setupTest(); + let el = browser.document.createElement("div"); + + const elRef = nodeCache.getOrCreateNodeReference(el, seenNodeIds); + + // Delete element and force a garbage collection + el = null; + + await doGC(); + + equal(nodeCache.getNode(browser.browsingContext, elRef), null); +}); + +add_task(function getNodeDetails_forTopBrowsingContext() { + const { browser, divEl, nodeCache, seenNodeIds } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + + const nodeDetails = nodeCache.getReferenceDetails(divElRef); + equal(nodeDetails.browserId, browser.browsingContext.browserId); + equal(nodeDetails.browsingContextGroupId, browser.browsingContext.group.id); + equal(nodeDetails.browsingContextId, browser.browsingContext.id); + ok(nodeDetails.isTopBrowsingContext); + ok(nodeDetails.nodeWeakRef); + equal(nodeDetails.nodeWeakRef.get(), divEl); +}); + +add_task(async function getNodeDetails_forChildBrowsingContext() { + const { browser, iframeEl, childEl, nodeCache, seenNodeIds } = setupTest(); + + const childElRef = nodeCache.getOrCreateNodeReference(childEl, seenNodeIds); + + const nodeDetails = nodeCache.getReferenceDetails(childElRef); + equal(nodeDetails.browserId, browser.browsingContext.browserId); + equal(nodeDetails.browsingContextGroupId, browser.browsingContext.group.id); + equal( + nodeDetails.browsingContextId, + iframeEl.contentWindow.browsingContext.id + ); + ok(!nodeDetails.isTopBrowsingContext); + ok(nodeDetails.nodeWeakRef); + equal(nodeDetails.nodeWeakRef.get(), childEl); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_Session.js b/remote/shared/webdriver/test/xpcshell/test_Session.js new file mode 100644 index 0000000000..3b3d893319 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Session.js @@ -0,0 +1,72 @@ +/* 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 { Capabilities, Timeouts } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs" +); +const { getWebDriverSessionById, WebDriverSession } = + ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Session.sys.mjs" + ); + +add_task(function test_WebDriverSession_ctor() { + const session = new WebDriverSession(); + + equal(typeof session.id, "string"); + ok(session.capabilities instanceof Capabilities); +}); + +add_task(function test_WebDriverSession_destroy() { + const session = new WebDriverSession(); + + session.destroy(); +}); + +add_task(function test_WebDriverSession_getters() { + const session = new WebDriverSession(); + + equal( + session.a11yChecks, + session.capabilities.get("moz:accessibilityChecks") + ); + equal(session.pageLoadStrategy, session.capabilities.get("pageLoadStrategy")); + equal(session.proxy, session.capabilities.get("proxy")); + equal( + session.strictFileInteractability, + session.capabilities.get("strictFileInteractability") + ); + equal(session.timeouts, session.capabilities.get("timeouts")); + equal( + session.unhandledPromptBehavior, + session.capabilities.get("unhandledPromptBehavior") + ); +}); + +add_task(function test_WebDriverSession_setters() { + const session = new WebDriverSession(); + + const timeouts = new Timeouts(); + timeouts.pageLoad = 45; + + session.timeouts = timeouts; + equal(session.timeouts, session.capabilities.get("timeouts")); +}); + +add_task(function test_getWebDriverSessionById() { + const session1 = new WebDriverSession(); + const session2 = new WebDriverSession(); + + equal(getWebDriverSessionById(session1.id), session1); + equal(getWebDriverSessionById(session2.id), session2); + + session1.destroy(); + equal(getWebDriverSessionById(session1.id), undefined); + equal(getWebDriverSessionById(session2.id), session2); + + session2.destroy(); + equal(getWebDriverSessionById(session1.id), undefined); + equal(getWebDriverSessionById(session2.id), undefined); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js new file mode 100644 index 0000000000..0e537a210f --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js @@ -0,0 +1,129 @@ +/* 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/. */ + +const { parseURLPattern } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs" +); + +add_task( + async function test_parseURLPattern_patternPattern_unescapedCharacters() { + const properties = ["protocol", "hostname", "port", "pathname", "search"]; + const values = ["*", "(", ")", "{", "}"]; + for (const property of properties) { + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", [property]: value }), + /InvalidArgumentError/ + ); + } + } + } +); + +add_task(async function test_parseURLPattern_patternPattern_protocol() { + const values = [ + "", + "http/", + "http\\*", + "http\\(", + "http\\)", + "http\\{", + "http\\}", + "http#", + "http@", + "http%", + ]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", protocol: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task( + async function test_parseURLPattern_patternPattern_unsupported_protocol() { + const values = ["ftp", "abc", "webpack"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", protocol: value }), + /UnsupportedOperationError/ + ); + } + } +); + +add_task(async function test_parseURLPattern_patternPattern_hostname() { + const values = ["", "abc/com/", "abc?com", "abc#com", "abc:com"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", hostname: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_patternPattern_port() { + const values = ["", "abcd", "-1", "80 ", "1.3", ":80", "65536"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", port: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_patternPattern_pathname() { + const values = ["path?", "path#"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", pathname: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_patternPattern_search() { + const values = ["search#"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", search: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_stringPattern_invalid_url() { + const values = ["", "invalid", "http:invalid:url", "[1::", "127.0..1"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task( + async function test_parseURLPattern_stringPattern_unescaped_characters() { + const values = ["*", "(", ")", "{", "}"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern: value }), + /InvalidArgumentError/ + ); + } + } +); + +add_task( + async function test_parseURLPattern_stringPattern_unsupported_protocol() { + const values = ["ftp://some/path", "abc:pathplaceholder", "webpack://test"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern: value }), + /UnsupportedOperationError/ + ); + } + } +); diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js new file mode 100644 index 0000000000..f4831d583f --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js @@ -0,0 +1,607 @@ +/* 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/. */ + +const { matchURLPattern, parseURLPattern } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs" +); + +// Test several variations which should match a string based http://example.com +// pattern. +add_task(async function test_matchURLPattern_url_variations() { + const pattern = parseURLPattern({ + type: "string", + pattern: "http://example.com", + }); + + const urls = [ + "http://example.com", + "http://EXAMPLE.com", + "http://user:password@example.com", + "http://example.com:80", + "http://example.com/", + "http://example.com/#some-hash", + "http:example.com", + "http:/example.com", + "http://example.com?", + "http://example.com/?", + ]; + for (const url of urls) { + ok( + matchURLPattern(pattern, url), + `url "${url}" should match pattern "http://example.com"` + ); + } + + // Test URLs close to http://example.com but which should not match. + const failingUrls = [ + "https://example.com", + "http://example.com:88", + "http://example.com/a", + "http://example.com/?abc", + ]; + for (const url of failingUrls) { + ok( + !matchURLPattern(pattern, url), + `url "${url}" should not match pattern "http://example.com"` + ); + } +}); + +add_task(async function test_matchURLPattern_stringPatterns() { + const tests = [ + { + pattern: "http://example.com", + url: "http://example.com", + match: true, + }, + { + pattern: "HTTP://example.com:80", + url: "http://example.com", + match: true, + }, + { + pattern: "http://example.com:80", + url: "http://example.com", + match: true, + }, + { + pattern: "http://example.com/path", + url: "http://example.com/path", + match: true, + }, + { + pattern: "http://example.com/PATH_CASE", + url: "http://example.com/path_case", + match: false, + }, + { + pattern: "http://example.com/path_single_segment", + url: "http://example.com/path_single_segment/", + match: false, + }, + { + pattern: "http://example.com/path", + url: "http://example.com/path_continued", + match: false, + }, + { + pattern: "http://example.com/path_two_segments/", + url: "http://example.com/path_two_segments/", + match: true, + }, + { + pattern: "http://example.com/path_two_segments/", + url: "http://example.com/path_two_segments", + match: false, + }, + { + pattern: "http://example.com/emptysearch?", + url: "http://example.com/emptysearch?", + match: true, + }, + { + pattern: "http://example.com/emptysearch?", + url: "http://example.com/emptysearch", + match: true, + }, + { + pattern: "http://example.com/emptysearch?", + url: "http://example.com/emptysearch??", + match: false, + }, + { + pattern: "http://example.com/emptysearch?", + url: "http://example.com/emptysearch?a", + match: false, + }, + { + pattern: "http://example.com/search?param", + url: "http://example.com/search?param", + match: true, + }, + { + pattern: "http://example.com/search?param", + url: "http://example.com/search?param=value", + match: false, + }, + { + pattern: "http://example.com/search?param=value", + url: "http://example.com/search?param=value", + match: true, + }, + { + pattern: "http://example.com/search?a=b&c=d", + url: "http://example.com/search?a=b&c=d", + match: true, + }, + { + pattern: "http://example.com/search?a=b&c=d", + url: "http://example.com/search?c=d&a=b", + match: false, + }, + { + pattern: "http://example.com/search?param", + url: "http://example.com/search?param#ref", + match: true, + }, + { + pattern: "http://example.com/search?param#ref", + url: "http://example.com/search?param#ref", + match: true, + }, + { + pattern: "http://example.com/search?param#ref", + url: "http://example.com/search?param", + match: true, + }, + { + pattern: "http://example.com/search?param", + url: "http://example.com/search?parameter", + match: false, + }, + { + pattern: "http://example.com/search?parameter", + url: "http://example.com/search?param", + match: false, + }, + { + pattern: "https://example.com:80", + url: "https://example.com", + match: false, + }, + { + pattern: "https://example.com:443", + url: "https://example.com", + match: true, + }, + { + pattern: "ws://example.com", + url: "ws://example.com:80", + match: true, + }, + ]; + + runMatchPatternTests(tests, "string"); +}); + +add_task(async function test_patternPatterns_no_property() { + const tests = [ + // Test protocol + { + pattern: {}, + url: "https://example.com", + match: true, + }, + { + pattern: {}, + url: "https://example.com", + match: true, + }, + { + pattern: {}, + url: "https://example.com:1234", + match: true, + }, + { + pattern: {}, + url: "https://example.com/a", + match: true, + }, + { + pattern: {}, + url: "https://example.com/a?test", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_protocol() { + const tests = [ + // Test protocol + { + pattern: { + protocol: "http", + }, + url: "http://example.com", + match: true, + }, + { + pattern: { + protocol: "HTTP", + }, + url: "http://example.com", + match: true, + }, + { + pattern: { + protocol: "http", + }, + url: "http://example.com:80", + match: true, + }, + { + pattern: { + protocol: "http", + }, + url: "http://example.com:1234", + match: true, + }, + { + pattern: { + protocol: "http", + port: "80", + }, + url: "http://example.com:80", + match: true, + }, + { + pattern: { + protocol: "http", + port: "1234", + }, + url: "http://example.com:1234", + match: true, + }, + { + pattern: { + protocol: "http", + port: "1234", + }, + url: "http://example.com", + match: false, + }, + { + pattern: { + protocol: "http", + }, + url: "https://wrong-scheme.com", + match: false, + }, + { + pattern: { + protocol: "http", + }, + url: "http://whatever.com/?search#ref", + match: true, + }, + { + pattern: { + protocol: "http", + }, + url: "http://example.com/a", + match: true, + }, + { + pattern: { + protocol: "http", + }, + url: "http://whatever.com/path?search#ref", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_port() { + const tests = [ + { + pattern: { + protocol: "http", + port: "80", + }, + url: "http://abc.com/", + match: true, + }, + { + pattern: { + port: "1234", + }, + url: "http://a.com:1234", + match: true, + }, + { + pattern: { + port: "1234", + }, + url: "https://a.com:1234", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_hostname() { + const tests = [ + { + pattern: { + hostname: "example.com", + }, + url: "http://example.com", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "http://example.com:80", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "https://example.com", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "https://example.com:443", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "ws://example.com", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "ws://example.com:80", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "http://example.com/path", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "http://example.com/?search", + match: true, + }, + { + pattern: { + hostname: "example\\{.com", + }, + url: "http://example{.com/", + match: true, + }, + { + pattern: { + hostname: "example\\{.com", + }, + url: "http://example\\{.com/", + match: false, + }, + { + pattern: { + hostname: "127.0.0.1", + }, + url: "http://127.0.0.1/", + match: true, + }, + { + pattern: { + hostname: "127.0.0.1", + }, + url: "http://127.0.0.2/", + match: false, + }, + { + pattern: { + hostname: "[2001:db8::1]", + }, + url: "http://[2001:db8::1]/", + match: true, + }, + { + pattern: { + hostname: "[::AB:1]", + }, + url: "http://[::ab:1]/", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_pathname() { + const tests = [ + { + pattern: { + pathname: "/", + }, + url: "http://example.com", + match: true, + }, + { + pattern: { + pathname: "/", + }, + url: "http://example.com/", + match: true, + }, + { + pattern: { + pathname: "/", + }, + url: "http://example.com/?", + match: true, + }, + { + pattern: { + pathname: "path", + }, + url: "http://example.com/path", + match: true, + }, + { + pattern: { + pathname: "/path", + }, + url: "http://example.com/path", + match: true, + }, + { + pattern: { + pathname: "path", + }, + url: "http://example.com/path/", + match: false, + }, + { + pattern: { + pathname: "path", + }, + url: "http://example.com/path_continued", + match: false, + }, + { + pattern: { + pathname: "/", + }, + url: "http://example.com/path", + match: false, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_search() { + const tests = [ + { + pattern: { + search: "", + }, + url: "http://example.com/?", + match: true, + }, + { + pattern: { + search: "", + }, + url: "http://example.com/", + match: true, + }, + { + pattern: { + search: "", + }, + url: "http://example.com/?#", + match: true, + }, + { + pattern: { + search: "?", + }, + url: "http://example.com/?", + match: true, + }, + { + pattern: { + search: "?a", + }, + url: "http://example.com/?a", + match: true, + }, + { + pattern: { + search: "?", + }, + url: "http://example.com/??", + match: false, + }, + { + pattern: { + search: "query", + }, + url: "http://example.com/?query", + match: true, + }, + { + pattern: { + search: "?query", + }, + url: "http://example.com/?query", + match: true, + }, + { + pattern: { + search: "query=value", + }, + url: "http://example.com/?query=value", + match: true, + }, + { + pattern: { + search: "query", + }, + url: "http://example.com/?query=value", + match: false, + }, + { + pattern: { + search: "query", + }, + url: "http://example.com/?query#value", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +function runMatchPatternTests(tests, type) { + for (const test of tests) { + let pattern; + if (type == "pattern") { + pattern = parseURLPattern({ type: "pattern", ...test.pattern }); + } else { + pattern = parseURLPattern({ type: "string", pattern: test.pattern }); + } + + equal( + matchURLPattern(pattern, test.url), + test.match, + `url "${test.url}" ${ + test.match ? "should" : "should not" + } match pattern ${JSON.stringify(test.pattern)}` + ); + } +} diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js new file mode 100644 index 0000000000..d4bf3c5fdf --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js @@ -0,0 +1,369 @@ +/* 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/. */ + +const { parseURLPattern } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs" +); + +add_task(async function test_parseURLPattern_stringPatterns() { + const STRING_PATTERN_TESTS = [ + { + input: "http://example.com", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://example.com/", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://EXAMPLE.com", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://example%2Ecom", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + + { + input: "http://example.com:80", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://example.com:8888", + protocol: "http", + hostname: "example.com", + port: "8888", + pathname: "/", + search: "", + }, + { + input: "http://example.com/a////b", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/a////b", + search: "", + }, + { + input: "http://example.com/?", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://example.com/??", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "?", + }, + { + input: "http://example.com/?/", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "/", + }, + { + input: "file:///testfolder/test.zip", + protocol: "file", + hostname: "", + port: null, + pathname: "/testfolder/test.zip", + search: "", + }, + { + input: "http://example\\{.com/", + protocol: "http", + hostname: "example{.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://[2001:db8::1]/", + protocol: "http", + hostname: "[2001:db8::1]", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://127.0.0.1/", + protocol: "http", + hostname: "127.0.0.1", + port: "", + pathname: "/", + search: "", + }, + ]; + + for (const test of STRING_PATTERN_TESTS) { + const pattern = parseURLPattern({ + type: "string", + pattern: test.input, + }); + + equal(pattern.protocol, "protocol" in test ? test.protocol : null); + equal(pattern.hostname, "hostname" in test ? test.hostname : null); + equal(pattern.port, "port" in test ? test.port : null); + equal(pattern.pathname, "pathname" in test ? test.pathname : null); + equal(pattern.search, "search" in test ? test.search : null); + } +}); + +add_task(async function test_parseURLPattern_patternPatterns() { + const PATTERN_PATTERN_TESTS = [ + { + pattern: { + protocol: "http", + }, + protocol: "http", + hostname: null, + port: null, + pathname: null, + search: null, + }, + { + pattern: { + protocol: "HTTP", + }, + protocol: "http", + hostname: null, + port: null, + pathname: null, + search: null, + }, + { + pattern: { + hostname: "example.com", + }, + protocol: null, + hostname: "example.com", + port: null, + pathname: null, + search: null, + }, + { + pattern: { + hostname: "EXAMPLE.com", + }, + protocol: null, + hostname: "example.com", + port: null, + pathname: null, + search: null, + }, + { + pattern: { + hostname: "127.0.0.1", + }, + protocol: null, + hostname: "127.0.0.1", + port: null, + pathname: null, + search: null, + }, + { + pattern: { + hostname: "[2001:db8::1]", + }, + protocol: null, + hostname: "[2001:db8::1]", + port: null, + pathname: null, + search: null, + }, + { + pattern: { + port: "80", + }, + protocol: null, + hostname: null, + port: "", + pathname: null, + search: null, + }, + { + pattern: { + port: "1234", + }, + protocol: null, + hostname: null, + port: "1234", + pathname: null, + search: null, + }, + { + pattern: { + pathname: "path/to", + }, + protocol: null, + hostname: null, + port: null, + pathname: "/path/to", + search: null, + }, + { + pattern: { + pathname: "/path/to", + }, + protocol: null, + hostname: null, + port: null, + pathname: "/path/to", + search: null, + }, + { + pattern: { + pathname: "/path/to/", + }, + protocol: null, + hostname: null, + port: null, + pathname: "/path/to/", + search: null, + }, + { + pattern: { + search: "?search", + }, + protocol: null, + hostname: null, + port: null, + pathname: null, + search: "search", + }, + { + pattern: { + search: "search", + }, + protocol: null, + hostname: null, + port: null, + pathname: null, + search: "search", + }, + { + pattern: { + search: "?search=something", + }, + protocol: null, + hostname: null, + port: null, + pathname: null, + search: "search=something", + }, + { + pattern: { + search: "search=something", + }, + protocol: null, + hostname: null, + port: null, + pathname: null, + search: "search=something", + }, + ]; + + for (const test of PATTERN_PATTERN_TESTS) { + const pattern = parseURLPattern({ + type: "pattern", + ...test.pattern, + }); + + equal(pattern.protocol, "protocol" in test ? test.protocol : null); + equal(pattern.hostname, "hostname" in test ? test.hostname : null); + equal(pattern.port, "port" in test ? test.port : null); + equal(pattern.pathname, "pathname" in test ? test.pathname : null); + equal(pattern.search, "search" in test ? test.search : null); + } +}); + +add_task(async function test_parseURLPattern_invalid_type() { + const values = [null, undefined, 1, [], "string"]; + for (const value of values) { + Assert.throws(() => parseURLPattern(value), /InvalidArgumentError/); + } +}); + +add_task(async function test_parseURLPattern_invalid_type_type() { + const values = [null, undefined, 1, {}, []]; + for (const type of values) { + Assert.throws(() => parseURLPattern({ type }), /InvalidArgumentError/); + } +}); + +add_task(async function test_parseURLPattern_invalid_type_value() { + const values = ["", "unknownType"]; + for (const type of values) { + Assert.throws(() => parseURLPattern({ type }), /InvalidArgumentError/); + } +}); + +add_task(async function test_parseURLPattern_invalid_stringPatternType() { + const values = [null, undefined, 1, {}, []]; + for (const pattern of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_invalid_stringPattern() { + const values = [ + "foo", + "*", + "(", + ")", + "{", + "}", + "http\\{s\\}://example.com", + "https://example.com:port/", + ]; + for (const pattern of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_invalid_patternPattern_type() { + const properties = ["protocol", "hostname", "port", "pathname", "search"]; + const values = [false, 42, [], {}]; + for (const property of properties) { + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", [property]: value }), + /InvalidArgumentError/ + ); + } + } +}); diff --git a/remote/shared/webdriver/test/xpcshell/xpcshell.toml b/remote/shared/webdriver/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..1cdd1eb47c --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/xpcshell.toml @@ -0,0 +1,20 @@ +[DEFAULT] +head = "head.js" + +["test_Actions.js"] + +["test_Assert.js"] + +["test_Capabilities.js"] + +["test_Errors.js"] + +["test_NodeCache.js"] + +["test_Session.js"] + +["test_URLPattern_invalid.js"] + +["test_URLPattern_matchURLPattern.js"] + +["test_URLPattern_parseURLPattern.js"] diff --git a/remote/test/puppeteer/.editorconfig b/remote/test/puppeteer/.editorconfig new file mode 100644 index 0000000000..c6c8b36219 --- /dev/null +++ b/remote/test/puppeteer/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/remote/test/puppeteer/.eslintignore b/remote/test/puppeteer/.eslintignore new file mode 100644 index 0000000000..77ccb9293d --- /dev/null +++ b/remote/test/puppeteer/.eslintignore @@ -0,0 +1,52 @@ +## [START] Keep in sync with .gitignore +# Dependencies +node_modules + +# Production +build/ +lib/ +bin/ + +# Generated files +**/*.tsbuildinfo +*.api.json +*.tgz +yarn.lock +.docusaurus/ +.cache-loader +test/output-*/ +.dev_profile* +coverage/ +generated/ +.eslintcache +.cache/ + +# IDE Artifacts +.vscode +!.vscode/extensions.json +!.vscode/*.template.json +.devcontainer + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Wireit +.wireit +## [END] Keep in sync with .gitignore + +# ESLint ignores. +assets/ +third_party/ + +# ng-schematics +packages/ng-schematics/sandbox/** +packages/ng-schematics/multi/** +packages/ng-schematics/src/**/files/ diff --git a/remote/test/puppeteer/.eslintrc.js b/remote/test/puppeteer/.eslintrc.js new file mode 100644 index 0000000000..250aa0c169 --- /dev/null +++ b/remote/test/puppeteer/.eslintrc.js @@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const {readdirSync} = require('fs'); +const {join} = require('path'); + +const rulesDirPlugin = require('eslint-plugin-rulesdir'); + +rulesDirPlugin.RULES_DIR = 'tools/eslint/lib'; + +function getThirdPartyPackages() { + return readdirSync(join(__dirname, 'packages/puppeteer-core/third_party'), { + withFileTypes: true, + }) + .filter(dirent => { + return dirent.isDirectory(); + }) + .map(({name}) => { + return { + name, + message: `Import \`${name}\` from the vendored location: third_party/${name}/index.js`, + }; + }); +} + +module.exports = { + root: true, + env: { + node: true, + es6: true, + }, + + parser: '@typescript-eslint/parser', + + plugins: ['mocha', '@typescript-eslint', 'import', 'rulesdir'], + + extends: ['plugin:prettier/recommended', 'plugin:import/typescript'], + + settings: { + 'import/resolver': { + typescript: true, + }, + }, + + rules: { + // Brackets keep code readable. + curly: ['error', 'all'], + // Brackets keep code readable and `return` intentions clear. + 'arrow-body-style': ['error', 'always'], + // Error if files are not formatted with Prettier correctly. + 'prettier/prettier': 'error', + // syntax preferences + 'spaced-comment': [ + 'error', + 'always', + { + markers: ['*'], + }, + ], + eqeqeq: ['error'], + 'accessor-pairs': [ + 'error', + { + getWithoutSet: false, + setWithoutGet: false, + }, + ], + 'new-parens': 'error', + 'func-call-spacing': 'error', + 'prefer-const': 'error', + + 'max-len': [ + 'error', + { + /* this setting doesn't impact things as we use Prettier to format + * our code and hence dictate the line length. + * Prettier aims for 80 but sometimes makes the decision to go just + * over 80 chars as it decides that's better than wrapping. ESLint's + * rule defaults to 80 but therefore conflicts with Prettier. So we + * set it to something far higher than Prettier would allow to avoid + * it causing issues and conflicting with Prettier. + */ + code: 200, + comments: 90, + ignoreTemplateLiterals: true, + ignoreUrls: true, + ignoreStrings: true, + ignoreRegExpLiterals: true, + }, + ], + // anti-patterns + 'no-var': 'error', + 'no-with': 'error', + 'no-multi-str': 'error', + 'no-caller': 'error', + 'no-implied-eval': 'error', + 'no-labels': 'error', + 'no-new-object': 'error', + 'no-octal-escape': 'error', + 'no-self-compare': 'error', + 'no-shadow-restricted-names': 'error', + 'no-cond-assign': 'error', + 'no-debugger': 'error', + 'no-dupe-keys': 'error', + 'no-duplicate-case': 'error', + 'no-empty-character-class': 'error', + 'no-unreachable': 'error', + 'no-unsafe-negation': 'error', + radix: 'error', + 'valid-typeof': 'error', + 'no-unused-vars': [ + 'error', + { + args: 'none', + vars: 'local', + varsIgnorePattern: + '([fx]?describe|[fx]?it|beforeAll|beforeEach|afterAll|afterEach)', + }, + ], + 'no-implicit-globals': ['error'], + + // es2015 features + 'require-yield': 'error', + 'template-curly-spacing': ['error', 'never'], + + // ensure we don't have any it.only or describe.only in prod + 'mocha/no-exclusive-tests': 'error', + + 'import/order': [ + 'error', + { + 'newlines-between': 'always', + alphabetize: {order: 'asc', caseInsensitive: true}, + }, + ], + + 'import/no-cycle': ['error', {maxDepth: Infinity}], + + 'no-restricted-syntax': [ + 'error', + // Don't allow underscored declarations on camelCased variables/properties. + // ...RESTRICTED_UNDERSCORED_IDENTIFIERS, + ], + + // Keeps comments formatted. + 'rulesdir/prettier-comments': 'error', + // Enforces consistent file extension + 'rulesdir/extensions': 'error', + // Enforces license headers on files + 'rulesdir/check-license': 'warn', + }, + overrides: [ + { + files: ['*.ts'], + parserOptions: { + allowAutomaticSingleRunInference: true, + project: './tsconfig.base.json', + }, + extends: [ + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/stylistic', + ], + plugins: ['eslint-plugin-tsdoc'], + rules: { + // Enforces clean up of used resources. + 'rulesdir/use-using': 'error', + // Brackets keep code readable. + curly: ['error', 'all'], + // Brackets keep code readable and `return` intentions clear. + 'arrow-body-style': ['error', 'always'], + // Error if comments do not adhere to `tsdoc`. + 'tsdoc/syntax': 'error', + // Keeps array types simple only when they are simple for readability. + '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + {argsIgnorePattern: '^_', varsIgnorePattern: '^_'}, + ], + 'func-call-spacing': 'off', + '@typescript-eslint/func-call-spacing': 'error', + semi: 'off', + '@typescript-eslint/semi': 'error', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-use-before-define': 'off', + // We have to use any on some types so the warning isn't valuable. + '@typescript-eslint/no-explicit-any': 'off', + // We don't require explicit return types on basic functions or + // dummy functions in tests, for example + '@typescript-eslint/explicit-function-return-type': 'off', + // We allow non-null assertions if the value was asserted using `assert` API. + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-useless-template-literals': 'error', + /** + * This is the default options (as per + * https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/docs/rules/ban-types.md), + * + * Unfortunately there's no way to + */ + '@typescript-eslint/ban-types': [ + 'error', + { + extendDefaults: true, + types: { + /* + * Puppeteer's API accepts generic functions in many places so it's + * not a useful linting rule to ban the `Function` type. This turns off + * the banning of the `Function` type which is a default rule. + */ + Function: false, + }, + }, + ], + // By default this is a warning but we want it to error. + '@typescript-eslint/explicit-module-boundary-types': 'error', + 'no-restricted-syntax': [ + 'error', + { + // Never use `require` in TypeScript since they are transpiled out. + selector: "CallExpression[callee.name='require']", + message: '`require` statements are not allowed. Use `import`.', + }, + { + // We need this as NodeJS will run until all the timers have resolved + message: 'Use method `Deferred.race()` instead.', + selector: + 'MemberExpression[object.name="Promise"][property.name="race"]', + }, + { + message: + 'Deferred `valueOrThrow` should not be called in `Deferred.race()` pass deferred directly', + selector: + 'CallExpression[callee.object.name="Deferred"][callee.property.name="race"] > ArrayExpression > CallExpression[callee.property.name="valueOrThrow"]', + }, + ], + '@typescript-eslint/no-floating-promises': [ + 'error', + {ignoreVoid: true, ignoreIIFE: true}, + ], + '@typescript-eslint/prefer-ts-expect-error': 'error', + // This is more performant; see https://v8.dev/blog/fast-async. + '@typescript-eslint/return-await': ['error', 'always'], + // This optimizes the dependency tracking for type-only files. + '@typescript-eslint/consistent-type-imports': 'error', + // So type-only exports get elided. + '@typescript-eslint/consistent-type-exports': 'error', + // Don't want to trigger unintended side-effects. + '@typescript-eslint/no-import-type-side-effects': 'error', + }, + overrides: [ + { + files: 'packages/puppeteer-core/src/**/*.ts', + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: ['*Events', '*.test.js'], + paths: [...getThirdPartyPackages()], + }, + ], + }, + }, + { + files: [ + 'packages/puppeteer-core/src/**/*.test.ts', + 'tools/mocha-runner/src/test.ts', + ], + rules: { + // With the Node.js test runner, `describe` and `it` are technically + // promises, but we don't need to await them. + '@typescript-eslint/no-floating-promises': 'off', + }, + }, + ], + }, + ], +}; diff --git a/remote/test/puppeteer/.eslintrc.types.cjs b/remote/test/puppeteer/.eslintrc.types.cjs new file mode 100644 index 0000000000..f266ee754b --- /dev/null +++ b/remote/test/puppeteer/.eslintrc.types.cjs @@ -0,0 +1,17 @@ +module.exports = { + plugins: ['unused-imports'], + parser: '@typescript-eslint/parser', + rules: { + '@typescript-eslint/no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + }, +}; diff --git a/remote/test/puppeteer/.mocharc.cjs b/remote/test/puppeteer/.mocharc.cjs new file mode 100644 index 0000000000..79c6c3bf65 --- /dev/null +++ b/remote/test/puppeteer/.mocharc.cjs @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +let timeout = process.platform === 'win32' ? 20_000 : 10_000; +if (!!process.env.DEBUGGER_ATTACHED) { + timeout = 0; +} +module.exports = { + reporter: 'dot', + logLevel: 'debug', + require: ['./test/build/mocha-utils.js', 'source-map-support/register'], + exit: !!process.env.CI, + retries: process.env.CI ? 3 : 0, + parallel: !!process.env.PARALLEL, + timeout: timeout, + reporter: process.env.CI ? 'spec' : 'dot', + // This should make mocha crash on uncaught errors. + // See https://github.com/mochajs/mocha/blob/master/docs/index.md#--allow-uncaught. + allowUncaught: true, + // See https://github.com/mochajs/mocha/blob/master/docs/index.md#--async-only--a. + asyncOnly: true, +}; diff --git a/remote/test/puppeteer/.npmrc b/remote/test/puppeteer/.npmrc new file mode 100644 index 0000000000..94a06c2180 --- /dev/null +++ b/remote/test/puppeteer/.npmrc @@ -0,0 +1 @@ +access=public diff --git a/remote/test/puppeteer/.nvmrc b/remote/test/puppeteer/.nvmrc new file mode 100644 index 0000000000..85aee5a534 --- /dev/null +++ b/remote/test/puppeteer/.nvmrc @@ -0,0 +1 @@ +v20
\ No newline at end of file diff --git a/remote/test/puppeteer/.prettierignore b/remote/test/puppeteer/.prettierignore new file mode 100644 index 0000000000..9da3d6ad79 --- /dev/null +++ b/remote/test/puppeteer/.prettierignore @@ -0,0 +1,56 @@ +## [START] Keep in sync with .gitignore +# Dependencies +node_modules + +# Production +build/ +lib/ +bin/ + +# Generated files +**/*.tsbuildinfo +*.api.json +*.tgz +yarn.lock +.docusaurus/ +.cache-loader +test/output-*/ +.dev_profile* +coverage/ +generated/ +.eslintcache +.cache/ + +# IDE Artifacts +.vscode/* +!.vscode/extensions.json +!.vscode/*.template.json +.devcontainer + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Wireit +.wireit +## [END] Keep in sync with .gitignore + +# Prettier-only ignores. +CHANGELOG.md +package-lock.json +test/assets/ +docs/api +docs/browsers-api +versioned_*/ + +# Ng-schematics +/packages/ng-schematics/files/ +/packages/ng-schematics/sandbox/ +/packages/ng-schematics/multi/ diff --git a/remote/test/puppeteer/.prettierrc.cjs b/remote/test/puppeteer/.prettierrc.cjs new file mode 100644 index 0000000000..46c608ced5 --- /dev/null +++ b/remote/test/puppeteer/.prettierrc.cjs @@ -0,0 +1,7 @@ +/** + * @type {import('prettier').Config} + */ +module.exports = { + ...require('gts/.prettierrc.json'), + // proseWrap: 'always', // Uncomment this while working on Markdown documents. MAKE SURE TO COMMENT THIS BEFORE RUNNING CHECKS/FORMATS OR EVERYTHING WILL BE MODIFIED. +}; diff --git a/remote/test/puppeteer/.release-please-manifest.json b/remote/test/puppeteer/.release-please-manifest.json new file mode 100644 index 0000000000..1237fb11dd --- /dev/null +++ b/remote/test/puppeteer/.release-please-manifest.json @@ -0,0 +1,7 @@ +{ + "packages/puppeteer": "21.10.0", + "packages/puppeteer-core": "21.10.0", + "packages/testserver": "0.6.0", + "packages/ng-schematics": "0.5.6", + "packages/browsers": "1.9.1" +} diff --git a/remote/test/puppeteer/.vscode/extensions.json b/remote/test/puppeteer/.vscode/extensions.json new file mode 100644 index 0000000000..4084e393e9 --- /dev/null +++ b/remote/test/puppeteer/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["google.wireit", "GitHub.vscode-github-actions"] +} diff --git a/remote/test/puppeteer/Herebyfile.mjs b/remote/test/puppeteer/Herebyfile.mjs new file mode 100644 index 0000000000..30f9c75262 --- /dev/null +++ b/remote/test/puppeteer/Herebyfile.mjs @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable import/order */ + +import {copyFile, readFile, writeFile} from 'fs/promises'; + +import {docgen, spliceIntoSection} from '@puppeteer/docgen'; +import {execa} from 'execa'; +import {task} from 'hereby'; +import semver from 'semver'; + +export const docsNgSchematicsTask = task({ + name: 'docs:ng-schematics', + run: async () => { + const readme = await readFile('packages/ng-schematics/README.md', 'utf-8'); + await writeFile('docs/integrations/ng-schematics.md', readme); + }, +}); + +/** + * This logic should match the one in `website/docusaurus.config.js`. + */ +function getApiUrl(version) { + if (semver.gte(version, '19.3.0')) { + return `https://github.com/puppeteer/puppeteer/blob/puppeteer-${version}/docs/api/index.md`; + } else if (semver.gte(version, '15.3.0')) { + return `https://github.com/puppeteer/puppeteer/blob/${version}/docs/api/index.md`; + } else { + return `https://github.com/puppeteer/puppeteer/blob/${version}/docs/api.md`; + } +} + +export const docsChromiumSupportTask = task({ + name: 'docs:chromium-support', + run: async () => { + const content = await readFile('docs/chromium-support.md', { + encoding: 'utf8', + }); + const {versionsPerRelease} = await import('./versions.js'); + const buffer = []; + for (const [chromiumVersion, puppeteerVersion] of versionsPerRelease) { + if (puppeteerVersion === 'NEXT') { + continue; + } + if (semver.gte(puppeteerVersion, '20.0.0')) { + buffer.push( + ` * [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) ${chromiumVersion} - [Puppeteer ${puppeteerVersion}](${getApiUrl( + puppeteerVersion + )})` + ); + } else { + buffer.push( + ` * Chromium ${chromiumVersion} - [Puppeteer ${puppeteerVersion}](${getApiUrl( + puppeteerVersion + )})` + ); + } + } + await writeFile( + 'docs/chromium-support.md', + spliceIntoSection('version', content, buffer.join('\n')) + ); + }, +}); + +export const docsTask = task({ + name: 'docs', + dependencies: [docsNgSchematicsTask, docsChromiumSupportTask], + run: async () => { + // Copy main page. + await copyFile('README.md', 'docs/index.md'); + + // Generate documentation + for (const [name, folder] of [ + ['browsers', 'browsers-api'], + ['puppeteer', 'api'], + ]) { + docgen(`docs/${name}.api.json`, `docs/${folder}`); + } + + // Update main @puppeteer/browsers page. + const readme = await readFile('packages/browsers/README.md', 'utf-8'); + const index = await readFile('docs/browsers-api/index.md', 'utf-8'); + await writeFile( + 'docs/browsers-api/index.md', + index.replace('# API Reference', readme) + ); + + // Format everything. + await execa('prettier', ['--ignore-path', 'none', '--write', 'docs']); + }, +}); diff --git a/remote/test/puppeteer/LICENSE b/remote/test/puppeteer/LICENSE new file mode 100644 index 0000000000..d2c171df74 --- /dev/null +++ b/remote/test/puppeteer/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Google Inc. + + Licensed 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 + + https://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. diff --git a/remote/test/puppeteer/README.md b/remote/test/puppeteer/README.md new file mode 100644 index 0000000000..74a15c6eb9 --- /dev/null +++ b/remote/test/puppeteer/README.md @@ -0,0 +1,257 @@ +# Puppeteer + +[![Build status](https://github.com/puppeteer/puppeteer/workflows/CI/badge.svg)](https://github.com/puppeteer/puppeteer/actions?query=workflow%3ACI) +[![npm puppeteer package](https://img.shields.io/npm/v/puppeteer.svg)](https://npmjs.org/package/puppeteer) + +<img src="https://user-images.githubusercontent.com/10379601/29446482-04f7036a-841f-11e7-9872-91d1fc2ea683.png" height="200" align="right"/> + +#### [Guides](https://pptr.dev/category/guides) | [API](https://pptr.dev/api) | [FAQ](https://pptr.dev/faq) | [Contributing](https://pptr.dev/contributing) | [Troubleshooting](https://pptr.dev/troubleshooting) + +> Puppeteer is a Node.js library which provides a high-level API to control +> Chrome/Chromium over the +> [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). +> Puppeteer runs in +> [headless](https://developer.chrome.com/articles/new-headless/) +> mode by default, but can be configured to run in full ("headful") +> Chrome/Chromium. + +#### What can I do? + +Most things that you can do manually in the browser can be done using Puppeteer! +Here are a few examples to get you started: + +- Generate screenshots and PDFs of pages. +- Crawl a SPA (Single-Page Application) and generate pre-rendered content (i.e. + "SSR" (Server-Side Rendering)). +- Automate form submission, UI testing, keyboard input, etc. +- Create an automated testing environment using the latest JavaScript and + browser features. +- Capture a + [timeline trace](https://developers.google.com/web/tools/chrome-devtools/evaluate-performance/reference) + of your site to help diagnose performance issues. +- [Test Chrome Extensions](https://pptr.dev/guides/chrome-extensions). + +## Getting Started + +### Installation + +To use Puppeteer in your project, run: + +```bash +npm i puppeteer +# or using yarn +yarn add puppeteer +# or using pnpm +pnpm i puppeteer +``` + +When you install Puppeteer, it automatically downloads a recent version of +[Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) (~170MB macOS, ~282MB Linux, ~280MB Windows) and a `chrome-headless-shell` binary (starting with Puppeteer v21.6.0) that is [guaranteed to +work](https://pptr.dev/faq#q-why-doesnt-puppeteer-vxxx-work-with-chromium-vyyy) +with Puppeteer. The browser is downloaded to the `$HOME/.cache/puppeteer` folder +by default (starting with Puppeteer v19.0.0). See [configuration](https://pptr.dev/api/puppeteer.configuration) for configuration options and environmental variables to control the download behavor. + +If you deploy a project using Puppeteer to a hosting provider, such as Render or +Heroku, you might need to reconfigure the location of the cache to be within +your project folder (see an example below) because not all hosting providers +include `$HOME/.cache` into the project's deployment. + +For a version of Puppeteer without the browser installation, see +[`puppeteer-core`](#puppeteer-core). + +If used with TypeScript, the minimum supported TypeScript version is `4.7.4`. + +#### Configuration + +Puppeteer uses several defaults that can be customized through configuration +files. + +For example, to change the default cache directory Puppeteer uses to install +browsers, you can add a `.puppeteerrc.cjs` (or `puppeteer.config.cjs`) at the +root of your application with the contents + +```js +const {join} = require('path'); + +/** + * @type {import("puppeteer").Configuration} + */ +module.exports = { + // Changes the cache location for Puppeteer. + cacheDirectory: join(__dirname, '.cache', 'puppeteer'), +}; +``` + +After adding the configuration file, you will need to remove and reinstall +`puppeteer` for it to take effect. + +See the [configuration guide](https://pptr.dev/guides/configuration) for more +information. + +#### `puppeteer-core` + +For every release since v1.7.0 we publish two packages: + +- [`puppeteer`](https://www.npmjs.com/package/puppeteer) +- [`puppeteer-core`](https://www.npmjs.com/package/puppeteer-core) + +`puppeteer` is a _product_ for browser automation. When installed, it downloads +a version of Chrome, which it then drives using `puppeteer-core`. Being an +end-user product, `puppeteer` automates several workflows using reasonable +defaults [that can be customized](https://pptr.dev/guides/configuration). + +`puppeteer-core` is a _library_ to help drive anything that supports DevTools +protocol. Being a library, `puppeteer-core` is fully driven through its +programmatic interface implying no defaults are assumed and `puppeteer-core` +will not download Chrome when installed. + +You should use `puppeteer-core` if you are +[connecting to a remote browser](https://pptr.dev/api/puppeteer.puppeteer.connect) +or [managing browsers yourself](https://pptr.dev/browsers-api/). +If you are managing browsers yourself, you will need to call +[`puppeteer.launch`](https://pptr.dev/api/puppeteer.puppeteernode.launch) with +an explicit +[`executablePath`](https://pptr.dev/api/puppeteer.launchoptions) +(or [`channel`](https://pptr.dev/api/puppeteer.launchoptions) if it's +installed in a standard location). + +When using `puppeteer-core`, remember to change the import: + +```ts +import puppeteer from 'puppeteer-core'; +``` + +### Usage + +Puppeteer follows the latest +[maintenance LTS](https://github.com/nodejs/Release#release-schedule) version of +Node. + +Puppeteer will be familiar to people using other browser testing frameworks. You +[launch](https://pptr.dev/api/puppeteer.puppeteernode.launch)/[connect](https://pptr.dev/api/puppeteer.puppeteernode.connect) +a [browser](https://pptr.dev/api/puppeteer.browser), +[create](https://pptr.dev/api/puppeteer.browser.newpage) some +[pages](https://pptr.dev/api/puppeteer.page), and then manipulate them with +[Puppeteer's API](https://pptr.dev/api). + +For more in-depth usage, check our [guides](https://pptr.dev/category/guides) +and [examples](https://github.com/puppeteer/puppeteer/tree/main/examples). + +#### Example + +The following example searches [developer.chrome.com](https://developer.chrome.com/) for blog posts with text "automate beyond recorder", click on the first result and print the full title of the blog post. + +```ts +import puppeteer from 'puppeteer'; + +(async () => { + // Launch the browser and open a new blank page + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + + // Navigate the page to a URL + await page.goto('https://developer.chrome.com/'); + + // Set screen size + await page.setViewport({width: 1080, height: 1024}); + + // Type into search box + await page.type('.devsite-search-field', 'automate beyond recorder'); + + // Wait and click on first result + const searchResultSelector = '.devsite-result-item-link'; + await page.waitForSelector(searchResultSelector); + await page.click(searchResultSelector); + + // Locate the full title with a unique string + const textSelector = await page.waitForSelector( + 'text/Customize and automate' + ); + const fullTitle = await textSelector?.evaluate(el => el.textContent); + + // Print the full title + console.log('The title of this blog post is "%s".', fullTitle); + + await browser.close(); +})(); +``` + +### Default runtime settings + +**1. Uses Headless mode** + +By default Puppeteer launches Chrome in +[old Headless mode](https://developer.chrome.com/articles/new-headless/). + +```ts +const browser = await puppeteer.launch(); +// Equivalent to +const browser = await puppeteer.launch({headless: true}); +``` + +[Chrome 112 launched a new Headless mode](https://developer.chrome.com/articles/new-headless/) that might cause some differences in behavior compared to the old Headless implementation. +In the future Puppeteer will start defaulting to new implementation. +We recommend you try it out before the switch: + +```ts +const browser = await puppeteer.launch({headless: 'new'}); +``` + +To launch a "headful" version of Chrome, set the +[`headless`](https://pptr.dev/api/puppeteer.browserlaunchargumentoptions) to `false` +option when launching a browser: + +```ts +const browser = await puppeteer.launch({headless: false}); +``` + +**2. Runs a bundled version of Chrome** + +By default, Puppeteer downloads and uses a specific version of Chrome so its +API is guaranteed to work out of the box. To use Puppeteer with a different +version of Chrome or Chromium, pass in the executable's path when creating a +`Browser` instance: + +```ts +const browser = await puppeteer.launch({executablePath: '/path/to/Chrome'}); +``` + +You can also use Puppeteer with Firefox. See +[status of cross-browser support](https://pptr.dev/faq/#q-what-is-the-status-of-cross-browser-support) for +more information. + +See +[`this article`](https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/) +for a description of the differences between Chromium and Chrome. +[`This article`](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/chromium_browser_vs_google_chrome.md) +describes some differences for Linux users. + +**3. Creates a fresh user profile** + +Puppeteer creates its own browser user profile which it **cleans up on every +run**. + +#### Using Docker + +See our [Docker guide](https://pptr.dev/guides/docker). + +#### Using Chrome Extensions + +See our [Chrome extensions guide](https://pptr.dev/guides/chrome-extensions). + +## Resources + +- [API Documentation](https://pptr.dev/api) +- [Guides](https://pptr.dev/category/guides) +- [Examples](https://github.com/puppeteer/puppeteer/tree/main/examples) +- [Community list of Puppeteer resources](https://github.com/transitive-bullshit/awesome-puppeteer) + +## Contributing + +Check out our [contributing guide](https://pptr.dev/contributing) to get an +overview of Puppeteer development. + +## FAQ + +Our [FAQ](https://pptr.dev/faq) has migrated to +[our site](https://pptr.dev/faq). diff --git a/remote/test/puppeteer/SECURITY.md b/remote/test/puppeteer/SECURITY.md new file mode 100644 index 0000000000..a202138d31 --- /dev/null +++ b/remote/test/puppeteer/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +The Puppeteer project takes security very seriously. Please use Chromium's process to report security issues. + +## Reporting a Vulnerability + +See https://www.chromium.org/Home/chromium-security/reporting-security-bugs/ diff --git a/remote/test/puppeteer/examples/README.md b/remote/test/puppeteer/examples/README.md new file mode 100644 index 0000000000..f20c2e7162 --- /dev/null +++ b/remote/test/puppeteer/examples/README.md @@ -0,0 +1,43 @@ +# Running the examples + +Assuming you have a checkout of the Puppeteer repo and have run `npm i` (or `yarn`) to install the dependencies, and `npm run build` (or `yarn run build`) to build the project, the examples can be run from the root folder like so: + +```bash +NODE_PATH=../ node examples/search.js +``` + +## Larger examples + +More complex and use case driven examples can be found at [github.com/GoogleChromeLabs/puppeteer-examples](https://github.com/GoogleChromeLabs/puppeteer-examples). + +# Other resources + +Other useful tools, articles, and projects that use Puppeteer. + +## Rendering and web scraping + +- [Puppetron](https://github.com/cheeaun/puppetron) - Demo site that shows how to use Puppeteer and Headless Chrome to render pages. Inspired by [GoogleChrome/rendertron](https://github.com/GoogleChrome/rendertron). +- [Thal](https://medium.com/@e_mad_ehsan/getting-started-with-puppeteer-and-chrome-headless-for-web-scrapping-6bf5979dee3e 'An article on medium') - Getting started with Puppeteer and Chrome Headless for Web Scraping. +- [pupperender](https://github.com/LasaleFamine/pupperender) - Express middleware that checks the User-Agent header of incoming requests, and if it matches one of a configurable set of bots, render the page using Puppeteer. Useful for PWA rendering. +- [headless-chrome-crawler](https://github.com/yujiosaka/headless-chrome-crawler) - Crawler that provides simple APIs to manipulate Headless Chrome and allows you to crawl dynamic websites. +- [puppeteer-examples](https://github.com/checkly/puppeteer-examples) - Puppeteer Headless Chrome examples for real life use cases such as getting useful info from the web pages or common login scenarios. +- [browserless](https://github.com/joelgriffith/browserless) - Headless Chrome as a service letting you execute Puppeteer scripts remotely. Provides a docker image with configuration for concurrency, launch arguments and more. +- [Puppeteer on AWS Lambda](https://github.com/jay-deshmukh/headless-chrome-with-puppeteer-on-AWS-lambda-with-serverless-framework) - Running puppeteer on AWS Lambda with Serverless framework +- [Apify SDK](https://github.com/apifytech/apify-js) - The scalable web crawling and scraping library for JavaScript. Automatically manages a pool of Puppeteer browsers and provides easy error handling, task management, proxy rotation and more. + +## Testing + +- [angular-puppeteer-demo](https://github.com/Quramy/angular-puppeteer-demo) - Demo repository explaining how to use Puppeteer in Karma. +- [mocha-headless-chrome](https://github.com/direct-adv-interfaces/mocha-headless-chrome) - Tool which runs client-side **mocha** tests in the command line through headless Chrome. +- [puppeteer-to-istanbul-example](https://github.com/bcoe/puppeteer-to-istanbul-example) - Demo repository demonstrating how to output Puppeteer coverage in Istanbul format. +- [jest-puppeteer](https://github.com/smooth-code/jest-puppeteer) - (almost) Zero configuration tool for setting up and running Jest and Puppeteer easily. Also includes an assertion library for Puppeteer. +- [puppeteer-har](https://github.com/Everettss/puppeteer-har) - Generate HAR file with puppeteer. +- [puppetry](https://puppetry.app/) - A desktop app to build Puppeteer/Jest driven tests without coding. +- [puppeteer-loadtest](https://github.com/svenkatreddy/puppeteer-loadtest) - commandline interface for performing load test on puppeteer scripts. +- [cucumber-puppeteer-example](https://github.com/mlampedx/cucumber-puppeteer-example) - Example repository demonstrating how to use Puppeeteer and Cucumber for integration testing. + +## Services + +- [Checkly](https://checklyhq.com) - Monitoring SaaS that uses Puppeteer to check availability and correctness of web pages and apps. +- [Doppio](https://doppio.sh) - SaaS API to create screenshots or PDFs from HTML/CSS/JS +- [Doczilla](https://www.doczilla.app) - SaaS API empowering the generation of screenshots or PDFs directly from HTML/CSS/JS code. diff --git a/remote/test/puppeteer/examples/block-images.js b/remote/test/puppeteer/examples/block-images.js new file mode 100644 index 0000000000..73a87eb089 --- /dev/null +++ b/remote/test/puppeteer/examples/block-images.js @@ -0,0 +1,36 @@ +/** + * Copyright 2017 Google Inc., PhantomJS Authors All rights reserved. + * + * Licensed 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 puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.setRequestInterception(true); + page.on('request', request => { + if (request.resourceType() === 'image') { + request.abort(); + } else { + request.continue(); + } + }); + await page.goto('https://news.google.com/news/'); + await page.screenshot({path: 'news.png', fullPage: true}); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/cross-browser.js b/remote/test/puppeteer/examples/cross-browser.js new file mode 100644 index 0000000000..0f972a0b70 --- /dev/null +++ b/remote/test/puppeteer/examples/cross-browser.js @@ -0,0 +1,46 @@ +const puppeteer = require('puppeteer'); + +/** + * To have Puppeteer fetch a Firefox binary for you, first run: + * + * PUPPETEER_PRODUCT=firefox npm install + * + * To get additional logging about which browser binary is executed, + * run this example as: + * + * DEBUG=puppeteer:launcher NODE_PATH=../ node examples/cross-browser.js + * + * You can set a custom binary with the `executablePath` launcher option. + */ + +const firefoxOptions = { + product: 'firefox', + extraPrefsFirefox: { + // Enable additional Firefox logging from its protocol implementation + // 'remote.log.level': 'Trace', + }, + // Make browser logs visible + dumpio: true, +}; + +(async () => { + const browser = await puppeteer.launch(firefoxOptions); + + const page = await browser.newPage(); + console.log(await browser.version()); + + await page.goto('https://news.ycombinator.com/'); + + // Extract articles from the page. + const resultsSelector = '.titleline > a'; + const links = await page.evaluate(resultsSelector => { + const anchors = Array.from(document.querySelectorAll(resultsSelector)); + return anchors.map(anchor => { + const title = anchor.textContent.trim(); + return `${title} - ${anchor.href}`; + }); + }, resultsSelector); + console.log(links.join('\n')); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/custom-event.js b/remote/test/puppeteer/examples/custom-event.js new file mode 100644 index 0000000000..960bca34ee --- /dev/null +++ b/remote/test/puppeteer/examples/custom-event.js @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + + // Define a window.onCustomEvent function on the page. + await page.exposeFunction('onCustomEvent', e => { + console.log(`${e.type} fired`, e.detail || ''); + }); + + /** + * Attach an event listener to page to capture a custom event on page load/navigation. + * @param {string} type Event name. + * @returns {!Promise} + */ + function listenFor(type) { + return page.evaluateOnNewDocument(type => { + document.addEventListener(type, e => { + window.onCustomEvent({type, detail: e.detail}); + }); + }, type); + } + + await listenFor('app-ready'); // Listen for "app-ready" custom event on page load. + + await page.goto('https://www.chromestatus.com/features', { + waitUntil: 'networkidle0', + }); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/detect-sniff.js b/remote/test/puppeteer/examples/detect-sniff.js new file mode 100644 index 0000000000..2900236fb8 --- /dev/null +++ b/remote/test/puppeteer/examples/detect-sniff.js @@ -0,0 +1,49 @@ +/** + * Copyright 2017 Google Inc., PhantomJS Authors All rights reserved. + * + * Licensed 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 puppeteer = require('puppeteer'); + +function sniffDetector() { + const userAgent = window.navigator.userAgent; + const platform = window.navigator.platform; + + window.navigator.__defineGetter__('userAgent', function () { + window.navigator.sniffed = true; + return userAgent; + }); + + window.navigator.__defineGetter__('platform', function () { + window.navigator.sniffed = true; + return platform; + }); +} + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.evaluateOnNewDocument(sniffDetector); + await page.goto('https://www.google.com', {waitUntil: 'networkidle2'}); + console.log( + 'Sniffed: ' + + (await page.evaluate(() => { + return !!navigator.sniffed; + })) + ); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/oopif.js b/remote/test/puppeteer/examples/oopif.js new file mode 100644 index 0000000000..6ed79f9ced --- /dev/null +++ b/remote/test/puppeteer/examples/oopif.js @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +async function attachFrame(frameId, url) { + const frame = document.createElement('iframe'); + frame.src = url; + frame.id = frameId; + document.body.appendChild(frame); + await new Promise(x => { + return (frame.onload = x); + }); + return frame; +} + +(async () => { + // Launch browser in non-headless mode. + const browser = await puppeteer.launch({headless: false}); + const page = await browser.newPage(); + + // Load a page from one origin: + await page.goto('http://example.org/'); + + // Inject iframe with the another origin. + await page.evaluateHandle(attachFrame, 'frame1', 'https://example.com/'); + + // At this point there should be a message in the output: + // puppeteer:frame The frame '...' moved to another session. Out-of-process + // iframes (OOPIF) are not supported by Puppeteer yet. + // https://github.com/puppeteer/puppeteer/issues/2548 + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/pdf.js b/remote/test/puppeteer/examples/pdf.js new file mode 100644 index 0000000000..e97cc53cdb --- /dev/null +++ b/remote/test/puppeteer/examples/pdf.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('https://news.ycombinator.com', { + waitUntil: 'networkidle2', + }); + // page.pdf() is currently supported only in headless mode. + // @see https://bugs.chromium.org/p/chromium/issues/detail?id=753118 + await page.pdf({ + path: 'hn.pdf', + format: 'letter', + }); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/proxy.js b/remote/test/puppeteer/examples/proxy.js new file mode 100644 index 0000000000..e41d0d8cd1 --- /dev/null +++ b/remote/test/puppeteer/examples/proxy.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch({ + // Launch chromium using a proxy server on port 9876. + // More on proxying: + // https://www.chromium.org/developers/design-documents/network-settings + args: [ + '--proxy-server=127.0.0.1:9876', + // Use proxy for localhost URLs + '--proxy-bypass-list=<-loopback>', + ], + }); + const page = await browser.newPage(); + await page.goto('https://google.com'); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/screenshot-fullpage.js b/remote/test/puppeteer/examples/screenshot-fullpage.js new file mode 100644 index 0000000000..cbc3d5e782 --- /dev/null +++ b/remote/test/puppeteer/examples/screenshot-fullpage.js @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.emulate(puppeteer.devices['iPhone 6']); + await page.goto('https://www.nytimes.com/'); + await page.screenshot({path: 'full.png', fullPage: true}); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/screenshot.js b/remote/test/puppeteer/examples/screenshot.js new file mode 100644 index 0000000000..85c8462cb5 --- /dev/null +++ b/remote/test/puppeteer/examples/screenshot.js @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('http://example.com'); + await page.screenshot({path: 'example.png'}); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/search.js b/remote/test/puppeteer/examples/search.js new file mode 100644 index 0000000000..7c2a081808 --- /dev/null +++ b/remote/test/puppeteer/examples/search.js @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Search developers.google.com/web for articles tagged + * "Headless Chrome" and scrape results from the results page. + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + + await page.goto('https://developers.google.com/web/'); + + // Type into search box. + await page.type('.devsite-search-field', 'Headless Chrome'); + + // Wait for suggest overlay to appear and click "show all results". + const allResultsSelector = '.devsite-suggest-all-results'; + await page.waitForSelector(allResultsSelector); + await page.click(allResultsSelector); + + // Wait for the results page to load and display the results. + const resultsSelector = '.gsc-table-result a.gs-title[href]'; + await page.waitForSelector(resultsSelector); + + // Extract the results from the page. + const links = await page.evaluate(resultsSelector => { + const anchors = Array.from(document.querySelectorAll(resultsSelector)); + return anchors.map(anchor => { + const title = anchor.textContent.split('|')[0].trim(); + return `${title} - ${anchor.href}`; + }); + }, resultsSelector); + console.log(links.join('\n')); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/json-mocha-reporter.js b/remote/test/puppeteer/json-mocha-reporter.js new file mode 100644 index 0000000000..ffe1d60675 --- /dev/null +++ b/remote/test/puppeteer/json-mocha-reporter.js @@ -0,0 +1,69 @@ +const mocha = require('mocha'); +module.exports = JSONExtra; + +const constants = mocha.Runner.constants; + +/* + +This is a copy of +https://github.com/mochajs/mocha/blob/master/lib/reporters/json-stream.js +with more event hooks. mocha does not support extending reporters or using +multiple reporters so a custom reporter is needed and it must be local +to the project. + +*/ + +function JSONExtra(runner, options) { + mocha.reporters.Base.call(this, runner, options); + mocha.reporters.JSON.call(this, runner, options); + const self = this; + + runner.once(constants.EVENT_RUN_BEGIN, function () { + writeEvent(['start', {total: runner.total}]); + }); + + runner.on(constants.EVENT_TEST_PASS, function (test) { + writeEvent(['pass', clean(test)]); + }); + + runner.on(constants.EVENT_TEST_FAIL, function (test, err) { + test = clean(test); + test.err = err.message; + test.stack = err.stack || null; + writeEvent(['fail', test]); + }); + + runner.once(constants.EVENT_RUN_END, function () { + writeEvent(['end', self.stats]); + }); + + runner.on(constants.EVENT_TEST_BEGIN, function (test) { + writeEvent(['test-start', clean(test)]); + }); + + runner.on(constants.EVENT_TEST_PENDING, function (test) { + writeEvent(['pending', clean(test)]); + }); +} + +function writeEvent(event) { + process.stdout.write(JSON.stringify(event) + '\n'); +} + +/** + * Returns an object literal representation of `test` + * free of cyclic properties, etc. + * + * @private + * @param {Object} test - Instance used as data source. + * @return {Object} object containing pared-down test instance data + */ +function clean(test) { + return { + title: test.title, + fullTitle: test.fullTitle(), + file: test.file, + duration: test.duration, + currentRetry: test.currentRetry(), + }; +} diff --git a/remote/test/puppeteer/moz.yaml b/remote/test/puppeteer/moz.yaml new file mode 100644 index 0000000000..5f732140c9 --- /dev/null +++ b/remote/test/puppeteer/moz.yaml @@ -0,0 +1,10 @@ +bugzilla: + component: Agent + product: Remote Protocol +origin: + description: Headless Chrome Node API + license: Apache-2.0 + name: puppeteer + release: puppeteer-v21.10.0 + url: ../puppeteer +schema: 1 diff --git a/remote/test/puppeteer/package-lock.json b/remote/test/puppeteer/package-lock.json new file mode 100644 index 0000000000..76878ce829 --- /dev/null +++ b/remote/test/puppeteer/package-lock.json @@ -0,0 +1,11657 @@ +{ + "name": "puppeteer-repo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "puppeteer-repo", + "hasInstallScript": true, + "workspaces": [ + "packages/*", + "test", + "test/installation", + "tools/eslint", + "tools/doctest", + "tools/docgen", + "tools/mocha-runner" + ], + "devDependencies": { + "@actions/core": "1.10.1", + "@types/mocha": "10.0.6", + "@types/node": "20.8.4", + "@types/semver": "7.5.6", + "@types/sinon": "17.0.3", + "@typescript-eslint/eslint-plugin": "6.19.1", + "@typescript-eslint/parser": "6.19.1", + "esbuild": "0.20.0", + "eslint": "8.56.0", + "eslint-config-prettier": "9.1.0", + "eslint-import-resolver-typescript": "3.6.1", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-mocha": "10.2.0", + "eslint-plugin-prettier": "5.1.3", + "eslint-plugin-rulesdir": "0.2.2", + "eslint-plugin-tsdoc": "0.2.17", + "eslint-plugin-unused-imports": "3.0.0", + "execa": "8.0.1", + "expect": "29.7.0", + "gts": "5.2.0", + "hereby": "1.8.9", + "license-checker": "25.0.1", + "mocha": "10.2.0", + "npm-run-all": "4.1.5", + "prettier": "3.2.4", + "semver": "7.5.4", + "sinon": "17.0.1", + "source-map-support": "0.5.21", + "spdx-satisfies": "5.0.1", + "tsd": "0.30.4", + "tsx": "4.7.0", + "typescript": "5.3.3", + "wireit": "0.14.4" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@actions/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.1.tgz", + "integrity": "sha512-3lBR9EDAY+iYIpTnTIXmWcNbX3T2kCkAEQGIQx4NVQ0575nk2k3GRZDTPQG+vVtS2izSLmINlxXf0uLtnrTP+g==", + "dev": true, + "dependencies": { + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.0.tgz", + "integrity": "sha512-q+epW0trjVUUHboliPb4UF9g2msf+w61b32tAkFEwL/IwP0DQWgbCMM0Hbe3e3WXSKz5VcUXbzJQgy8Hkra/Lg==", + "dev": true, + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz", + "integrity": "sha512-fGFDEctNh0CcSwsiRPxiaqX0P5rq+AqE0SRhYGZ4PX46Lg1FNR6oCxJghf8YgY0WQEgQuh3lErUFE4KxLeRmmw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.0.tgz", + "integrity": "sha512-3bMAfInvByLHfJwYPJRlpTeaQA75n8C/QKpEaiS4HrFWFiJlNI0vzq/zCjBrhAYcPyVPG7Eo9dMrcQXuqmNk5g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.0.tgz", + "integrity": "sha512-aVpnM4lURNkp0D3qPoAzSG92VXStYmoVPOgXveAUoQBWRSuQzt51yvSju29J6AHPmwY1BjH49uR29oyfH1ra8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.0.tgz", + "integrity": "sha512-uK7wAnlRvjkCPzh8jJ+QejFyrP8ObKuR5cBIsQZ+qbMunwR8sbd8krmMbxTLSrDhiPZaJYKQAU5Y3iMDcZPhyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.0.tgz", + "integrity": "sha512-AjEcivGAlPs3UAcJedMa9qYg9eSfU6FnGHJjT8s346HSKkrcWlYezGE8VaO2xKfvvlZkgAhyvl06OJOxiMgOYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.0.tgz", + "integrity": "sha512-bsgTPoyYDnPv8ER0HqnJggXK6RyFy4PH4rtsId0V7Efa90u2+EifxytE9pZnsDgExgkARy24WUQGv9irVbTvIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.0.tgz", + "integrity": "sha512-kQ7jYdlKS335mpGbMW5tEe3IrQFIok9r84EM3PXB8qBFJPSc6dpWfrtsC/y1pyrz82xfUIn5ZrnSHQQsd6jebQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.0.tgz", + "integrity": "sha512-uG8B0WSepMRsBNVXAQcHf9+Ko/Tr+XqmK7Ptel9HVmnykupXdS4J7ovSQUIi0tQGIndhbqWLaIL/qO/cWhXKyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.0.tgz", + "integrity": "sha512-2ezuhdiZw8vuHf1HKSf4TIk80naTbP9At7sOqZmdVwvvMyuoDiZB49YZKLsLOfKIr77+I40dWpHVeY5JHpIEIg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.0.tgz", + "integrity": "sha512-uTtyYAP5veqi2z9b6Gr0NUoNv9F/rOzI8tOD5jKcCvRUn7T60Bb+42NDBCWNhMjkQzI0qqwXkQGo1SY41G52nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.0.tgz", + "integrity": "sha512-c88wwtfs8tTffPaoJ+SQn3y+lKtgTzyjkD8NgsyCtCmtoIC8RDL7PrJU05an/e9VuAke6eJqGkoMhJK1RY6z4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.0.tgz", + "integrity": "sha512-lR2rr/128/6svngnVta6JN4gxSXle/yZEZL3o4XZ6esOqhyR4wsKyfu6qXAL04S4S5CgGfG+GYZnjFd4YiG3Aw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.0.tgz", + "integrity": "sha512-9Sycc+1uUsDnJCelDf6ZNqgZQoK1mJvFtqf2MUz4ujTxGhvCWw+4chYfDLPepMEvVL9PDwn6HrXad5yOrNzIsQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.0.tgz", + "integrity": "sha512-CoWSaaAXOZd+CjbUTdXIJE/t7Oz+4g90A3VBCHLbfuc5yUQU/nFDLOzQsN0cdxgXd97lYW/psIIBdjzQIwTBGw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.0.tgz", + "integrity": "sha512-mlb1hg/eYRJUpv8h/x+4ShgoNLL8wgZ64SUr26KwglTYnwAWjkhR2GpoKftDbPOCnodA9t4Y/b68H4J9XmmPzA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.0.tgz", + "integrity": "sha512-fgf9ubb53xSnOBqyvWEY6ukBNRl1mVX1srPNu06B6mNsNK20JfH6xV6jECzrQ69/VMiTLvHMicQR/PgTOgqJUQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz", + "integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.0.tgz", + "integrity": "sha512-lCT675rTN1v8Fo+RGrE5KjSnfY0x9Og4RN7t7lVrN3vMSjy34/+3na0q7RIfWDAj0e0rCh0OL+P88lu3Rt21MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.0.tgz", + "integrity": "sha512-HKoUGXz/TOVXKQ+67NhxyHv+aDSZf44QpWLa3I1lLvAwGq8x1k0T+e2HHSRvxWhfJrFxaaqre1+YyzQ99KixoA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.0.tgz", + "integrity": "sha512-GDwAqgHQm1mVoPppGsoq4WJwT3vhnz/2N62CzhvApFD1eJyTroob30FPpOZabN+FgCjhG+AgcZyOPIkR8dfD7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.0.tgz", + "integrity": "sha512-0vYsP8aC4TvMlOQYozoksiaxjlvUcQrac+muDqj1Fxy6jh9l9CZJzj7zmh8JGfiV49cYLTorFLxg7593pGldwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.0.tgz", + "integrity": "sha512-p98u4rIgfh4gdpV00IqknBD5pC84LCub+4a3MO+zjqvU5MVXOc3hqR2UgT2jI2nh3h8s9EQxmOsVI3tyzv1iFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.0.tgz", + "integrity": "sha512-NgJnesu1RtWihtTtXGFMU5YSE6JyyHPMxCwBZK7a6/8d31GuSo9l0Ss7w1Jw5QnKUawG6UEehs883kcXf5fYwg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", + "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@ljharb/through": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.12.tgz", + "integrity": "sha512-ajo/heTlG3QgC8EGP6APIejksVAYt4ayz4tqoP3MolFELzcH1x1fzwEYRJTPO0IELutZ5HQ0c26/GqAYy79u3g==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@microsoft/api-documenter": { + "version": "7.23.20", + "resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.23.20.tgz", + "integrity": "sha512-61V6sukyYZ5jQEdyvDFzInaIRTd0wgT2ECKPanr2ba0fc+Mien+KIr5shz9EAqJMZz0GifTnw9HmJqsfR688xA==", + "dev": true, + "dependencies": { + "@microsoft/api-extractor-model": "7.28.7", + "@microsoft/tsdoc": "0.14.2", + "@rushstack/node-core-library": "3.64.2", + "@rushstack/ts-command-line": "4.17.1", + "colors": "~1.2.1", + "js-yaml": "~3.13.1", + "resolve": "~1.22.1" + }, + "bin": { + "api-documenter": "bin/api-documenter" + } + }, + "node_modules/@microsoft/api-documenter/node_modules/js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@microsoft/api-extractor": { + "version": "7.39.4", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.39.4.tgz", + "integrity": "sha512-6YvfkpbEqRQ0UPdVBc+lOiq7VlXi9kw8U3w+RcXCFDVc/UljlXU5l9fHEyuBAW1GGO2opUe+yf9OscWhoHANhg==", + "dev": true, + "dependencies": { + "@microsoft/api-extractor-model": "7.28.7", + "@microsoft/tsdoc": "0.14.2", + "@microsoft/tsdoc-config": "~0.16.1", + "@rushstack/node-core-library": "3.64.2", + "@rushstack/rig-package": "0.5.1", + "@rushstack/ts-command-line": "4.17.1", + "colors": "~1.2.1", + "lodash": "~4.17.15", + "resolve": "~1.22.1", + "semver": "~7.5.4", + "source-map": "~0.6.1", + "typescript": "5.3.3" + }, + "bin": { + "api-extractor": "bin/api-extractor" + } + }, + "node_modules/@microsoft/api-extractor-model": { + "version": "7.28.7", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.28.7.tgz", + "integrity": "sha512-4gCGGEQGHmbQmarnDcEWS2cjj0LtNuD3D6rh3ZcAyAYTkceAugAk2eyQHGdTcGX8w3qMjWCTU1TPb8xHnMM+Kg==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.14.2", + "@microsoft/tsdoc-config": "~0.16.1", + "@rushstack/node-core-library": "3.64.2" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", + "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==", + "dev": true + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz", + "integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.14.2", + "ajv": "~6.12.6", + "jju": "~1.4.0", + "resolve": "~1.19.0" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/resolve": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", + "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "dev": true, + "dependencies": { + "is-core-module": "^2.1.0", + "path-parse": "^1.0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz", + "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/@npmcli/fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", + "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz", + "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==", + "dev": true, + "dependencies": { + "@npmcli/promise-spawn": "^7.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^9.0.0", + "proc-log": "^3.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz", + "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==", + "dev": true, + "dependencies": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "bin": { + "installed-package-contents": "lib/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/installed-package-contents/node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", + "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.0.0.tgz", + "integrity": "sha512-OI2zdYBLhQ7kpNPaJxiflofYIpkNLi+lnGdzqUOfRmCF3r2l1nadcjtCYMJKv/Utm/ZtlffaUuTiAktPHbc17g==", + "dev": true, + "dependencies": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^3.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/hosted-git-info": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", + "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/json-parse-even-better-errors": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", + "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/lru-cache": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/@npmcli/package-json/node_modules/normalize-package-data": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz", + "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==", + "dev": true, + "dependencies": { + "hosted-git-info": "^7.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz", + "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==", + "dev": true, + "dependencies": { + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.4.tgz", + "integrity": "sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg==", + "dev": true, + "dependencies": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.0.0", + "@npmcli/promise-spawn": "^7.0.0", + "node-gyp": "^10.0.0", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/run-script/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/run-script/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@pptr/testserver": { + "resolved": "packages/testserver", + "link": true + }, + "node_modules/@prettier/sync": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@prettier/sync/-/sync-0.5.0.tgz", + "integrity": "sha512-1a6veNypZYkSbU33anha4Pdna9Jz3HXUc0aru7sgN7HuyJHPIVNdCTfjhm1S+mG9yXmWuAO+a6I+Cznp9Ogt3A==", + "dev": true, + "dependencies": { + "make-synchronized": "^0.2.5" + }, + "funding": { + "url": "https://github.com/prettier/prettier-synchronized?sponsor=1" + }, + "peerDependencies": { + "prettier": "*" + } + }, + "node_modules/@puppeteer-test/installation": { + "resolved": "test/installation", + "link": true + }, + "node_modules/@puppeteer-test/test": { + "resolved": "test", + "link": true + }, + "node_modules/@puppeteer/browsers": { + "resolved": "packages/browsers", + "link": true + }, + "node_modules/@puppeteer/docgen": { + "resolved": "tools/docgen", + "link": true + }, + "node_modules/@puppeteer/doctest": { + "resolved": "tools/doctest", + "link": true + }, + "node_modules/@puppeteer/eslint": { + "resolved": "tools/eslint", + "link": true + }, + "node_modules/@puppeteer/mocha-runner": { + "resolved": "tools/mocha-runner", + "link": true + }, + "node_modules/@puppeteer/ng-schematics": { + "resolved": "packages/ng-schematics", + "link": true + }, + "node_modules/@rushstack/node-core-library": { + "version": "3.64.2", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.64.2.tgz", + "integrity": "sha512-n1S2VYEklONiwKpUyBq/Fym6yAsfsCXrqFabuOMcCuj4C+zW+HyaspSHXJCKqkMxfjviwe/c9+DUqvRWIvSN9Q==", + "dev": true, + "dependencies": { + "colors": "~1.2.1", + "fs-extra": "~7.0.1", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.5.4", + "z-schema": "~5.0.2" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/rig-package": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.1.tgz", + "integrity": "sha512-pXRYSe29TjRw7rqxD4WS3HN/sRSbfr+tJs4a9uuaSIBAITbUggygdhuG0VrO0EO+QqH91GhYMN4S6KRtOEmGVA==", + "dev": true, + "dependencies": { + "resolve": "~1.22.1", + "strip-json-comments": "~3.1.1" + } + }, + "node_modules/@rushstack/ts-command-line": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.17.1.tgz", + "integrity": "sha512-2jweO1O57BYP5qdBGl6apJLB+aRIn5ccIRTPDyULh0KMwVzFqWtw6IZWt1qtUoZD/pD2RNkIOosH6Cq45rIYeg==", + "dev": true, + "dependencies": { + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "colors": "~1.2.1", + "string-argv": "~0.3.1" + } + }, + "node_modules/@sigstore/bundle": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.1.tgz", + "integrity": "sha512-v3/iS+1nufZdKQ5iAlQKcCsoh0jffQyABvYIxKsZQFWc4ubuGjwZklFHpDgV6O6T7vvV78SW5NHI91HFKEcxKg==", + "dev": true, + "dependencies": { + "@sigstore/protobuf-specs": "^0.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/core": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-0.2.0.tgz", + "integrity": "sha512-THobAPPZR9pDH2CAvDLpkrYedt7BlZnsyxDe+Isq4ZmGfPy5juOFZq487vCU2EgKD7aHSiTfE/i7sN7aEdzQnA==", + "dev": true, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz", + "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.1.tgz", + "integrity": "sha512-U5sKQEj+faE1MsnLou1f4DQQHeFZay+V9s9768lw48J4pKykPj34rWyI1lsMOGJ3Mae47Ye6q3HAJvgXO21rkQ==", + "dev": true, + "dependencies": { + "@sigstore/bundle": "^2.1.1", + "@sigstore/core": "^0.2.0", + "@sigstore/protobuf-specs": "^0.2.1", + "make-fetch-happen": "^13.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.0.tgz", + "integrity": "sha512-S98jo9cpJwO1mtQ+2zY7bOdcYyfVYCUaofCG6wWRzk3pxKHVAkSfshkfecto2+LKsx7Ovtqbgb2LS8zTRhxJ9Q==", + "dev": true, + "dependencies": { + "@sigstore/protobuf-specs": "^0.2.1", + "tuf-js": "^2.2.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-0.1.0.tgz", + "integrity": "sha512-2UzMNYAa/uaz11NhvgRnIQf4gpLTJ59bhb8ESXaoSS5sxedfS+eLak8bsdMc+qpNQfITUTFoSKFx5h8umlRRiA==", + "dev": true, + "dependencies": { + "@sigstore/bundle": "^2.1.1", + "@sigstore/core": "^0.2.0", + "@sigstore/protobuf-specs": "^0.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, + "node_modules/@swc/core": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.107.tgz", + "integrity": "sha512-zKhqDyFcTsyLIYK1iEmavljZnf4CCor5pF52UzLAz4B6Nu/4GLU+2LQVAf+oRHjusG39PTPjd2AlRT3f3QWfsQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@swc/counter": "^0.1.1", + "@swc/types": "^0.1.5" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.3.107", + "@swc/core-darwin-x64": "1.3.107", + "@swc/core-linux-arm-gnueabihf": "1.3.107", + "@swc/core-linux-arm64-gnu": "1.3.107", + "@swc/core-linux-arm64-musl": "1.3.107", + "@swc/core-linux-x64-gnu": "1.3.107", + "@swc/core-linux-x64-musl": "1.3.107", + "@swc/core-win32-arm64-msvc": "1.3.107", + "@swc/core-win32-ia32-msvc": "1.3.107", + "@swc/core-win32-x64-msvc": "1.3.107" + }, + "peerDependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.107.tgz", + "integrity": "sha512-47tD/5vSXWxPd0j/ZllyQUg4bqalbQTsmqSw0J4dDdS82MWqCAwUErUrAZPRjBkjNQ6Kmrf5rpCWaGTtPw+ngw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.107.tgz", + "integrity": "sha512-hwiLJ2ulNkBGAh1m1eTfeY1417OAYbRGcb/iGsJ+LuVLvKAhU/itzsl535CvcwAlt2LayeCFfcI8gdeOLeZa9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.107.tgz", + "integrity": "sha512-I2wzcC0KXqh0OwymCmYwNRgZ9nxX7DWnOOStJXV3pS0uB83TXAkmqd7wvMBuIl9qu4Hfomi9aDM7IlEEn9tumQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.107.tgz", + "integrity": "sha512-HWgnn7JORYlOYnGsdunpSF8A+BCZKPLzLtEUA27/M/ZuANcMZabKL9Zurt7XQXq888uJFAt98Gy+59PU90aHKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.107.tgz", + "integrity": "sha512-vfPF74cWfAm8hyhS8yvYI94ucMHIo8xIYU+oFOW9uvDlGQRgnUf/6DEVbLyt/3yfX5723Ln57U8uiMALbX5Pyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.107.tgz", + "integrity": "sha512-uBVNhIg0ip8rH9OnOsCARUFZ3Mq3tbPHxtmWk9uAa5u8jQwGWeBx5+nTHpDOVd3YxKb6+5xDEI/edeeLpha/9g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.107.tgz", + "integrity": "sha512-mvACkUvzSIB12q1H5JtabWATbk3AG+pQgXEN95AmEX2ZA5gbP9+B+mijsg7Sd/3tboHr7ZHLz/q3SHTvdFJrEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.107.tgz", + "integrity": "sha512-J3P14Ngy/1qtapzbguEH41kY109t6DFxfbK4Ntz9dOWNuVY3o9/RTB841ctnJk0ZHEG+BjfCJjsD2n8H5HcaOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.107.tgz", + "integrity": "sha512-ZBUtgyjTHlz8TPJh7kfwwwFma+ktr6OccB1oXC8fMSopD0AxVnQasgun3l3099wIsAB9eEsJDQ/3lDkOLs1gBA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.3.107", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.107.tgz", + "integrity": "sha512-Eyzo2XRqWOxqhE1gk9h7LWmUf4Bp4Xn2Ttb0ayAXFp6YSTxQIThXcT9kipXZqcpxcmDwoq8iWbbf2P8XL743EA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz", + "integrity": "sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==", + "dev": true + }, + "node_modules/@swc/types": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", + "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", + "dev": true + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" + }, + "node_modules/@tsd/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-CQlfzol0ldaU+ftWuG52vH29uRoKboLinLy84wS8TQOu+m+tWoaUfk4svL4ij2V8M5284KymJBlHUusKj6k34w==", + "dev": true, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz", + "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==", + "dev": true, + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "dev": true + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/diff": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.0.9.tgz", + "integrity": "sha512-RWVEhh/zGXpAVF/ZChwNnv7r4rvqzJ7lYNSmZSVTxjV0PBLf6Qu7RNg+SUtkpzxmiNkjCx0Xn2tPp7FIkshJwQ==", + "dev": true + }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true + }, + "node_modules/@types/eslint": { + "version": "8.56.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", + "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.4.tgz", + "integrity": "sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==", + "dev": true + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true + }, + "node_modules/@types/mocha": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz", + "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==", + "dev": true + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.8.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz", + "integrity": "sha512-ZVPnqU58giiCjSxjVUESDtdPk4QR5WQhhINbc9UBrKLU68MX5BF6kbQzTrkwbolyr0X8ChBpXfavr5mZFKZQ5A==", + "devOptional": true, + "dependencies": { + "undici-types": "~5.25.1" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true + }, + "node_modules/@types/pixelmatch": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.6.tgz", + "integrity": "sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pngjs": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.4.tgz", + "integrity": "sha512-atAK9xLKOnxiuArxcHovmnOUUGBZOQ3f0vCf43FnoKs6XnqiambT1kkJWmdo71IR+BoXSh+CueeFR0GfH3dTlQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/progress": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/progress/-/progress-2.0.7.tgz", + "integrity": "sha512-iadjw02vte8qWx7U0YM++EybBha2CQLPGu9iJ97whVgJUT5Zq9MjAPYUnbfRI2Kpehimf1QjFJYxD0t8nqzu5w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/semver": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", + "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", + "dev": true + }, + "node_modules/@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, + "node_modules/@types/source-map-support": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.10.tgz", + "integrity": "sha512-tgVP2H469x9zq34Z0m/fgPewGhg/MLClalNOiPIzQlXrSS2YrKu/xCdSCKnEDwkFha51VKEKB6A9wW26/ZNwzA==", + "dev": true, + "dependencies": { + "source-map": "^0.6.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/tar-fs": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/tar-fs/-/tar-fs-2.0.4.tgz", + "integrity": "sha512-ipPec0CjTmVDWE+QKr9cTmIIoTl7dFG/yARCM5MqK8i6CNLIG1P8x4kwDsOQY1ChZOZjH0wO9nvfgBvWl4R3kA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tar-stream": "*" + } + }, + "node_modules/@types/tar-stream": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.3.tgz", + "integrity": "sha512-Zbnx4wpkWBMBSu5CytMbrT5ZpMiF55qgM+EpHzR4yIDu7mv52cej8hTkOc6K+LzpkOAbxwn/m7j3iO+/l42YkQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-D8X5uuJRISqc8YtwL8jNW2FpPdUOCYXbfD6zNROCTbVXK9nawucxh10tVXE3MPjnHdRA1LvB0zDxVya/lBsnYw==", + "dev": true, + "dependencies": { + "@types/through": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.19.1.tgz", + "integrity": "sha512-roQScUGFruWod9CEyoV5KlCYrubC/fvG8/1zXuT0WTcxX87GnMMmnksMwSg99lo1xiKrBzw2icsJPMAw1OtKxg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/type-utils": "6.19.1", + "@typescript-eslint/utils": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.19.1.tgz", + "integrity": "sha512-WEfX22ziAh6pRE9jnbkkLGp/4RhTpffr2ZK5bJ18M8mIfA8A+k97U9ZyaXCEJRlmMHh7R9MJZWXp/r73DzINVQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/typescript-estree": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.1.tgz", + "integrity": "sha512-4CdXYjKf6/6aKNMSly/BP4iCSOpvMmqtDzRtqFyyAae3z5kkqEjKndR5vDHL8rSuMIIWP8u4Mw4VxLyxZW6D5w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.1.tgz", + "integrity": "sha512-0vdyld3ecfxJuddDjACUvlAeYNrHP/pDeQk2pWBR2ESeEzQhg52DF53AbI9QCBkYE23lgkhLCZNkHn2hEXXYIg==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.19.1", + "@typescript-eslint/utils": "6.19.1", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.1.tgz", + "integrity": "sha512-6+bk6FEtBhvfYvpHsDgAL3uo4BfvnTnoge5LrrCj2eJN8g3IJdLTD4B/jK3Q6vo4Ql/Hoip9I8aB6fF+6RfDqg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.1.tgz", + "integrity": "sha512-aFdAxuhzBFRWhy+H20nYu19+Km+gFfwNO4TEqyszkMcgBDYQjmPJ61erHxuT2ESJXhlhrO7I5EFIlZ+qGR8oVA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.1.tgz", + "integrity": "sha512-JvjfEZuP5WoMqwh9SPAPDSHSg9FBHHGhjPugSRxu5jMfjvBpq5/sGTD+9M9aQ5sh6iJ8AY/Kk/oUYVEMAPwi7w==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/typescript-estree": "6.19.1", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.1.tgz", + "integrity": "sha512-gkdtIO+xSO/SmI0W68DBg4u1KElmIUo3vXzgHyGPs6cxgB0sa3TlptRAAE0hUY1hM6FcDKEv7aIwiTGm76cXfQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.1", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-back": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", + "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-includes": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/basic-ftp": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.4.tgz", + "integrity": "sha512-8PzkB0arJFV4jJWSGOYR+OEic6aeKMu/osRhBULN6RY0ykby6LKhbmuQ5ublvaas5BOwboah5D87nrHyuh8PPA==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/builtins": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", + "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==", + "dev": true, + "dependencies": { + "semver": "^7.0.0" + } + }, + "node_modules/c8": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", + "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=14.14.0" + } + }, + "node_modules/c8/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/c8/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/c8/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz", + "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==", + "dev": true, + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-bidi": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.5.6.tgz", + "integrity": "sha512-ber8smgoAs4EqSUHRb0I8fpx371ZmvsdQav8HRM9oO4fk5Ox16vQiNYXlsZkRj4FfvVL2dCef+zBFQixp+79CA==", + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/colors": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.2.5.tgz", + "integrity": "sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/command-line-usage": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.3.tgz", + "integrity": "sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==", + "dev": true, + "dependencies": { + "array-back": "^4.0.2", + "chalk": "^2.4.2", + "table-layout": "^1.0.2", + "typical": "^5.2.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/command-line-usage/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/command-line-usage/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/command-line-usage/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/command-line-usage/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/command-line-usage/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/command-line-usage/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/command-line-usage/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.1.tgz", + "integrity": "sha512-MZd3VlchQkp8rdend6vrx7MmVDJzSNTBvghvKjirLkD+WTChA3KUf0jkE68Q4UyctNqI11zZO9/x2Yx+ub5Cvg==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/debuglog": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", + "integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1232444", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1232444.tgz", + "integrity": "sha512-pM27vqEfxSxRkTMnF+XCmxSEb6duO5R+t8A9DEEJgy4Wz2RVanje2mmj99B6A3zv2r/qGfYlOvYznUhuokizmg==" + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.0.tgz", + "integrity": "sha512-6iwE3Y2RVYCME1jLpBqq7LQWK3MW6vjV2bZy6gt/WrqkY+WE74Spyc0ThAOYpMtITvnjX09CrC6ym7A/m9mebA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.0", + "@esbuild/android-arm": "0.20.0", + "@esbuild/android-arm64": "0.20.0", + "@esbuild/android-x64": "0.20.0", + "@esbuild/darwin-arm64": "0.20.0", + "@esbuild/darwin-x64": "0.20.0", + "@esbuild/freebsd-arm64": "0.20.0", + "@esbuild/freebsd-x64": "0.20.0", + "@esbuild/linux-arm": "0.20.0", + "@esbuild/linux-arm64": "0.20.0", + "@esbuild/linux-ia32": "0.20.0", + "@esbuild/linux-loong64": "0.20.0", + "@esbuild/linux-mips64el": "0.20.0", + "@esbuild/linux-ppc64": "0.20.0", + "@esbuild/linux-riscv64": "0.20.0", + "@esbuild/linux-s390x": "0.20.0", + "@esbuild/linux-x64": "0.20.0", + "@esbuild/netbsd-x64": "0.20.0", + "@esbuild/openbsd-x64": "0.20.0", + "@esbuild/sunos-x64": "0.20.0", + "@esbuild/win32-arm64": "0.20.0", + "@esbuild/win32-ia32": "0.20.0", + "@esbuild/win32-x64": "0.20.0" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", + "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.56.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-formatter-pretty": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-formatter-pretty/-/eslint-formatter-pretty-4.1.0.tgz", + "integrity": "sha512-IsUTtGxF1hrH6lMWiSl1WbGaiP01eT6kzywdY1U+zLc0MP+nwEnUiS9UI8IaOTUhTeQJLlCEWIbXINBH4YJbBQ==", + "dev": true, + "dependencies": { + "@types/eslint": "^7.2.13", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "eslint-rule-docs": "^1.1.5", + "log-symbols": "^4.0.0", + "plur": "^4.0.0", + "string-width": "^4.2.0", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-formatter-pretty/node_modules/@types/eslint": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz", + "integrity": "sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", + "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "enhanced-resolve": "^5.12.0", + "eslint-module-utils": "^2.7.4", + "fast-glob": "^3.3.1", + "get-tsconfig": "^4.5.0", + "is-core-module": "^2.11.0", + "is-glob": "^4.0.3" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", + "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", + "dev": true, + "dependencies": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-es/node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-plugin-es/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.2.0.tgz", + "integrity": "sha512-ZhdxzSZnd1P9LqDPF0DBcFLpRIGdh1zkF2JHnQklKQOvrQtT73kdP5K9V2mzvbLR+cCAO9OI48NXK/Ax9/ciCQ==", + "dev": true, + "dependencies": { + "eslint-utils": "^3.0.0", + "rambda": "^7.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-node": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", + "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", + "dev": true, + "dependencies": { + "eslint-plugin-es": "^3.0.0", + "eslint-utils": "^2.0.0", + "ignore": "^5.1.1", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "peerDependencies": { + "eslint": ">=5.16.0" + } + }, + "node_modules/eslint-plugin-node/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-node/node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-plugin-node/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-plugin-node/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-node/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-rulesdir": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.2.2.tgz", + "integrity": "sha512-qhBtmrWgehAIQeMDJ+Q+PnOz1DWUZMPeVrI0wE9NZtnpIMFUfh3aPKFYt2saeMSemZRrvUtjWfYwepsC8X+mjQ==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eslint-plugin-tsdoc": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.2.17.tgz", + "integrity": "sha512-xRmVi7Zx44lOBuYqG8vzTXuL6IdGOeF9nHX17bjJ8+VE6fsxpdGem0/SBTmAwgYMKYB1WBkqRJVQ+n8GK041pA==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.14.2", + "@microsoft/tsdoc-config": "0.16.2" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.0.0.tgz", + "integrity": "sha512-sduiswLJfZHeeBJ+MQaG+xYzSWdRXoSw61DpU13mzWumCkR0ufD0HmO4kdNokjrkluMHpj/7PJeN35pgbhW3kw==", + "dev": true, + "dependencies": { + "eslint-rule-composer": "^0.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^6.0.0", + "eslint": "^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-rule-composer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", + "integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eslint-rule-docs": { + "version": "1.1.235", + "resolved": "https://registry.npmjs.org/eslint-rule-docs/-/eslint-rule-docs-1.1.235.tgz", + "integrity": "sha512-+TQ+x4JdTnDoFEXXb3fDvfGOwnyNV7duH8fXWTPD1ieaBmB8omj7Gw/pMBBu4uI2uJCCU8APDaQJzWuXnTsH4A==", + "dev": true + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "dev": true + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", + "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.0.tgz", + "integrity": "sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", + "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/get-uri": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.2.tgz", + "integrity": "sha512-5KLucCJobh8vBY1K07EFV4+cPZH3mrV9YeAruUseCQKHB58SGjjT2l9/eA9LD082IiuMjSlFJEcdJ27TXvbZNw==", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.0", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/gts": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/gts/-/gts-5.2.0.tgz", + "integrity": "sha512-25qOnePUUX7upFc4ycqWersDBq+o1X6hXUTW56JOWCxPYKJXQ1RWzqT9q+2SU3LfPKJf+4sz4Dw3VT0p96Kv6g==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "5.62.0", + "@typescript-eslint/parser": "5.62.0", + "chalk": "^4.1.2", + "eslint": "8.50.0", + "eslint-config-prettier": "9.0.0", + "eslint-plugin-node": "11.1.0", + "eslint-plugin-prettier": "5.0.0", + "execa": "^5.0.0", + "inquirer": "^7.3.3", + "json5": "^2.1.3", + "meow": "^9.0.0", + "ncp": "^2.0.0", + "prettier": "3.0.3", + "rimraf": "3.0.2", + "write-file-atomic": "^4.0.0" + }, + "bin": { + "gts": "build/src/cli.js" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "typescript": ">=3" + } + }, + "node_modules/gts/node_modules/@eslint/js": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.50.0.tgz", + "integrity": "sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/gts/node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/gts/node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/gts/node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/gts/node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/gts/node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/gts/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/gts/node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/gts/node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/gts/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/gts/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/gts/node_modules/eslint": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.50.0.tgz", + "integrity": "sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.2", + "@eslint/js": "8.50.0", + "@humanwhocodes/config-array": "^0.11.11", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/gts/node_modules/eslint-config-prettier": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", + "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/gts/node_modules/eslint-plugin-prettier": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.0.tgz", + "integrity": "sha512-AgaZCVuYDXHUGxj/ZGu1u8H8CYgDY3iG6w5kUFw4AzMVXzB7VvbKgYR4nATIN+OvUrghMbiDLeimVjVY5ilq3w==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.5" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/gts/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/gts/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/gts/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gts/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/gts/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gts/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/gts/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gts/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gts/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gts/node_modules/prettier": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", + "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/gts/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/gts/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hereby": { + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/hereby/-/hereby-1.8.9.tgz", + "integrity": "sha512-BM/Btsy77GGhuHujCdr2e0jBh3ubrjJcq8M2E/BGQ0O8M9K2uB1PfVSh/LtItAuf9TFe0l8XStaKugMjbYgs4Q==", + "dev": true, + "dependencies": { + "command-line-usage": "^6.1.3", + "fastest-levenshtein": "^1.0.16", + "import-meta-resolve": "^2.2.2", + "minimist": "^1.2.8", + "picocolors": "^1.0.0", + "pretty-ms": "^8.0.0" + }, + "bin": { + "hereby": "bin/hereby.js" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", + "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", + "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz", + "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==", + "dev": true, + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-meta-resolve": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-2.2.2.tgz", + "integrity": "sha512-f8KcQ1D80V7RnqVm+/lirO9zkOxjGxhaTC1IPrBGd3MEfNgmNG67tSUO9gTi2F3Blr2Az6g1vocaxzkVnWl9MA==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/inquirer": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.19", + "mute-stream": "0.0.8", + "run-async": "^2.4.0", + "rxjs": "^6.6.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz", + "integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==" + }, + "node_modules/irregular-plurals": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", + "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/license-checker": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/license-checker/-/license-checker-25.0.1.tgz", + "integrity": "sha512-mET5AIwl7MR2IAKYYoVBBpV0OnkKQ1xGj2IMMeEFIs42QAkEVjRtFZGWmQ28WeU7MP779iAgOaOy93Mn44mn6g==", + "dev": true, + "dependencies": { + "chalk": "^2.4.1", + "debug": "^3.1.0", + "mkdirp": "^0.5.1", + "nopt": "^4.0.1", + "read-installed": "~4.0.3", + "semver": "^5.5.0", + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0", + "spdx-satisfies": "^4.0.0", + "treeify": "^1.1.0" + }, + "bin": { + "license-checker": "bin/license-checker" + } + }, + "node_modules/license-checker/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/license-checker/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/license-checker/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/license-checker/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/license-checker/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/license-checker/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/license-checker/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/license-checker/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/license-checker/node_modules/spdx-satisfies": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/spdx-satisfies/-/spdx-satisfies-4.0.1.tgz", + "integrity": "sha512-WVzZ/cXAzoNmjCWiEluEA3BjHp5tiUmmhn9MK+X0tBbR9sOqtC6UQwmgCNrAIZvNlMuBUYAaHYfb2oqlF9SwKA==", + "dev": true, + "dependencies": { + "spdx-compare": "^1.0.0", + "spdx-expression-parse": "^3.0.0", + "spdx-ranges": "^2.0.0" + } + }, + "node_modules/license-checker/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-fetch-happen": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz", + "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==", + "dev": true, + "dependencies": { + "@npmcli/agent": "^2.0.0", + "cacache": "^18.0.0", + "http-cache-semantics": "^4.1.1", + "is-lambda": "^1.0.1", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/make-synchronized": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/make-synchronized/-/make-synchronized-0.2.7.tgz", + "integrity": "sha512-tbTJaNgmKV3E6yYxEN5djObcMt0j1WB2ltn8JteZYczrdFkGMor3KAraPGUf4NJsf5u+FvJbgbGGL35N3J6VVw==", + "dev": true, + "funding": { + "url": "https://github.com/fisker/make-synchronized?sponsor=1" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/meow": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", + "dev": true, + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize": "^1.2.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz", + "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-json-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz", + "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==", + "dev": true, + "dependencies": { + "jsonparse": "^1.3.1", + "minipass": "^3.0.0" + } + }, + "node_modules/minipass-json-stream/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", + "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "dependencies": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true + }, + "node_modules/ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", + "dev": true, + "bin": { + "ncp": "bin/ncp" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/nise": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.7.tgz", + "integrity": "sha512-wWtNUhkT7k58uvWTB/Gy26eA/EJKtPZFVAhEilN5UYVmmGRYOURbejRUyKm0Uu9XVEW7K5nBOZfR8VMB4QR2RQ==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz", + "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^4.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz", + "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==", + "dev": true, + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/nopt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", + "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", + "dev": true, + "dependencies": { + "abbrev": "1", + "osenv": "^0.1.4" + }, + "bin": { + "nopt": "bin/nopt.js" + } + }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-bundled": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz", + "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==", + "dev": true, + "dependencies": { + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-bundled/node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-install-checks": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", + "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "dev": true, + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", + "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", + "dev": true + }, + "node_modules/npm-package-arg": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz", + "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==", + "dev": true, + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/hosted-git-info": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", + "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/lru-cache": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/npm-packlist": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz", + "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==", + "dev": true, + "dependencies": { + "ignore-walk": "^6.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz", + "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==", + "dev": true, + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^11.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-pick-manifest/node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz", + "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==", + "dev": true, + "dependencies": { + "make-fetch-happen": "^13.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-json-stream": "^1.0.1", + "minizlib": "^2.1.2", + "npm-package-arg": "^11.0.0", + "proc-log": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-all/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm-run-all/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/npm-run-all/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/npm-run-all/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/npm-run-all/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/npm-run-all/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/npm-run-all/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/npm-run-path": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", + "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "dependencies": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz", + "integrity": "sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "pac-resolver": "^7.0.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.0.tgz", + "integrity": "sha512-Fd9lT9vJbHYRACT8OhCbZBbxr6KRSawSovFpy8nDGshaK99S/EBhVIHp9+crhxrsZOuvLpgL1n23iyPg6Rl2hg==", + "dependencies": { + "degenerator": "^5.0.0", + "ip": "^1.1.8", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parse-ms": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz", + "integrity": "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parsel-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/parsel-js/-/parsel-js-1.1.2.tgz", + "integrity": "sha512-D66DG2nKx4Yoq66TMEyCUHlR2STGqO7vsBrX7tgyS9cfQyO6XD5JyzOiflwmWN6a4wbUAqpmHqmrxlTQVGZcbA==", + "dev": true + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", + "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/pkg-dir": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-8.0.0.tgz", + "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", + "dev": true, + "dependencies": { + "find-up-simple": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/plur": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", + "integrity": "sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==", + "dev": true, + "dependencies": { + "irregular-plurals": "^3.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", + "integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-ms": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-8.0.0.tgz", + "integrity": "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==", + "dev": true, + "dependencies": { + "parse-ms": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proc-log": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", + "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/proxy-agent": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.1.tgz", + "integrity": "sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer": { + "resolved": "packages/puppeteer", + "link": true + }, + "node_modules/puppeteer-core": { + "resolved": "packages/puppeteer-core", + "link": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, + "node_modules/quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/rambda": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/rambda/-/rambda-7.5.0.tgz", + "integrity": "sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA==", + "dev": true + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/read-installed": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/read-installed/-/read-installed-4.0.3.tgz", + "integrity": "sha512-O03wg/IYuV/VtnK2h/KXEt9VIbMUFbk3ERG0Iu4FhLZw0EP0T9znqrYDGn6ncbEsXUFaUjiVAWXHzxwt3lhRPQ==", + "dev": true, + "dependencies": { + "debuglog": "^1.0.1", + "read-package-json": "^2.0.0", + "readdir-scoped-modules": "^1.0.0", + "semver": "2 || 3 || 4 || 5", + "slide": "~1.1.3", + "util-extend": "^1.0.1" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.2" + } + }, + "node_modules/read-installed/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-package-json": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-2.1.2.tgz", + "integrity": "sha512-D1KmuLQr6ZSJS0tW8hf3WGpRlwszJOXZ3E8Yd/DNRaM5d+1wVRZdHlpGBLAuovjr28LbWvjpWkBHMxpRGGjzNA==", + "dev": true, + "dependencies": { + "glob": "^7.1.1", + "json-parse-even-better-errors": "^2.3.0", + "normalize-package-data": "^2.0.0", + "npm-normalize-package-bin": "^1.0.0" + } + }, + "node_modules/read-package-json-fast": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", + "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", + "dev": true, + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json-fast/node_modules/json-parse-even-better-errors": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", + "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json-fast/node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/read-package-json/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/read-package-json/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/read-package-json/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/read-package-json/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-package-json/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-scoped-modules": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz", + "integrity": "sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "dependencies": { + "debuglog": "^1.0.1", + "dezalgo": "^1.0.0", + "graceful-fs": "^4.1.2", + "once": "^1.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reduce-flatten": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz", + "integrity": "sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.2.tgz", + "integrity": "sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", + "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.2", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.2.0.tgz", + "integrity": "sha512-fcU9clHwEss2/M/11FFM8Jwc4PjBgbhXoNskoK5guoK0qGQBSeUbQZRJ+B2fDFIvhyf0gqCaPrel9mszbhAxug==", + "dev": true, + "dependencies": { + "@sigstore/bundle": "^2.1.1", + "@sigstore/core": "^0.2.0", + "@sigstore/protobuf-specs": "^0.2.1", + "@sigstore/sign": "^2.2.1", + "@sigstore/tuf": "^2.3.0", + "@sigstore/verify": "^0.1.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slide": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", + "integrity": "sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz", + "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks/node_modules/ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/spdx-compare/-/spdx-compare-1.0.0.tgz", + "integrity": "sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A==", + "dev": true, + "dependencies": { + "array-find-index": "^1.0.2", + "spdx-expression-parse": "^3.0.0", + "spdx-ranges": "^2.0.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "dev": true + }, + "node_modules/spdx-ranges": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/spdx-ranges/-/spdx-ranges-2.1.1.tgz", + "integrity": "sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==", + "dev": true + }, + "node_modules/spdx-satisfies": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/spdx-satisfies/-/spdx-satisfies-5.0.1.tgz", + "integrity": "sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw==", + "dev": true, + "dependencies": { + "spdx-compare": "^1.0.0", + "spdx-expression-parse": "^3.0.0", + "spdx-ranges": "^2.0.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/ssri": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", + "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/streamx": { + "version": "2.15.6", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz", + "integrity": "sha512-q+vQL4AAz+FdfT137VF69Cc/APqUbxy+MDOImRrMvchJpigHj9GksgDU2LYbO9rx7RX6osWgxJB2WxhYv4SZAw==", + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/synckit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", + "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/synckit/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/table-layout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-1.0.2.tgz", + "integrity": "sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==", + "dev": true, + "dependencies": { + "array-back": "^4.0.1", + "deep-extend": "~0.6.0", + "typical": "^5.2.0", + "wordwrapjs": "^4.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", + "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "dependencies": { + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/treeify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", + "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-api-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "dev": true, + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsd": { + "version": "0.30.4", + "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.30.4.tgz", + "integrity": "sha512-ncC4SwAeUk0OTcXt5h8l0/gOLHJSp9ogosvOADT6QYzrl0ITm398B3wkz8YESqefIsEEwvYAU8bvo7/rcN/M0Q==", + "dev": true, + "dependencies": { + "@tsd/typescript": "~5.3.3", + "eslint-formatter-pretty": "^4.1.0", + "globby": "^11.0.1", + "jest-diff": "^29.0.3", + "meow": "^9.0.0", + "path-exists": "^4.0.0", + "read-pkg-up": "^7.0.0" + }, + "bin": { + "tsd": "dist/cli.js" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsx": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.0.tgz", + "integrity": "sha512-I+t79RYPlEYlHn9a+KzwrvEwhJg35h/1zHsLC2JXvhC2mdynMv6Zxzvhv5EMV6VF5qJlLlkSnMVvdZV3PSIGcg==", + "dev": true, + "dependencies": { + "esbuild": "~0.19.10", + "get-tsconfig": "^4.7.2" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/tuf-js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.0.tgz", + "integrity": "sha512-ZSDngmP1z6zw+FIkIBjvOp/II/mIub/O7Pp12j1WNsiCpg5R5wAc//i555bBQsE44O94btLt0xM/Zr2LQjwdCg==", + "dev": true, + "dependencies": { + "@tufjs/models": "2.0.0", + "debug": "^4.3.4", + "make-fetch-happen": "^13.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typical": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", + "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/undici": { + "version": "5.28.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.2.tgz", + "integrity": "sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==", + "dev": true, + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "devOptional": true + }, + "node_modules/unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "dev": true, + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/util-extend": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/util-extend/-/util-extend-1.0.3.tgz", + "integrity": "sha512-mLs5zAK+ctllYBj+iAQvlDCwoxU/WDOUaJkcFudeiAX6OajC6BKXJUa9a+tbtkC11dz2Ufb7h0lyvIOVn4LADA==", + "dev": true + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", + "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz", + "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==", + "dev": true, + "dependencies": { + "builtins": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wireit": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/wireit/-/wireit-0.14.4.tgz", + "integrity": "sha512-WNAXEw2cJs1nSRNJNRcPypARZNumgtsRTJFTNpd6turCA6JZ6cEwl4ZU3C1IHc/3IaXoPu9LdxcI5TBTdD6/pg==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "chokidar": "^3.5.3", + "fast-glob": "^3.2.11", + "jsonc-parser": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "bin": { + "wireit": "bin/wireit.js" + }, + "engines": { + "node": ">=14.14.0" + } + }, + "node_modules/wordwrapjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-4.0.1.tgz", + "integrity": "sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==", + "dev": true, + "dependencies": { + "reduce-flatten": "^2.0.0", + "typical": "^5.2.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs-unparser/node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs-unparser/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "dev": true, + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/browsers": { + "name": "@puppeteer/browsers", + "version": "1.9.1", + "license": "Apache-2.0", + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "progress": "2.0.3", + "proxy-agent": "6.3.1", + "tar-fs": "3.0.4", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "devDependencies": { + "@types/debug": "4.1.12", + "@types/progress": "2.0.7", + "@types/tar-fs": "2.0.4", + "@types/unbzip2-stream": "1.4.3", + "@types/yargs": "17.0.32" + }, + "engines": { + "node": ">=16.3.0" + } + }, + "packages/browsers/node_modules/cliui": { + "version": "8.0.1", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "packages/browsers/node_modules/yargs": { + "version": "17.7.2", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "packages/browsers/node_modules/yargs-parser": { + "version": "21.1.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "packages/ng-schematics": { + "name": "@puppeteer/ng-schematics", + "version": "0.5.6", + "license": "Apache-2.0", + "dependencies": { + "@angular-devkit/architect": "^0.1701.1", + "@angular-devkit/core": "^17.0.7", + "@angular-devkit/schematics": "^17.0.7" + }, + "devDependencies": { + "@angular/cli": "^17.0.7", + "@schematics/angular": "^17.0.7" + }, + "engines": { + "node": ">=16.13.2" + } + }, + "packages/ng-schematics/node_modules/@angular-devkit/architect": { + "version": "0.1700.6", + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "17.0.6", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "packages/ng-schematics/node_modules/@angular-devkit/core": { + "version": "17.0.6", + "license": "MIT", + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "picomatch": "3.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "packages/ng-schematics/node_modules/@angular-devkit/schematics": { + "version": "17.0.6", + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "17.0.6", + "jsonc-parser": "3.2.0", + "magic-string": "0.30.5", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "packages/ng-schematics/node_modules/@angular/cli": { + "version": "17.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.1700.6", + "@angular-devkit/core": "17.0.6", + "@angular-devkit/schematics": "17.0.6", + "@schematics/angular": "17.0.6", + "@yarnpkg/lockfile": "1.1.0", + "ansi-colors": "4.1.3", + "ini": "4.1.1", + "inquirer": "9.2.11", + "jsonc-parser": "3.2.0", + "npm-package-arg": "11.0.1", + "npm-pick-manifest": "9.0.0", + "open": "8.4.2", + "ora": "5.4.1", + "pacote": "17.0.4", + "resolve": "1.22.8", + "semver": "7.5.4", + "symbol-observable": "4.0.0", + "yargs": "17.7.2" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "packages/ng-schematics/node_modules/@schematics/angular": { + "version": "17.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "17.0.6", + "@angular-devkit/schematics": "17.0.6", + "jsonc-parser": "3.2.0" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "packages/ng-schematics/node_modules/ajv": { + "version": "8.12.0", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/ng-schematics/node_modules/ansi-colors": { + "version": "4.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "packages/ng-schematics/node_modules/chalk": { + "version": "5.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/ng-schematics/node_modules/cli-width": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "packages/ng-schematics/node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "packages/ng-schematics/node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "packages/ng-schematics/node_modules/escape-string-regexp": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/ng-schematics/node_modules/figures": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0", + "is-unicode-supported": "^1.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/ng-schematics/node_modules/hosted-git-info": { + "version": "7.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "packages/ng-schematics/node_modules/inquirer": { + "version": "9.2.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@ljharb/through": "^2.3.9", + "ansi-escapes": "^4.3.2", + "chalk": "^5.3.0", + "cli-cursor": "^3.1.0", + "cli-width": "^4.1.0", + "external-editor": "^3.1.0", + "figures": "^5.0.0", + "lodash": "^4.17.21", + "mute-stream": "1.0.0", + "ora": "^5.4.1", + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "packages/ng-schematics/node_modules/is-unicode-supported": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/ng-schematics/node_modules/json-parse-even-better-errors": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "packages/ng-schematics/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "packages/ng-schematics/node_modules/jsonc-parser": { + "version": "3.2.0", + "license": "MIT" + }, + "packages/ng-schematics/node_modules/lru-cache": { + "version": "10.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "14 || >=16.14" + } + }, + "packages/ng-schematics/node_modules/mute-stream": { + "version": "1.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "packages/ng-schematics/node_modules/normalize-package-data": { + "version": "6.0.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "packages/ng-schematics/node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "packages/ng-schematics/node_modules/pacote": { + "version": "17.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/promise-spawn": "^7.0.0", + "@npmcli/run-script": "^7.0.0", + "cacache": "^18.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^11.0.0", + "npm-packlist": "^8.0.0", + "npm-pick-manifest": "^9.0.0", + "npm-registry-fetch": "^16.0.0", + "proc-log": "^3.0.0", + "promise-retry": "^2.0.1", + "read-package-json": "^7.0.0", + "read-package-json-fast": "^3.0.0", + "sigstore": "^2.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "lib/bin.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "packages/ng-schematics/node_modules/picomatch": { + "version": "3.0.1", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/ng-schematics/node_modules/read-package-json": { + "version": "7.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.2.2", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "packages/ng-schematics/node_modules/run-async": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "packages/ng-schematics/node_modules/rxjs": { + "version": "7.8.1", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "packages/ng-schematics/node_modules/source-map": { + "version": "0.7.4", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "packages/ng-schematics/node_modules/tslib": { + "version": "2.6.2", + "license": "0BSD" + }, + "packages/ng-schematics/node_modules/wrap-ansi": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/ng-schematics/node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "packages/ng-schematics/node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "packages/puppeteer": { + "version": "21.10.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "1.9.1", + "cosmiconfig": "9.0.0", + "puppeteer-core": "21.10.0" + }, + "bin": { + "puppeteer": "lib/esm/puppeteer/node/cli.js" + }, + "devDependencies": { + "@types/node": "18.17.15" + }, + "engines": { + "node": ">=16.13.2" + } + }, + "packages/puppeteer-core": { + "version": "21.10.0", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "1.9.1", + "chromium-bidi": "0.5.6", + "cross-fetch": "4.0.0", + "debug": "4.3.4", + "devtools-protocol": "0.0.1232444", + "ws": "8.16.0" + }, + "devDependencies": { + "@types/debug": "4.1.12", + "@types/node": "18.17.15", + "@types/ws": "8.5.10", + "mitt": "3.0.1", + "parsel-js": "1.1.2", + "rxjs": "7.8.1" + }, + "engines": { + "node": ">=16.13.2" + } + }, + "packages/puppeteer-core/node_modules/@types/node": { + "version": "18.17.15", + "dev": true, + "license": "MIT" + }, + "packages/puppeteer-core/node_modules/rxjs": { + "version": "7.8.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "packages/puppeteer-core/node_modules/tslib": { + "version": "2.6.2", + "dev": true, + "license": "0BSD" + }, + "packages/puppeteer/node_modules/@types/node": { + "version": "18.17.15", + "dev": true, + "license": "MIT" + }, + "packages/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.0", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/puppeteer/node_modules/parse-json": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/testserver": { + "name": "@pptr/testserver", + "version": "0.6.0", + "license": "Apache-2.0", + "dependencies": { + "mime": "3.0.0", + "ws": "8.16.0" + }, + "devDependencies": { + "@types/mime": "3.0.4" + } + }, + "test": { + "name": "@puppeteer-test/test", + "version": "latest", + "dependencies": { + "diff": "5.1.0", + "jpeg-js": "0.4.4", + "pixelmatch": "5.3.0", + "pngjs": "7.0.0" + }, + "devDependencies": { + "@types/diff": "5.0.9", + "@types/pixelmatch": "5.2.6", + "@types/pngjs": "6.0.4" + } + }, + "test/installation": { + "name": "@puppeteer-test/installation", + "version": "latest", + "dependencies": { + "glob": "10.3.10", + "mocha": "10.2.0" + } + }, + "test/node_modules/diff": { + "version": "5.1.0", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "tools/docgen": { + "name": "@puppeteer/docgen", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@microsoft/api-documenter": "7.23.20", + "@microsoft/api-extractor": "7.39.4", + "@microsoft/api-extractor-model": "7.28.7", + "@microsoft/tsdoc": "0.14.2", + "@rushstack/node-core-library": "3.64.2" + } + }, + "tools/doctest": { + "name": "@puppeteer/doctest", + "version": "0.1.0", + "license": "Apache-2.0", + "bin": { + "doctest": "bin/doctest.js" + }, + "devDependencies": { + "@swc/core": "1.3.107", + "@types/doctrine": "0.0.9", + "@types/source-map-support": "0.5.10", + "@types/yargs": "17.0.32", + "acorn": "8.11.3", + "doctrine": "3.0.0", + "glob": "10.3.10", + "pkg-dir": "8.0.0", + "source-map": "0.7.4", + "source-map-support": "0.5.21", + "yargs": "17.7.2" + } + }, + "tools/doctest/node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "tools/doctest/node_modules/source-map": { + "version": "0.7.4", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "tools/doctest/node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "tools/doctest/node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "tools/eslint": { + "name": "@puppeteer/eslint", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@prettier/sync": "0.5.0" + } + }, + "tools/mocha-runner": { + "name": "@puppeteer/mocha-runner", + "version": "0.1.0", + "license": "Apache-2.0", + "bin": { + "mocha-runner": "bin/mocha-runner.js" + }, + "devDependencies": { + "@types/yargs": "17.0.32", + "c8": "9.1.0", + "glob": "10.3.10", + "yargs": "17.7.2", + "zod": "3.22.4" + } + }, + "tools/mocha-runner/node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "tools/mocha-runner/node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "tools/mocha-runner/node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/remote/test/puppeteer/package.json b/remote/test/puppeteer/package.json new file mode 100644 index 0000000000..612f5ac369 --- /dev/null +++ b/remote/test/puppeteer/package.json @@ -0,0 +1,187 @@ +{ + "name": "puppeteer-repo", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer" + }, + "scripts": { + "build": "wireit", + "build:tools": "wireit", + "check": "npm run check --workspaces --if-present && run-p check:*", + "check:pinned-deps": "tsx tools/ensure-pinned-deps", + "clean": "npm run clean --workspaces --if-present", + "debug": "mocha --inspect-brk", + "docs": "wireit", + "doctest": "wireit", + "format": "run-s format:*", + "format:eslint": "eslint --ext js --ext mjs --ext ts --fix .", + "format:expectations": "node tools/sort-test-expectations.mjs", + "format:prettier": "prettier --write .", + "lint": "run-s lint:*", + "lint:eslint": "eslint --ext js --ext mjs --ext ts .", + "lint:prettier": "prettier --check .", + "lint:expectations": "node tools/sort-test-expectations.mjs --lint", + "postinstall": "npm run postinstall --workspaces --if-present", + "prepare": "npm run prepare --workspaces --if-present", + "test": "wireit", + "test-install": "npm run test --workspace @puppeteer-test/installation", + "test-types": "wireit", + "test:chrome": "wireit", + "test:chrome:bidi": "wireit", + "test:chrome:bidi-local": "wireit", + "test:chrome:headful": "wireit", + "test:chrome:headless": "wireit", + "test:chrome:new-headless": "wireit", + "test:firefox": "wireit", + "test:firefox:bidi": "wireit", + "test:firefox:bidi:headful": "wireit", + "test:firefox:headful": "wireit", + "test:firefox:headless": "wireit", + "validate-licenses": "tsx tools/third_party/validate-licenses.ts", + "unit": "npm run unit --workspaces --if-present" + }, + "wireit": { + "build": { + "dependencies": [ + "./packages/browsers:build", + "./packages/ng-schematics:build", + "./packages/puppeteer-core:build", + "./packages/puppeteer:build", + "./packages/testserver:build", + "./test:build", + "./test/installation:build" + ] + }, + "build:tools": { + "dependencies": [ + "./tools/docgen:build", + "./tools/doctest:build", + "./tools/mocha-runner:build", + "./tools/eslint:build", + "./packages/testserver:build" + ] + }, + "docs": { + "command": "hereby docs", + "dependencies": [ + "./packages/browsers:build:docs", + "./packages/puppeteer:build:docs", + "./packages/puppeteer-core:build:docs", + "./tools/docgen:build" + ] + }, + "doctest": { + "command": "npx ./tools/doctest 'packages/puppeteer-core/lib/esm/**/*.js'", + "dependencies": [ + "./packages/puppeteer-core:build", + "./tools/doctest:build" + ] + }, + "test:chrome": { + "dependencies": [ + "test:chrome:bidi", + "test:chrome:headful", + "test:chrome:headless", + "test:chrome:new-headless" + ] + }, + "test:chrome:bidi": { + "command": "npm test -- --test-suite chrome-bidi" + }, + "test:chrome:bidi-local": { + "command": "PUPPETEER_EXECUTABLE_PATH=$(node tools/download_chrome_bidi.mjs ~/.cache/puppeteer/chrome-canary --shell) npm test -- --test-suite chrome-bidi" + }, + "test:chrome:headful": { + "command": "npm test -- --test-suite chrome-headful" + }, + "test:chrome:headless": { + "command": "npm test -- --test-suite chrome-headless" + }, + "test:chrome:new-headless": { + "command": "npm test -- --test-suite chrome-new-headless" + }, + "test:firefox:bidi": { + "command": "npm test -- --test-suite firefox-bidi" + }, + "test:firefox:bidi:headful": { + "command": "npm test -- --test-suite firefox-bidi-headful" + }, + "test:firefox:headful": { + "command": "npm test -- --test-suite firefox-headful" + }, + "test:firefox:headless": { + "command": "npm test -- --test-suite firefox-headless" + }, + "test:firefox": { + "dependencies": [ + "test:firefox:bidi", + "test:firefox:bidi:headful", + "test:firefox:headful", + "test:firefox:headless" + ] + }, + "test": { + "command": "npx ./tools/mocha-runner --min-tests 1003", + "dependencies": [ + "./test:build", + "./tools/mocha-runner:build" + ] + }, + "test-types": { + "command": "tsd -t packages/puppeteer", + "dependencies": [ + "./packages/puppeteer:build" + ] + } + }, + "devDependencies": { + "@actions/core": "1.10.1", + "@types/mocha": "10.0.6", + "@types/node": "20.8.4", + "@types/semver": "7.5.6", + "@types/sinon": "17.0.3", + "@typescript-eslint/eslint-plugin": "6.19.1", + "@typescript-eslint/parser": "6.19.1", + "esbuild": "0.20.0", + "eslint-config-prettier": "9.1.0", + "eslint-import-resolver-typescript": "3.6.1", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-mocha": "10.2.0", + "eslint-plugin-prettier": "5.1.3", + "eslint-plugin-rulesdir": "0.2.2", + "eslint-plugin-tsdoc": "0.2.17", + "eslint-plugin-unused-imports": "3.0.0", + "eslint": "8.56.0", + "execa": "8.0.1", + "expect": "29.7.0", + "gts": "5.2.0", + "hereby": "1.8.9", + "license-checker": "25.0.1", + "mocha": "10.2.0", + "npm-run-all": "4.1.5", + "prettier": "3.2.4", + "semver": "7.5.4", + "sinon": "17.0.1", + "source-map-support": "0.5.21", + "spdx-satisfies": "5.0.1", + "tsd": "0.30.4", + "tsx": "4.7.0", + "typescript": "5.3.3", + "wireit": "0.14.4" + }, + "overrides": { + "@microsoft/api-extractor": { + "typescript": "$typescript" + } + }, + "workspaces": [ + "packages/*", + "test", + "test/installation", + "tools/eslint", + "tools/doctest", + "tools/docgen", + "tools/mocha-runner" + ] +} diff --git a/remote/test/puppeteer/packages/browsers/.mocharc.cjs b/remote/test/puppeteer/packages/browsers/.mocharc.cjs new file mode 100644 index 0000000000..50110ff654 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/.mocharc.cjs @@ -0,0 +1,8 @@ +module.exports = { + logLevel: 'debug', + spec: 'test/build/**/*.spec.js', + require: ['./test/build/mocha-utils.js'], + exit: !!process.env.CI, + reporter: 'spec', + timeout: 10_000, +}; diff --git a/remote/test/puppeteer/packages/browsers/CHANGELOG.md b/remote/test/puppeteer/packages/browsers/CHANGELOG.md new file mode 100644 index 0000000000..abfb45bb6d --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/CHANGELOG.md @@ -0,0 +1,282 @@ +# Changelog + +## [1.9.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.9.0...browsers-v1.9.1) (2024-01-04) + + +### Bug Fixes + +* disable GFX sanity window for Firefox and enable WebDriver BiDi CI jobs for Windows ([#11578](https://github.com/puppeteer/puppeteer/issues/11578)) ([e41a265](https://github.com/puppeteer/puppeteer/commit/e41a2656d9e1f3f037b298457fbd6c6e08f5a371)) + +## [1.9.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.8.0...browsers-v1.9.0) (2023-12-05) + + +### Features + +* implement the Puppeteer CLI ([#11344](https://github.com/puppeteer/puppeteer/issues/11344)) ([53fb69b](https://github.com/puppeteer/puppeteer/commit/53fb69bf7f2bf06fa4fd7bb6d3cf21382386f6e7)) + + +### Bug Fixes + +* ng-schematics install Windows ([#11487](https://github.com/puppeteer/puppeteer/issues/11487)) ([02af748](https://github.com/puppeteer/puppeteer/commit/02af7482d9bf2163b90dfe623b0af18c513d5a3b)) +* remove CDP-specific preferences from defaults for Firefox ([#11477](https://github.com/puppeteer/puppeteer/issues/11477)) ([f8c9469](https://github.com/puppeteer/puppeteer/commit/f8c94699c7f5b15c7bb96f299c2c8217d74230cd)) + +## [1.8.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.7.1...browsers-v1.8.0) (2023-10-20) + + +### Features + +* enable tab targets ([#11099](https://github.com/puppeteer/puppeteer/issues/11099)) ([8324c16](https://github.com/puppeteer/puppeteer/commit/8324c1634883d97ed83f32a1e62acc9b5e64e0bd)) + +## [1.7.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.7.0...browsers-v1.7.1) (2023-09-13) + + +### Bug Fixes + +* use supported node range for types ([#10896](https://github.com/puppeteer/puppeteer/issues/10896)) ([2d851c1](https://github.com/puppeteer/puppeteer/commit/2d851c1398e5efcdabdb5304dc78e68cbd3fadd2)) + +## [1.7.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.6.0...browsers-v1.7.0) (2023-08-18) + + +### Features + +* support chrome-headless-shell ([#10739](https://github.com/puppeteer/puppeteer/issues/10739)) ([416843b](https://github.com/puppeteer/puppeteer/commit/416843ba68aaab7ae14bbc74c2ac705e877e91a7)) + +## [1.6.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.5.1...browsers-v1.6.0) (2023-08-10) + + +### Features + +* allow installing chrome/chromedriver by milestone and version prefix ([#10720](https://github.com/puppeteer/puppeteer/issues/10720)) ([bec2357](https://github.com/puppeteer/puppeteer/commit/bec2357aeedda42cfaf3096c6293c2f49ceb825e)) + +## [1.5.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.5.0...browsers-v1.5.1) (2023-08-08) + + +### Bug Fixes + +* add buildId to archive path ([#10699](https://github.com/puppeteer/puppeteer/issues/10699)) ([21461b0](https://github.com/puppeteer/puppeteer/commit/21461b02c65062f5ed240e8ea357e9b7f2d26b32)) + +## [1.5.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.6...browsers-v1.5.0) (2023-08-02) + + +### Features + +* add executablePath to InstalledBrowser ([#10594](https://github.com/puppeteer/puppeteer/issues/10594)) ([87522e7](https://github.com/puppeteer/puppeteer/commit/87522e778a6487111931458755e701f1c4b717d9)) + + +### Bug Fixes + +* clear pending TLS socket handle ([#10667](https://github.com/puppeteer/puppeteer/issues/10667)) ([87bd791](https://github.com/puppeteer/puppeteer/commit/87bd791ddc10c247bf154bbac2aa912327a4cf20)) +* remove typescript from peer dependencies ([#10593](https://github.com/puppeteer/puppeteer/issues/10593)) ([c60572a](https://github.com/puppeteer/puppeteer/commit/c60572a1ca36ea5946d287bd629ac31798d84cb0)) + +## [1.4.6](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.5...browsers-v1.4.6) (2023-07-20) + + +### Bug Fixes + +* restore proxy-agent ([#10569](https://github.com/puppeteer/puppeteer/issues/10569)) ([bf6304e](https://github.com/puppeteer/puppeteer/commit/bf6304e064eb52d39d7f993f1ea868da06f7f006)) + +## [1.4.5](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.4...browsers-v1.4.5) (2023-07-13) + + +### Bug Fixes + +* stop relying on vm2 (via proxy agent) ([#10548](https://github.com/puppeteer/puppeteer/issues/10548)) ([4070cd6](https://github.com/puppeteer/puppeteer/commit/4070cd68b6d01fb9a1643da2662ce0b6f53cf37d)) + +## [1.4.4](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.3...browsers-v1.4.4) (2023-07-11) + + +### Bug Fixes + +* correctly parse the default buildId ([#10535](https://github.com/puppeteer/puppeteer/issues/10535)) ([c308266](https://github.com/puppeteer/puppeteer/commit/c3082661113b4b55534f25da86e3b261d3952953)) +* remove Chromium channels ([#10536](https://github.com/puppeteer/puppeteer/issues/10536)) ([c0dc8ad](https://github.com/puppeteer/puppeteer/commit/c0dc8ad8a82446752e29f98d8eee617b9a67c942)) + +## [1.4.3](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.2...browsers-v1.4.3) (2023-06-29) + + +### Bug Fixes + +* negative timeout doesn't break launch ([#10480](https://github.com/puppeteer/puppeteer/issues/10480)) ([6a89a2a](https://github.com/puppeteer/puppeteer/commit/6a89a2aadcaf683fe57f1e0e13886f1fa937e194)) + +## [1.4.2](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.1...browsers-v1.4.2) (2023-06-20) + + +### Bug Fixes + +* include src into published package ([#10415](https://github.com/puppeteer/puppeteer/issues/10415)) ([d1ffad0](https://github.com/puppeteer/puppeteer/commit/d1ffad059ae66104842b92dc814d362c123b9646)) + +## [1.4.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.0...browsers-v1.4.1) (2023-05-31) + + +### Bug Fixes + +* pass on the auth from the download URL ([#10271](https://github.com/puppeteer/puppeteer/issues/10271)) ([3a1f4f0](https://github.com/puppeteer/puppeteer/commit/3a1f4f0f8f5fe4e20c4ed69f5485a827a841cf54)) + +## [1.4.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.3.0...browsers-v1.4.0) (2023-05-24) + + +### Features + +* use proxy-agent to support various proxies ([#10227](https://github.com/puppeteer/puppeteer/issues/10227)) ([2c0bd54](https://github.com/puppeteer/puppeteer/commit/2c0bd54d2e3b778818b9b4b32f436778f571b918)) + +## [1.3.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.2.0...browsers-v1.3.0) (2023-05-15) + + +### Features + +* add ability to uninstall a browser ([#10179](https://github.com/puppeteer/puppeteer/issues/10179)) ([d388a6e](https://github.com/puppeteer/puppeteer/commit/d388a6edfd164548b008cb0d8e9cb5c0d03cdcda)) + + +### Bug Fixes + +* update the command name ([#10178](https://github.com/puppeteer/puppeteer/issues/10178)) ([ccbb82d](https://github.com/puppeteer/puppeteer/commit/ccbb82d9cd5b77f8262c143a5663fc1f9938a8c4)) + +## [1.2.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.1.0...browsers-v1.2.0) (2023-05-11) + + +### Features + +* support Chrome channels for ChromeDriver ([#10158](https://github.com/puppeteer/puppeteer/issues/10158)) ([e313b05](https://github.com/puppeteer/puppeteer/commit/e313b054e658887e2c062ea55d8ee99f3f4f3789)) + +## [1.1.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.0.1...browsers-v1.1.0) (2023-05-08) + + +### Features + +* support stable/dev/beta/canary keywords for chrome and chromium ([#10140](https://github.com/puppeteer/puppeteer/issues/10140)) ([90ed263](https://github.com/puppeteer/puppeteer/commit/90ed263eafb0ca0420ea1918d7c1f326eaa58e20)) + +## [1.0.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.0.0...browsers-v1.0.1) (2023-05-05) + + +### Bug Fixes + +* rename PUPPETEER_DOWNLOAD_HOST to PUPPETEER_DOWNLOAD_BASE_URL ([#10130](https://github.com/puppeteer/puppeteer/issues/10130)) ([9758cae](https://github.com/puppeteer/puppeteer/commit/9758cae029f90908c4b5340561d9c51c26aa2f21)) + +## [1.0.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.5.0...browsers-v1.0.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) + +### Features + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) ([7405d65](https://github.com/puppeteer/puppeteer/commit/7405d6585aa09b240fbab09aa360674d4442b3d9)) +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) ([df4d60c](https://github.com/puppeteer/puppeteer/commit/df4d60c187aa11c4ad783827242e9511f4ec2aab)) + + +### Bug Fixes + +* add Host header when used with http_proxy ([#10080](https://github.com/puppeteer/puppeteer/issues/10080)) ([edbfff7](https://github.com/puppeteer/puppeteer/commit/edbfff7b04baffc29c01c37c595d6b3355c0dea0)) + +## [0.5.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.4.1...browsers-v0.5.0) (2023-04-21) + + +### Features + +* **browser:** add a method to get installed browsers ([#10057](https://github.com/puppeteer/puppeteer/issues/10057)) ([e16e2a9](https://github.com/puppeteer/puppeteer/commit/e16e2a97284f5e7ab4073f375254572a6a89e800)) + +## [0.4.1](https://github.com/puppeteer/puppeteer/compare/browsers-v0.4.0...browsers-v0.4.1) (2023-04-13) + + +### Bug Fixes + +* report install errors properly ([#10016](https://github.com/puppeteer/puppeteer/issues/10016)) ([7381229](https://github.com/puppeteer/puppeteer/commit/7381229a164e598e7523862f2438cd0cd1cd796a)) + +## [0.4.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.3.3...browsers-v0.4.0) (2023-04-06) + + +### Features + +* **browsers:** support downloading chromedriver ([#9990](https://github.com/puppeteer/puppeteer/issues/9990)) ([ef0fb5d](https://github.com/puppeteer/puppeteer/commit/ef0fb5d87299c604af2387ac1c72be317c50316d)) + +## [0.3.3](https://github.com/puppeteer/puppeteer/compare/browsers-v0.3.2...browsers-v0.3.3) (2023-04-06) + + +### Bug Fixes + +* **browsers:** update package json ([#9968](https://github.com/puppeteer/puppeteer/issues/9968)) ([817288c](https://github.com/puppeteer/puppeteer/commit/817288cd901121ddc8a44226eda689bb784cee61)) +* **browsers:** various fixes and improvements ([#9966](https://github.com/puppeteer/puppeteer/issues/9966)) ([f1211cb](https://github.com/puppeteer/puppeteer/commit/f1211cbec091ec669de019aeb7fb4f011a81c1d7)) +* consider downloadHost as baseUrl ([#9973](https://github.com/puppeteer/puppeteer/issues/9973)) ([05a44af](https://github.com/puppeteer/puppeteer/commit/05a44afe5affcac9fe0f0a2e83f17807c99b2f0c)) + +## [0.3.2](https://github.com/puppeteer/puppeteer/compare/browsers-v0.3.1...browsers-v0.3.2) (2023-04-03) + + +### Bug Fixes + +* typo in the browsers package ([#9957](https://github.com/puppeteer/puppeteer/issues/9957)) ([c780384](https://github.com/puppeteer/puppeteer/commit/c7803844cf10b6edaa2da83134029b7acf5b45b2)) + +## [0.3.1](https://github.com/puppeteer/puppeteer/compare/browsers-v0.3.0...browsers-v0.3.1) (2023-03-29) + + +### Bug Fixes + +* bump @puppeteer/browsers ([#9938](https://github.com/puppeteer/puppeteer/issues/9938)) ([2a29d30](https://github.com/puppeteer/puppeteer/commit/2a29d30d1790b47c99f8d196b3844364d351acbd)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.2.0...browsers-v0.3.0) (2023-03-27) + + +### Features + +* update Chrome browser binaries ([#9917](https://github.com/puppeteer/puppeteer/issues/9917)) ([fcb233c](https://github.com/puppeteer/puppeteer/commit/fcb233ce949f5f716aee39253e910104b04aa000)) + +## [0.2.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.1.1...browsers-v0.2.0) (2023-03-24) + + +### Features + +* implement a command to clear the cache ([#9868](https://github.com/puppeteer/puppeteer/issues/9868)) ([b8d38cb](https://github.com/puppeteer/puppeteer/commit/b8d38cb05f7eedf554ed46f2f7428b621197d1cc)) + +## [0.1.1](https://github.com/puppeteer/puppeteer/compare/browsers-v0.1.0...browsers-v0.1.1) (2023-03-14) + + +### Bug Fixes + +* export ChromeReleaseChannel ([#9851](https://github.com/puppeteer/puppeteer/issues/9851)) ([3e7a514](https://github.com/puppeteer/puppeteer/commit/3e7a514e556ddb4306aa3c15f24c512beaac65f4)) + +## [0.1.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.5...browsers-v0.1.0) (2023-03-14) + + +### Features + +* implement system channels for chrome in browsers ([#9844](https://github.com/puppeteer/puppeteer/issues/9844)) ([dec48a9](https://github.com/puppeteer/puppeteer/commit/dec48a95923e21a054c1d70d22c14001a0150293)) + + +### Bug Fixes + +* add browsers entry point ([#9846](https://github.com/puppeteer/puppeteer/issues/9846)) ([1a1e79d](https://github.com/puppeteer/puppeteer/commit/1a1e79d046ccad6fe843aa219501c17da08bc498)) + +## [0.0.5](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.4...browsers-v0.0.5) (2023-03-07) + + +### Bug Fixes + +* change the install output to include the executable path ([#9797](https://github.com/puppeteer/puppeteer/issues/9797)) ([8cca7bb](https://github.com/puppeteer/puppeteer/commit/8cca7bb7a2a1cdf62919d9c7eca62d6774e698db)) + +## [0.0.4](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.3...browsers-v0.0.4) (2023-03-06) + + +### Features + +* browsers: recognize chromium as a valid browser ([#9760](https://github.com/puppeteer/puppeteer/issues/9760)) ([04247a4](https://github.com/puppeteer/puppeteer/commit/04247a4e00b43683977bd8aa309d493eee663735)) + +## [0.0.3](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.2...browsers-v0.0.3) (2023-02-22) + + +### Bug Fixes + +* define options per command ([#9733](https://github.com/puppeteer/puppeteer/issues/9733)) ([8bae054](https://github.com/puppeteer/puppeteer/commit/8bae0545b7321d398dae3f522952dd981111587e)) + +## [0.0.2](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.1...browsers-v0.0.2) (2023-02-22) + + +### Bug Fixes + +* permissions for the browser CLI ([#9731](https://github.com/puppeteer/puppeteer/issues/9731)) ([e944931](https://github.com/puppeteer/puppeteer/commit/e944931de22726f35c5c83052892f8ab4667b035)) + +## 0.0.1 (2023-02-22) + + +### Features + +* initial release of browsers ([#9722](https://github.com/puppeteer/puppeteer/issues/9722)) ([#9727](https://github.com/puppeteer/puppeteer/issues/9727)) ([86a2d1d](https://github.com/puppeteer/puppeteer/commit/86a2d1dd3b2c024b886c6280e08a2d7dc8caabc5)) diff --git a/remote/test/puppeteer/packages/browsers/README.md b/remote/test/puppeteer/packages/browsers/README.md new file mode 100644 index 0000000000..f5342126c6 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/README.md @@ -0,0 +1,28 @@ +# @puppeteer/browsers + +Manage and launch browsers/drivers from a CLI or programmatically. + +## CLI + +Use `npx` to run the CLI: + +```bash +npx @puppeteer/browsers --help +``` + +CLI help will provide all documentation you need to use the CLI. + +```bash +npx @puppeteer/browsers --help # help for all commands +npx @puppeteer/browsers install --help # help for the install command +npx @puppeteer/browsers launch --help # help for the launch command +``` + +## Known limitations + +1. We support installing and running Firefox, Chrome and Chromium. The `latest`, `beta`, `dev`, `canary`, `stable` keywords are only supported for the install command. For the `launch` command you need to specify an exact build ID. The build ID is provided by the `install` command (see `npx @puppeteer/browsers install --help` for the format). +2. Launching the system browsers is only possible for Chrome/Chromium. + +## API + +The programmatic API allows installing and launching browsers from your code. See the `test` folder for examples on how to use the `install`, `canInstall`, `launch`, `computeExecutablePath`, `computeSystemExecutablePath` and other methods. diff --git a/remote/test/puppeteer/packages/browsers/api-extractor.docs.json b/remote/test/puppeteer/packages/browsers/api-extractor.docs.json new file mode 100644 index 0000000000..6a41a3b59c --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/api-extractor.docs.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/main.d.ts", + + "extends": "./api-extractor.json", + + "dtsRollup": { + "enabled": false + }, + + "docModel": { + "enabled": true, + "apiJsonFilePath": "<projectFolder>/../../docs/<unscopedPackageName>.api.json" + } +} diff --git a/remote/test/puppeteer/packages/browsers/api-extractor.json b/remote/test/puppeteer/packages/browsers/api-extractor.json new file mode 100644 index 0000000000..da1caae622 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/api-extractor.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/main.d.ts", + "bundledPackages": [], + + "apiReport": { + "enabled": false + }, + + "docModel": { + "enabled": false + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + + "extractorMessageReporting": { + "ae-internal-missing-underscore": { + "logLevel": "none" + }, + "default": { + "logLevel": "warning" + } + }, + + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/remote/test/puppeteer/packages/browsers/package.json b/remote/test/puppeteer/packages/browsers/package.json new file mode 100644 index 0000000000..45de79abb8 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/package.json @@ -0,0 +1,113 @@ +{ + "name": "@puppeteer/browsers", + "version": "1.9.1", + "description": "Download and launch browsers", + "scripts": { + "build:docs": "wireit", + "build": "wireit", + "build:test": "wireit", + "clean": "../../tools/clean.js", + "test": "wireit" + }, + "type": "commonjs", + "bin": "lib/cjs/main-cli.js", + "main": "./lib/cjs/main.js", + "exports": { + "import": "./lib/esm/main.js", + "require": "./lib/cjs/main.js" + }, + "wireit": { + "build": { + "command": "tsc -b && tsx ../../tools/chmod.ts 755 lib/cjs/main-cli.js lib/esm/main-cli.js", + "files": [ + "src/**/*.ts", + "tsconfig.json" + ], + "clean": "if-file-deleted", + "output": [ + "lib/**", + "!lib/esm/package.json" + ], + "dependencies": [ + "generate:package-json" + ] + }, + "generate:package-json": { + "command": "tsx ../../tools/generate_module_package_json.ts lib/esm/package.json", + "files": [ + "../../tools/generate_module_package_json.ts" + ], + "output": [ + "lib/esm/package.json" + ] + }, + "build:docs": { + "command": "api-extractor run --local --config \"./api-extractor.docs.json\"", + "files": [ + "api-extractor.docs.json", + "lib/esm/main.d.ts", + "tsconfig.json" + ], + "dependencies": [ + "build" + ] + }, + "build:test": { + "command": "tsc -b test/src/tsconfig.json", + "files": [ + "test/**/*.ts", + "test/src/tsconfig.json" + ], + "output": [ + "test/build/**" + ], + "dependencies": [ + "build", + "../testserver:build" + ] + }, + "test": { + "command": "node tools/downloadTestBrowsers.mjs && mocha", + "files": [ + ".mocharc.cjs" + ], + "dependencies": [ + "build:test" + ] + } + }, + "keywords": [ + "puppeteer", + "browsers" + ], + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/browsers" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=16.3.0" + }, + "files": [ + "lib", + "src", + "!*.tsbuildinfo" + ], + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "progress": "2.0.3", + "proxy-agent": "6.3.1", + "tar-fs": "3.0.4", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.2" + }, + "devDependencies": { + "@types/debug": "4.1.12", + "@types/progress": "2.0.7", + "@types/tar-fs": "2.0.4", + "@types/unbzip2-stream": "1.4.3", + "@types/yargs": "17.0.32" + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/CLI.ts b/remote/test/puppeteer/packages/browsers/src/CLI.ts new file mode 100644 index 0000000000..255f5545b4 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/CLI.ts @@ -0,0 +1,401 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {stdin as input, stdout as output} from 'process'; +import * as readline from 'readline'; + +import ProgressBar from 'progress'; +import type * as Yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; +import yargs from 'yargs/yargs'; + +import { + resolveBuildId, + type Browser, + BrowserPlatform, + type ChromeReleaseChannel, +} from './browser-data/browser-data.js'; +import {Cache} from './Cache.js'; +import {detectBrowserPlatform} from './detectPlatform.js'; +import {install} from './install.js'; +import { + computeExecutablePath, + computeSystemExecutablePath, + launch, +} from './launch.js'; + +interface InstallArgs { + browser: { + name: Browser; + buildId: string; + }; + path?: string; + platform?: BrowserPlatform; + baseUrl?: string; +} + +interface LaunchArgs { + browser: { + name: Browser; + buildId: string; + }; + path?: string; + platform?: BrowserPlatform; + detached: boolean; + system: boolean; +} + +interface ClearArgs { + path?: string; +} + +/** + * @public + */ +export class CLI { + #cachePath; + #rl?: readline.Interface; + #scriptName = ''; + #allowCachePathOverride = true; + #pinnedBrowsers?: Partial<{[key in Browser]: string}>; + #prefixCommand?: {cmd: string; description: string}; + + constructor( + opts?: + | string + | { + cachePath?: string; + scriptName?: string; + prefixCommand?: {cmd: string; description: string}; + allowCachePathOverride?: boolean; + pinnedBrowsers?: Partial<{[key in Browser]: string}>; + }, + rl?: readline.Interface + ) { + if (!opts) { + opts = {}; + } + if (typeof opts === 'string') { + opts = { + cachePath: opts, + }; + } + this.#cachePath = opts.cachePath ?? process.cwd(); + this.#rl = rl; + this.#scriptName = opts.scriptName ?? '@puppeteer/browsers'; + this.#allowCachePathOverride = opts.allowCachePathOverride ?? true; + this.#pinnedBrowsers = opts.pinnedBrowsers; + this.#prefixCommand = opts.prefixCommand; + } + + #defineBrowserParameter(yargs: Yargs.Argv<unknown>): void { + yargs.positional('browser', { + description: + 'Which browser to install <browser>[@<buildId|latest>]. `latest` will try to find the latest available build. `buildId` is a browser-specific identifier such as a version or a revision.', + type: 'string', + coerce: (opt): InstallArgs['browser'] => { + return { + name: this.#parseBrowser(opt), + buildId: this.#parseBuildId(opt), + }; + }, + }); + } + + #definePlatformParameter(yargs: Yargs.Argv<unknown>): void { + yargs.option('platform', { + type: 'string', + desc: 'Platform that the binary needs to be compatible with.', + choices: Object.values(BrowserPlatform), + defaultDescription: 'Auto-detected', + }); + } + + #definePathParameter(yargs: Yargs.Argv<unknown>, required = false): void { + if (!this.#allowCachePathOverride) { + return; + } + yargs.option('path', { + type: 'string', + desc: 'Path to the root folder for the browser downloads and installation. The installation folder structure is compatible with the cache structure used by Puppeteer.', + defaultDescription: 'Current working directory', + ...(required ? {} : {default: process.cwd()}), + }); + if (required) { + yargs.demandOption('path'); + } + } + + async run(argv: string[]): Promise<void> { + const yargsInstance = yargs(hideBin(argv)); + let target = yargsInstance.scriptName(this.#scriptName); + if (this.#prefixCommand) { + target = target.command( + this.#prefixCommand.cmd, + this.#prefixCommand.description, + yargs => { + return this.#build(yargs); + } + ); + } else { + target = this.#build(target); + } + await target + .demandCommand(1) + .help() + .wrap(Math.min(120, yargsInstance.terminalWidth())) + .parse(); + } + + #build(yargs: Yargs.Argv<unknown>): Yargs.Argv<unknown> { + const latestOrPinned = this.#pinnedBrowsers ? 'pinned' : 'latest'; + return yargs + .command( + 'install <browser>', + 'Download and install the specified browser. If successful, the command outputs the actual browser buildId that was installed and the absolute path to the browser executable (format: <browser>@<buildID> <path>).', + yargs => { + this.#defineBrowserParameter(yargs); + this.#definePlatformParameter(yargs); + this.#definePathParameter(yargs); + yargs.option('base-url', { + type: 'string', + desc: 'Base URL to download from', + }); + yargs.example( + '$0 install chrome', + `Install the ${latestOrPinned} available build of the Chrome browser.` + ); + yargs.example( + '$0 install chrome@latest', + 'Install the latest available build for the Chrome browser.' + ); + yargs.example( + '$0 install chrome@canary', + 'Install the latest available build for the Chrome Canary browser.' + ); + yargs.example( + '$0 install chrome@115', + 'Install the latest available build for Chrome 115.' + ); + yargs.example( + '$0 install chromedriver@canary', + 'Install the latest available build for ChromeDriver Canary.' + ); + yargs.example( + '$0 install chromedriver@115', + 'Install the latest available build for ChromeDriver 115.' + ); + yargs.example( + '$0 install chromedriver@115.0.5790', + 'Install the latest available patch (115.0.5790.X) build for ChromeDriver.' + ); + yargs.example( + '$0 install chrome-headless-shell', + 'Install the latest available chrome-headless-shell build.' + ); + yargs.example( + '$0 install chrome-headless-shell@beta', + 'Install the latest available chrome-headless-shell build corresponding to the Beta channel.' + ); + yargs.example( + '$0 install chrome-headless-shell@118', + 'Install the latest available chrome-headless-shell 118 build.' + ); + yargs.example( + '$0 install chromium@1083080', + 'Install the revision 1083080 of the Chromium browser.' + ); + yargs.example( + '$0 install firefox', + 'Install the latest available build of the Firefox browser.' + ); + yargs.example( + '$0 install firefox --platform mac', + 'Install the latest Mac (Intel) build of the Firefox browser.' + ); + if (this.#allowCachePathOverride) { + yargs.example( + '$0 install firefox --path /tmp/my-browser-cache', + 'Install to the specified cache directory.' + ); + } + }, + async argv => { + const args = argv as unknown as InstallArgs; + args.platform ??= detectBrowserPlatform(); + if (!args.platform) { + throw new Error(`Could not resolve the current platform`); + } + if (args.browser.buildId === 'pinned') { + const pinnedVersion = this.#pinnedBrowsers?.[args.browser.name]; + if (!pinnedVersion) { + throw new Error( + `No pinned version found for ${args.browser.name}` + ); + } + args.browser.buildId = pinnedVersion; + } + args.browser.buildId = await resolveBuildId( + args.browser.name, + args.platform, + args.browser.buildId + ); + await install({ + browser: args.browser.name, + buildId: args.browser.buildId, + platform: args.platform, + cacheDir: args.path ?? this.#cachePath, + downloadProgressCallback: makeProgressCallback( + args.browser.name, + args.browser.buildId + ), + baseUrl: args.baseUrl, + }); + console.log( + `${args.browser.name}@${ + args.browser.buildId + } ${computeExecutablePath({ + browser: args.browser.name, + buildId: args.browser.buildId, + cacheDir: args.path ?? this.#cachePath, + platform: args.platform, + })}` + ); + } + ) + .command( + 'launch <browser>', + 'Launch the specified browser', + yargs => { + this.#defineBrowserParameter(yargs); + this.#definePlatformParameter(yargs); + this.#definePathParameter(yargs); + yargs.option('detached', { + type: 'boolean', + desc: 'Detach the child process.', + default: false, + }); + yargs.option('system', { + type: 'boolean', + desc: 'Search for a browser installed on the system instead of the cache folder.', + default: false, + }); + yargs.example( + '$0 launch chrome@115.0.5790.170', + 'Launch Chrome 115.0.5790.170' + ); + yargs.example( + '$0 launch firefox@112.0a1', + 'Launch the Firefox browser identified by the milestone 112.0a1.' + ); + yargs.example( + '$0 launch chrome@115.0.5790.170 --detached', + 'Launch the browser but detach the sub-processes.' + ); + yargs.example( + '$0 launch chrome@canary --system', + 'Try to locate the Canary build of Chrome installed on the system and launch it.' + ); + }, + async argv => { + const args = argv as unknown as LaunchArgs; + const executablePath = args.system + ? computeSystemExecutablePath({ + browser: args.browser.name, + // TODO: throw an error if not a ChromeReleaseChannel is provided. + channel: args.browser.buildId as ChromeReleaseChannel, + platform: args.platform, + }) + : computeExecutablePath({ + browser: args.browser.name, + buildId: args.browser.buildId, + cacheDir: args.path ?? this.#cachePath, + platform: args.platform, + }); + launch({ + executablePath, + detached: args.detached, + }); + } + ) + .command( + 'clear', + this.#allowCachePathOverride + ? 'Removes all installed browsers from the specified cache directory' + : `Removes all installed browsers from ${this.#cachePath}`, + yargs => { + this.#definePathParameter(yargs, true); + }, + async argv => { + const args = argv as unknown as ClearArgs; + const cacheDir = args.path ?? this.#cachePath; + const rl = this.#rl ?? readline.createInterface({input, output}); + rl.question( + `Do you want to permanently and recursively delete the content of ${cacheDir} (yes/No)? `, + answer => { + rl.close(); + if (!['y', 'yes'].includes(answer.toLowerCase().trim())) { + console.log('Cancelled.'); + return; + } + const cache = new Cache(cacheDir); + cache.clear(); + console.log(`${cacheDir} cleared.`); + } + ); + } + ) + .demandCommand(1) + .help(); + } + + #parseBrowser(version: string): Browser { + return version.split('@').shift() as Browser; + } + + #parseBuildId(version: string): string { + const parts = version.split('@'); + return parts.length === 2 + ? parts[1]! + : this.#pinnedBrowsers + ? 'pinned' + : 'latest'; + } +} + +/** + * @public + */ +export function makeProgressCallback( + browser: Browser, + buildId: string +): (downloadedBytes: number, totalBytes: number) => void { + let progressBar: ProgressBar; + let lastDownloadedBytes = 0; + return (downloadedBytes: number, totalBytes: number) => { + if (!progressBar) { + progressBar = new ProgressBar( + `Downloading ${browser} r${buildId} - ${toMegabytes( + totalBytes + )} [:bar] :percent :etas `, + { + complete: '=', + incomplete: ' ', + width: 20, + total: totalBytes, + } + ); + } + const delta = downloadedBytes - lastDownloadedBytes; + lastDownloadedBytes = downloadedBytes; + progressBar.tick(delta); + }; +} + +function toMegabytes(bytes: number) { + const mb = bytes / 1000 / 1000; + return `${Math.round(mb * 10) / 10} MB`; +} diff --git a/remote/test/puppeteer/packages/browsers/src/Cache.ts b/remote/test/puppeteer/packages/browsers/src/Cache.ts new file mode 100644 index 0000000000..13b465835a --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/Cache.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + Browser, + type BrowserPlatform, + executablePathByBrowser, +} from './browser-data/browser-data.js'; +import {detectBrowserPlatform} from './detectPlatform.js'; + +/** + * @public + */ +export class InstalledBrowser { + browser: Browser; + buildId: string; + platform: BrowserPlatform; + readonly executablePath: string; + + #cache: Cache; + + /** + * @internal + */ + constructor( + cache: Cache, + browser: Browser, + buildId: string, + platform: BrowserPlatform + ) { + this.#cache = cache; + this.browser = browser; + this.buildId = buildId; + this.platform = platform; + this.executablePath = cache.computeExecutablePath({ + browser, + buildId, + platform, + }); + } + + /** + * Path to the root of the installation folder. Use + * {@link computeExecutablePath} to get the path to the executable binary. + */ + get path(): string { + return this.#cache.installationDir( + this.browser, + this.platform, + this.buildId + ); + } +} + +/** + * @internal + */ +export interface ComputeExecutablePathOptions { + /** + * Determines which platform the browser will be suited for. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * Determines which browser to launch. + */ + browser: Browser; + /** + * Determines which buildId to download. BuildId should uniquely identify + * binaries and they are used for caching. + */ + buildId: string; +} + +/** + * The cache used by Puppeteer relies on the following structure: + * + * - rootDir + * -- <browser1> | browserRoot(browser1) + * ---- <platform>-<buildId> | installationDir() + * ------ the browser-platform-buildId + * ------ specific structure. + * -- <browser2> | browserRoot(browser2) + * ---- <platform>-<buildId> | installationDir() + * ------ the browser-platform-buildId + * ------ specific structure. + * @internal + */ +export class Cache { + #rootDir: string; + + constructor(rootDir: string) { + this.#rootDir = rootDir; + } + + /** + * @internal + */ + get rootDir(): string { + return this.#rootDir; + } + + browserRoot(browser: Browser): string { + return path.join(this.#rootDir, browser); + } + + installationDir( + browser: Browser, + platform: BrowserPlatform, + buildId: string + ): string { + return path.join(this.browserRoot(browser), `${platform}-${buildId}`); + } + + clear(): void { + fs.rmSync(this.#rootDir, { + force: true, + recursive: true, + maxRetries: 10, + retryDelay: 500, + }); + } + + uninstall( + browser: Browser, + platform: BrowserPlatform, + buildId: string + ): void { + fs.rmSync(this.installationDir(browser, platform, buildId), { + force: true, + recursive: true, + maxRetries: 10, + retryDelay: 500, + }); + } + + getInstalledBrowsers(): InstalledBrowser[] { + if (!fs.existsSync(this.#rootDir)) { + return []; + } + const types = fs.readdirSync(this.#rootDir); + const browsers = types.filter((t): t is Browser => { + return (Object.values(Browser) as string[]).includes(t); + }); + return browsers.flatMap(browser => { + const files = fs.readdirSync(this.browserRoot(browser)); + return files + .map(file => { + const result = parseFolderPath( + path.join(this.browserRoot(browser), file) + ); + if (!result) { + return null; + } + return new InstalledBrowser( + this, + browser, + result.buildId, + result.platform as BrowserPlatform + ); + }) + .filter((item: InstalledBrowser | null): item is InstalledBrowser => { + return item !== null; + }); + }); + } + + computeExecutablePath(options: ComputeExecutablePathOptions): string { + options.platform ??= detectBrowserPlatform(); + if (!options.platform) { + throw new Error( + `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})` + ); + } + const installationDir = this.installationDir( + options.browser, + options.platform, + options.buildId + ); + return path.join( + installationDir, + executablePathByBrowser[options.browser]( + options.platform, + options.buildId + ) + ); + } +} + +function parseFolderPath( + folderPath: string +): {platform: string; buildId: string} | undefined { + const name = path.basename(folderPath); + const splits = name.split('-'); + if (splits.length !== 2) { + return; + } + const [platform, buildId] = splits; + if (!buildId || !platform) { + return; + } + return {platform, buildId}; +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/browser-data.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/browser-data.ts new file mode 100644 index 0000000000..67bb4990b2 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/browser-data.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as chromeHeadlessShell from './chrome-headless-shell.js'; +import * as chrome from './chrome.js'; +import * as chromedriver from './chromedriver.js'; +import * as chromium from './chromium.js'; +import * as firefox from './firefox.js'; +import { + Browser, + BrowserPlatform, + BrowserTag, + ChromeReleaseChannel, + type ProfileOptions, +} from './types.js'; + +export type {ProfileOptions}; + +export const downloadUrls = { + [Browser.CHROMEDRIVER]: chromedriver.resolveDownloadUrl, + [Browser.CHROMEHEADLESSSHELL]: chromeHeadlessShell.resolveDownloadUrl, + [Browser.CHROME]: chrome.resolveDownloadUrl, + [Browser.CHROMIUM]: chromium.resolveDownloadUrl, + [Browser.FIREFOX]: firefox.resolveDownloadUrl, +}; + +export const downloadPaths = { + [Browser.CHROMEDRIVER]: chromedriver.resolveDownloadPath, + [Browser.CHROMEHEADLESSSHELL]: chromeHeadlessShell.resolveDownloadPath, + [Browser.CHROME]: chrome.resolveDownloadPath, + [Browser.CHROMIUM]: chromium.resolveDownloadPath, + [Browser.FIREFOX]: firefox.resolveDownloadPath, +}; + +export const executablePathByBrowser = { + [Browser.CHROMEDRIVER]: chromedriver.relativeExecutablePath, + [Browser.CHROMEHEADLESSSHELL]: chromeHeadlessShell.relativeExecutablePath, + [Browser.CHROME]: chrome.relativeExecutablePath, + [Browser.CHROMIUM]: chromium.relativeExecutablePath, + [Browser.FIREFOX]: firefox.relativeExecutablePath, +}; + +export {Browser, BrowserPlatform, ChromeReleaseChannel}; + +/** + * @public + */ +export async function resolveBuildId( + browser: Browser, + platform: BrowserPlatform, + tag: string +): Promise<string> { + switch (browser) { + case Browser.FIREFOX: + switch (tag as BrowserTag) { + case BrowserTag.LATEST: + return await firefox.resolveBuildId('FIREFOX_NIGHTLY'); + case BrowserTag.BETA: + case BrowserTag.CANARY: + case BrowserTag.DEV: + case BrowserTag.STABLE: + throw new Error( + `${tag} is not supported for ${browser}. Use 'latest' instead.` + ); + } + case Browser.CHROME: { + switch (tag as BrowserTag) { + case BrowserTag.LATEST: + return await chrome.resolveBuildId(ChromeReleaseChannel.CANARY); + case BrowserTag.BETA: + return await chrome.resolveBuildId(ChromeReleaseChannel.BETA); + case BrowserTag.CANARY: + return await chrome.resolveBuildId(ChromeReleaseChannel.CANARY); + case BrowserTag.DEV: + return await chrome.resolveBuildId(ChromeReleaseChannel.DEV); + case BrowserTag.STABLE: + return await chrome.resolveBuildId(ChromeReleaseChannel.STABLE); + default: + const result = await chrome.resolveBuildId(tag); + if (result) { + return result; + } + } + return tag; + } + case Browser.CHROMEDRIVER: { + switch (tag) { + case BrowserTag.LATEST: + case BrowserTag.CANARY: + return await chromedriver.resolveBuildId(ChromeReleaseChannel.CANARY); + case BrowserTag.BETA: + return await chromedriver.resolveBuildId(ChromeReleaseChannel.BETA); + case BrowserTag.DEV: + return await chromedriver.resolveBuildId(ChromeReleaseChannel.DEV); + case BrowserTag.STABLE: + return await chromedriver.resolveBuildId(ChromeReleaseChannel.STABLE); + default: + const result = await chromedriver.resolveBuildId(tag); + if (result) { + return result; + } + } + return tag; + } + case Browser.CHROMEHEADLESSSHELL: { + switch (tag) { + case BrowserTag.LATEST: + case BrowserTag.CANARY: + return await chromeHeadlessShell.resolveBuildId( + ChromeReleaseChannel.CANARY + ); + case BrowserTag.BETA: + return await chromeHeadlessShell.resolveBuildId( + ChromeReleaseChannel.BETA + ); + case BrowserTag.DEV: + return await chromeHeadlessShell.resolveBuildId( + ChromeReleaseChannel.DEV + ); + case BrowserTag.STABLE: + return await chromeHeadlessShell.resolveBuildId( + ChromeReleaseChannel.STABLE + ); + default: + const result = await chromeHeadlessShell.resolveBuildId(tag); + if (result) { + return result; + } + } + return tag; + } + case Browser.CHROMIUM: + switch (tag as BrowserTag) { + case BrowserTag.LATEST: + return await chromium.resolveBuildId(platform); + case BrowserTag.BETA: + case BrowserTag.CANARY: + case BrowserTag.DEV: + case BrowserTag.STABLE: + throw new Error( + `${tag} is not supported for ${browser}. Use 'latest' instead.` + ); + } + } + // We assume the tag is the buildId if it didn't match any keywords. + return tag; +} + +/** + * @public + */ +export async function createProfile( + browser: Browser, + opts: ProfileOptions +): Promise<void> { + switch (browser) { + case Browser.FIREFOX: + return await firefox.createProfile(opts); + case Browser.CHROME: + case Browser.CHROMIUM: + throw new Error(`Profile creation is not support for ${browser} yet`); + } +} + +/** + * @public + */ +export function resolveSystemExecutablePath( + browser: Browser, + platform: BrowserPlatform, + channel: ChromeReleaseChannel +): string { + switch (browser) { + case Browser.CHROMEDRIVER: + case Browser.CHROMEHEADLESSSHELL: + case Browser.FIREFOX: + case Browser.CHROMIUM: + throw new Error( + `System browser detection is not supported for ${browser} yet.` + ); + case Browser.CHROME: + return chrome.resolveSystemExecutablePath(platform, channel); + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/chrome-headless-shell.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/chrome-headless-shell.ts new file mode 100644 index 0000000000..b1c6178de8 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/chrome-headless-shell.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import path from 'path'; + +import {BrowserPlatform} from './types.js'; + +function folder(platform: BrowserPlatform): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'linux64'; + case BrowserPlatform.MAC_ARM: + return 'mac-arm64'; + case BrowserPlatform.MAC: + return 'mac-x64'; + case BrowserPlatform.WIN32: + return 'win32'; + case BrowserPlatform.WIN64: + return 'win64'; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [ + buildId, + folder(platform), + `chrome-headless-shell-${folder(platform)}.zip`, + ]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC: + case BrowserPlatform.MAC_ARM: + return path.join( + 'chrome-headless-shell-' + folder(platform), + 'chrome-headless-shell' + ); + case BrowserPlatform.LINUX: + return path.join( + 'chrome-headless-shell-linux64', + 'chrome-headless-shell' + ); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join( + 'chrome-headless-shell-' + folder(platform), + 'chrome-headless-shell.exe' + ); + } +} + +export {resolveBuildId} from './chrome.js'; diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/chrome.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/chrome.ts new file mode 100644 index 0000000000..c6329255c3 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/chrome.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; + +import {getJSON} from '../httpUtil.js'; + +import {BrowserPlatform, ChromeReleaseChannel} from './types.js'; + +function folder(platform: BrowserPlatform): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'linux64'; + case BrowserPlatform.MAC_ARM: + return 'mac-arm64'; + case BrowserPlatform.MAC: + return 'mac-x64'; + case BrowserPlatform.WIN32: + return 'win32'; + case BrowserPlatform.WIN64: + return 'win64'; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [buildId, folder(platform), `chrome-${folder(platform)}.zip`]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC: + case BrowserPlatform.MAC_ARM: + return path.join( + 'chrome-' + folder(platform), + 'Google Chrome for Testing.app', + 'Contents', + 'MacOS', + 'Google Chrome for Testing' + ); + case BrowserPlatform.LINUX: + return path.join('chrome-linux64', 'chrome'); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join('chrome-' + folder(platform), 'chrome.exe'); + } +} + +export async function getLastKnownGoodReleaseForChannel( + channel: ChromeReleaseChannel +): Promise<{version: string; revision: string}> { + const data = (await getJSON( + new URL( + 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json' + ) + )) as { + channels: Record<string, {version: string}>; + }; + + for (const channel of Object.keys(data.channels)) { + data.channels[channel.toLowerCase()] = data.channels[channel]!; + delete data.channels[channel]; + } + + return ( + data as { + channels: { + [channel in ChromeReleaseChannel]: {version: string; revision: string}; + }; + } + ).channels[channel]; +} + +export async function getLastKnownGoodReleaseForMilestone( + milestone: string +): Promise<{version: string; revision: string} | undefined> { + const data = (await getJSON( + new URL( + 'https://googlechromelabs.github.io/chrome-for-testing/latest-versions-per-milestone.json' + ) + )) as { + milestones: Record<string, {version: string; revision: string}>; + }; + return data.milestones[milestone] as + | {version: string; revision: string} + | undefined; +} + +export async function getLastKnownGoodReleaseForBuild( + /** + * @example `112.0.23`, + */ + buildPrefix: string +): Promise<{version: string; revision: string} | undefined> { + const data = (await getJSON( + new URL( + 'https://googlechromelabs.github.io/chrome-for-testing/latest-patch-versions-per-build.json' + ) + )) as { + builds: Record<string, {version: string; revision: string}>; + }; + return data.builds[buildPrefix] as + | {version: string; revision: string} + | undefined; +} + +export async function resolveBuildId( + channel: ChromeReleaseChannel +): Promise<string>; +export async function resolveBuildId( + channel: string +): Promise<string | undefined>; +export async function resolveBuildId( + channel: ChromeReleaseChannel | string +): Promise<string | undefined> { + if ( + Object.values(ChromeReleaseChannel).includes( + channel as ChromeReleaseChannel + ) + ) { + return ( + await getLastKnownGoodReleaseForChannel(channel as ChromeReleaseChannel) + ).version; + } + if (channel.match(/^\d+$/)) { + // Potentially a milestone. + return (await getLastKnownGoodReleaseForMilestone(channel))?.version; + } + if (channel.match(/^\d+\.\d+\.\d+$/)) { + // Potentially a build prefix without the patch version. + return (await getLastKnownGoodReleaseForBuild(channel))?.version; + } + return; +} + +export function resolveSystemExecutablePath( + platform: BrowserPlatform, + channel: ChromeReleaseChannel +): string { + switch (platform) { + case BrowserPlatform.WIN64: + case BrowserPlatform.WIN32: + switch (channel) { + case ChromeReleaseChannel.STABLE: + return `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`; + case ChromeReleaseChannel.BETA: + return `${process.env['PROGRAMFILES']}\\Google\\Chrome Beta\\Application\\chrome.exe`; + case ChromeReleaseChannel.CANARY: + return `${process.env['PROGRAMFILES']}\\Google\\Chrome SxS\\Application\\chrome.exe`; + case ChromeReleaseChannel.DEV: + return `${process.env['PROGRAMFILES']}\\Google\\Chrome Dev\\Application\\chrome.exe`; + } + case BrowserPlatform.MAC_ARM: + case BrowserPlatform.MAC: + switch (channel) { + case ChromeReleaseChannel.STABLE: + return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + case ChromeReleaseChannel.BETA: + return '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta'; + case ChromeReleaseChannel.CANARY: + return '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'; + case ChromeReleaseChannel.DEV: + return '/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev'; + } + case BrowserPlatform.LINUX: + switch (channel) { + case ChromeReleaseChannel.STABLE: + return '/opt/google/chrome/chrome'; + case ChromeReleaseChannel.BETA: + return '/opt/google/chrome-beta/chrome'; + case ChromeReleaseChannel.DEV: + return '/opt/google/chrome-unstable/chrome'; + } + } + + throw new Error( + `Unable to detect browser executable path for '${channel}' on ${platform}.` + ); +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/chromedriver.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/chromedriver.ts new file mode 100644 index 0000000000..290598d0d7 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/chromedriver.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import path from 'path'; + +import {BrowserPlatform} from './types.js'; + +function folder(platform: BrowserPlatform): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'linux64'; + case BrowserPlatform.MAC_ARM: + return 'mac-arm64'; + case BrowserPlatform.MAC: + return 'mac-x64'; + case BrowserPlatform.WIN32: + return 'win32'; + case BrowserPlatform.WIN64: + return 'win64'; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [buildId, folder(platform), `chromedriver-${folder(platform)}.zip`]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC: + case BrowserPlatform.MAC_ARM: + return path.join('chromedriver-' + folder(platform), 'chromedriver'); + case BrowserPlatform.LINUX: + return path.join('chromedriver-linux64', 'chromedriver'); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join('chromedriver-' + folder(platform), 'chromedriver.exe'); + } +} + +export {resolveBuildId} from './chrome.js'; diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/chromium.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/chromium.ts new file mode 100644 index 0000000000..09cfc987a8 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/chromium.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; + +import {getText} from '../httpUtil.js'; + +import {BrowserPlatform} from './types.js'; + +function archive(platform: BrowserPlatform, buildId: string): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'chrome-linux'; + case BrowserPlatform.MAC_ARM: + case BrowserPlatform.MAC: + return 'chrome-mac'; + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + // Windows archive name changed at r591479. + return parseInt(buildId, 10) > 591479 ? 'chrome-win' : 'chrome-win32'; + } +} + +function folder(platform: BrowserPlatform): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'Linux_x64'; + case BrowserPlatform.MAC_ARM: + return 'Mac_Arm'; + case BrowserPlatform.MAC: + return 'Mac'; + case BrowserPlatform.WIN32: + return 'Win'; + case BrowserPlatform.WIN64: + return 'Win_x64'; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://storage.googleapis.com/chromium-browser-snapshots' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [folder(platform), buildId, `${archive(platform, buildId)}.zip`]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC: + case BrowserPlatform.MAC_ARM: + return path.join( + 'chrome-mac', + 'Chromium.app', + 'Contents', + 'MacOS', + 'Chromium' + ); + case BrowserPlatform.LINUX: + return path.join('chrome-linux', 'chrome'); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join('chrome-win', 'chrome.exe'); + } +} +export async function resolveBuildId( + platform: BrowserPlatform +): Promise<string> { + return await getText( + new URL( + `https://storage.googleapis.com/chromium-browser-snapshots/${folder( + platform + )}/LAST_CHANGE` + ) + ); +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts new file mode 100644 index 0000000000..ccc30fa1b5 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts @@ -0,0 +1,330 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; + +import {getJSON} from '../httpUtil.js'; + +import {BrowserPlatform, type ProfileOptions} from './types.js'; + +function archive(platform: BrowserPlatform, buildId: string): string { + switch (platform) { + case BrowserPlatform.LINUX: + return `firefox-${buildId}.en-US.${platform}-x86_64.tar.bz2`; + case BrowserPlatform.MAC_ARM: + case BrowserPlatform.MAC: + return `firefox-${buildId}.en-US.mac.dmg`; + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return `firefox-${buildId}.en-US.${platform}.zip`; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [archive(platform, buildId)]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC_ARM: + case BrowserPlatform.MAC: + return path.join('Firefox Nightly.app', 'Contents', 'MacOS', 'firefox'); + case BrowserPlatform.LINUX: + return path.join('firefox', 'firefox'); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join('firefox', 'firefox.exe'); + } +} + +export async function resolveBuildId( + channel: 'FIREFOX_NIGHTLY' = 'FIREFOX_NIGHTLY' +): Promise<string> { + const versions = (await getJSON( + new URL('https://product-details.mozilla.org/1.0/firefox_versions.json') + )) as Record<string, string>; + const version = versions[channel]; + if (!version) { + throw new Error(`Channel ${channel} is not found.`); + } + return version; +} + +export async function createProfile(options: ProfileOptions): Promise<void> { + if (!fs.existsSync(options.path)) { + await fs.promises.mkdir(options.path, { + recursive: true, + }); + } + await writePreferences({ + preferences: { + ...defaultProfilePreferences(options.preferences), + ...options.preferences, + }, + path: options.path, + }); +} + +function defaultProfilePreferences( + extraPrefs: Record<string, unknown> +): Record<string, unknown> { + const server = 'dummy.test'; + + const defaultPrefs = { + // Make sure Shield doesn't hit the network. + 'app.normandy.api_url': '', + // Disable Firefox old build background check + 'app.update.checkInstallTime': false, + // Disable automatically upgrading Firefox + 'app.update.disabledForTesting': true, + + // Increase the APZ content response timeout to 1 minute + 'apz.content_response_timeout': 60000, + + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'browser.contentblocking.features.standard': + '-tp,tpPrivate,cookieBehavior0,-cm,-fp', + + // Enable the dump function: which sends messages to the system + // console + // https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 + 'browser.dom.window.dump.enabled': true, + // Disable topstories + 'browser.newtabpage.activity-stream.feeds.system.topstories': false, + // Always display a blank page + 'browser.newtabpage.enabled': false, + // Background thumbnails in particular cause grief: and disabling + // thumbnails in general cannot hurt + 'browser.pagethumbnails.capturing_disabled': true, + + // Disable safebrowsing components. + 'browser.safebrowsing.blockedURIs.enabled': false, + 'browser.safebrowsing.downloads.enabled': false, + 'browser.safebrowsing.malware.enabled': false, + 'browser.safebrowsing.phishing.enabled': false, + + // Disable updates to search engines. + 'browser.search.update': false, + // Do not restore the last open set of tabs if the browser has crashed + 'browser.sessionstore.resume_from_crash': false, + // Skip check for default browser on startup + 'browser.shell.checkDefaultBrowser': false, + + // Disable newtabpage + 'browser.startup.homepage': 'about:blank', + // Do not redirect user when a milstone upgrade of Firefox is detected + 'browser.startup.homepage_override.mstone': 'ignore', + // Start with a blank page about:blank + 'browser.startup.page': 0, + + // Do not allow background tabs to be zombified on Android: otherwise for + // tests that open additional tabs: the test harness tab itself might get + // unloaded + 'browser.tabs.disableBackgroundZombification': false, + // Do not warn when closing all other open tabs + 'browser.tabs.warnOnCloseOtherTabs': false, + // Do not warn when multiple tabs will be opened + 'browser.tabs.warnOnOpen': false, + + // Do not automatically offer translations, as tests do not expect this. + 'browser.translations.automaticallyPopup': false, + + // Disable the UI tour. + 'browser.uitour.enabled': false, + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + 'browser.urlbar.suggest.searches': false, + // Disable first run splash page on Windows 10 + 'browser.usedOnWindows10.introURL': '', + // Do not warn on quitting Firefox + 'browser.warnOnQuit': false, + + // Defensively disable data reporting systems + 'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`, + 'datareporting.healthreport.logging.consoleEnabled': false, + 'datareporting.healthreport.service.enabled': false, + 'datareporting.healthreport.service.firstRun': false, + 'datareporting.healthreport.uploadEnabled': false, + + // Do not show datareporting policy notifications which can interfere with tests + 'datareporting.policy.dataSubmissionEnabled': false, + 'datareporting.policy.dataSubmissionPolicyBypassNotification': true, + + // DevTools JSONViewer sometimes fails to load dependencies with its require.js. + // This doesn't affect Puppeteer but spams console (Bug 1424372) + 'devtools.jsonview.enabled': false, + + // Disable popup-blocker + 'dom.disable_open_during_load': false, + + // Enable the support for File object creation in the content process + // Required for |Page.setFileInputFiles| protocol method. + 'dom.file.createInChild': true, + + // Disable the ProcessHangMonitor + 'dom.ipc.reportProcessHangs': false, + + // Disable slow script dialogues + 'dom.max_chrome_script_run_time': 0, + 'dom.max_script_run_time': 0, + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + 'extensions.autoDisableScopes': 0, + 'extensions.enabledScopes': 5, + + // Disable metadata caching for installed add-ons by default + 'extensions.getAddons.cache.enabled': false, + + // Disable installing any distribution extensions or add-ons. + 'extensions.installDistroAddons': false, + + // Disabled screenshots extension + 'extensions.screenshots.disabled': true, + + // Turn off extension updates so they do not bother tests + 'extensions.update.enabled': false, + + // Turn off extension updates so they do not bother tests + 'extensions.update.notifyUser': false, + + // Make sure opening about:addons will not hit the network + 'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`, + + // Allow the application to have focus even it runs in the background + 'focusmanager.testmode': true, + + // Disable useragent updates + 'general.useragent.updates.enabled': false, + + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + 'geo.provider.testing': true, + + // Do not scan Wifi + 'geo.wifi.scan': false, + + // No hang monitor + 'hangmonitor.timeout': 0, + + // Show chrome errors and warnings in the error console + 'javascript.options.showInConsole': true, + + // Disable download and usage of OpenH264: and Widevine plugins + 'media.gmp-manager.updateEnabled': false, + + // Disable the GFX sanity window + 'media.sanity-test.disabled': true, + + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'network.cookie.cookieBehavior': 0, + + // Disable experimental feature that is only available in Nightly + 'network.cookie.sameSite.laxByDefault': false, + + // Do not prompt for temporary redirects + 'network.http.prompt-temp-redirect': false, + + // Disable speculative connections so they are not reported as leaking + // when they are hanging around + 'network.http.speculative-parallel-limit': 0, + + // Do not automatically switch between offline and online + 'network.manage-offline-status': false, + + // Make sure SNTP requests do not hit the network + 'network.sntp.pools': server, + + // Disable Flash. + 'plugin.state.flash': 0, + + 'privacy.trackingprotection.enabled': false, + + // Can be removed once Firefox 89 is no longer supported + // https://bugzilla.mozilla.org/show_bug.cgi?id=1710839 + 'remote.enabled': true, + + // Don't do network connections for mitm priming + 'security.certerrors.mitm.priming.enabled': false, + + // Local documents have access to all other local documents, + // including directory listings + 'security.fileuri.strict_origin_policy': false, + + // Do not wait for the notification button security delay + 'security.notification_enable_delay': 0, + + // Ensure blocklist updates do not hit the network + 'services.settings.server': `http://${server}/dummy/blocklist/`, + + // Do not automatically fill sign-in forms with known usernames and + // passwords + 'signon.autofillForms': false, + + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + 'signon.rememberSignons': false, + + // Disable first-run welcome page + 'startup.homepage_welcome_url': 'about:blank', + + // Disable first-run welcome page + 'startup.homepage_welcome_url.additional': '', + + // Disable browser animations (tabs, fullscreen, sliding alerts) + 'toolkit.cosmeticAnimations.enabled': false, + + // Prevent starting into safe mode after application crashes + 'toolkit.startup.max_resumed_crashes': -1, + }; + + return Object.assign(defaultPrefs, extraPrefs); +} + +/** + * Populates the user.js file with custom preferences as needed to allow + * Firefox's CDP support to properly function. These preferences will be + * automatically copied over to prefs.js during startup of Firefox. To be + * able to restore the original values of preferences a backup of prefs.js + * will be created. + * + * @param prefs - List of preferences to add. + * @param profilePath - Firefox profile to write the preferences to. + */ +async function writePreferences(options: ProfileOptions): Promise<void> { + const lines = Object.entries(options.preferences).map(([key, value]) => { + return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`; + }); + + await fs.promises.writeFile( + path.join(options.path, 'user.js'), + lines.join('\n') + ); + + // Create a backup of the preferences file if it already exitsts. + const prefsPath = path.join(options.path, 'prefs.js'); + if (fs.existsSync(prefsPath)) { + const prefsBackupPath = path.join(options.path, 'prefs.js.puppeteer'); + await fs.promises.copyFile(prefsPath, prefsBackupPath); + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/types.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/types.ts new file mode 100644 index 0000000000..ac72661a2d --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/types.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Supported browsers. + * + * @public + */ +export enum Browser { + CHROME = 'chrome', + CHROMEHEADLESSSHELL = 'chrome-headless-shell', + CHROMIUM = 'chromium', + FIREFOX = 'firefox', + CHROMEDRIVER = 'chromedriver', +} + +/** + * Platform names used to identify a OS platform x architecture combination in the way + * that is relevant for the browser download. + * + * @public + */ +export enum BrowserPlatform { + LINUX = 'linux', + MAC = 'mac', + MAC_ARM = 'mac_arm', + WIN32 = 'win32', + WIN64 = 'win64', +} + +/** + * @public + */ +export enum BrowserTag { + CANARY = 'canary', + BETA = 'beta', + DEV = 'dev', + STABLE = 'stable', + LATEST = 'latest', +} + +/** + * @public + */ +export interface ProfileOptions { + preferences: Record<string, unknown>; + path: string; +} + +/** + * @public + */ +export enum ChromeReleaseChannel { + STABLE = 'stable', + DEV = 'dev', + CANARY = 'canary', + BETA = 'beta', +} diff --git a/remote/test/puppeteer/packages/browsers/src/debug.ts b/remote/test/puppeteer/packages/browsers/src/debug.ts new file mode 100644 index 0000000000..491097f41d --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/debug.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import debug from 'debug'; + +export {debug}; diff --git a/remote/test/puppeteer/packages/browsers/src/detectPlatform.ts b/remote/test/puppeteer/packages/browsers/src/detectPlatform.ts new file mode 100644 index 0000000000..df644c38b7 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/detectPlatform.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'os'; + +import {BrowserPlatform} from './browser-data/browser-data.js'; + +/** + * @public + */ +export function detectBrowserPlatform(): BrowserPlatform | undefined { + const platform = os.platform(); + switch (platform) { + case 'darwin': + return os.arch() === 'arm64' + ? BrowserPlatform.MAC_ARM + : BrowserPlatform.MAC; + case 'linux': + return BrowserPlatform.LINUX; + case 'win32': + return os.arch() === 'x64' || + // Windows 11 for ARM supports x64 emulation + (os.arch() === 'arm64' && isWindows11(os.release())) + ? BrowserPlatform.WIN64 + : BrowserPlatform.WIN32; + default: + return undefined; + } +} + +/** + * Windows 11 is identified by the version 10.0.22000 or greater + * @internal + */ +function isWindows11(version: string): boolean { + const parts = version.split('.'); + if (parts.length > 2) { + const major = parseInt(parts[0] as string, 10); + const minor = parseInt(parts[1] as string, 10); + const patch = parseInt(parts[2] as string, 10); + return ( + major > 10 || + (major === 10 && minor > 0) || + (major === 10 && minor === 0 && patch >= 22000) + ); + } + return false; +} diff --git a/remote/test/puppeteer/packages/browsers/src/fileUtil.ts b/remote/test/puppeteer/packages/browsers/src/fileUtil.ts new file mode 100644 index 0000000000..50a6897853 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/fileUtil.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {exec as execChildProcess} from 'child_process'; +import {createReadStream} from 'fs'; +import {mkdir, readdir} from 'fs/promises'; +import * as path from 'path'; +import {promisify} from 'util'; + +import extractZip from 'extract-zip'; +import tar from 'tar-fs'; +import bzip from 'unbzip2-stream'; + +const exec = promisify(execChildProcess); + +/** + * @internal + */ +export async function unpackArchive( + archivePath: string, + folderPath: string +): Promise<void> { + if (archivePath.endsWith('.zip')) { + await extractZip(archivePath, {dir: folderPath}); + } else if (archivePath.endsWith('.tar.bz2')) { + await extractTar(archivePath, folderPath); + } else if (archivePath.endsWith('.dmg')) { + await mkdir(folderPath); + await installDMG(archivePath, folderPath); + } else { + throw new Error(`Unsupported archive format: ${archivePath}`); + } +} + +/** + * @internal + */ +function extractTar(tarPath: string, folderPath: string): Promise<void> { + return new Promise((fulfill, reject) => { + const tarStream = tar.extract(folderPath); + tarStream.on('error', reject); + tarStream.on('finish', fulfill); + const readStream = createReadStream(tarPath); + readStream.pipe(bzip()).pipe(tarStream); + }); +} + +/** + * @internal + */ +async function installDMG(dmgPath: string, folderPath: string): Promise<void> { + const {stdout} = await exec( + `hdiutil attach -nobrowse -noautoopen "${dmgPath}"` + ); + + const volumes = stdout.match(/\/Volumes\/(.*)/m); + if (!volumes) { + throw new Error(`Could not find volume path in ${stdout}`); + } + const mountPath = volumes[0]!; + + try { + const fileNames = await readdir(mountPath); + const appName = fileNames.find(item => { + return typeof item === 'string' && item.endsWith('.app'); + }); + if (!appName) { + throw new Error(`Cannot find app in ${mountPath}`); + } + const mountedPath = path.join(mountPath!, appName); + + await exec(`cp -R "${mountedPath}" "${folderPath}"`); + } finally { + await exec(`hdiutil detach "${mountPath}" -quiet`); + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/httpUtil.ts b/remote/test/puppeteer/packages/browsers/src/httpUtil.ts new file mode 100644 index 0000000000..96f7fc9f36 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/httpUtil.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {createWriteStream} from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import {URL, urlToHttpOptions} from 'url'; + +import {ProxyAgent} from 'proxy-agent'; + +export function headHttpRequest(url: URL): Promise<boolean> { + return new Promise(resolve => { + const request = httpRequest( + url, + 'HEAD', + response => { + // consume response data free node process + response.resume(); + resolve(response.statusCode === 200); + }, + false + ); + request.on('error', () => { + resolve(false); + }); + }); +} + +export function httpRequest( + url: URL, + method: string, + response: (x: http.IncomingMessage) => void, + keepAlive = true +): http.ClientRequest { + const options: http.RequestOptions = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method, + headers: keepAlive ? {Connection: 'keep-alive'} : undefined, + auth: urlToHttpOptions(url).auth, + agent: new ProxyAgent(), + }; + + const requestCallback = (res: http.IncomingMessage): void => { + if ( + res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + httpRequest(new URL(res.headers.location), method, response); + } else { + response(res); + } + }; + const request = + options.protocol === 'https:' + ? https.request(options, requestCallback) + : http.request(options, requestCallback); + request.end(); + return request; +} + +/** + * @internal + */ +export function downloadFile( + url: URL, + destinationPath: string, + progressCallback?: (downloadedBytes: number, totalBytes: number) => void +): Promise<void> { + return new Promise<void>((resolve, reject) => { + let downloadedBytes = 0; + let totalBytes = 0; + + function onData(chunk: string): void { + downloadedBytes += chunk.length; + progressCallback!(downloadedBytes, totalBytes); + } + + const request = httpRequest(url, 'GET', response => { + if (response.statusCode !== 200) { + const error = new Error( + `Download failed: server returned code ${response.statusCode}. URL: ${url}` + ); + // consume response data to free up memory + response.resume(); + reject(error); + return; + } + const file = createWriteStream(destinationPath); + file.on('finish', () => { + return resolve(); + }); + file.on('error', error => { + return reject(error); + }); + response.pipe(file); + totalBytes = parseInt(response.headers['content-length']!, 10); + if (progressCallback) { + response.on('data', onData); + } + }); + request.on('error', error => { + return reject(error); + }); + }); +} + +export async function getJSON(url: URL): Promise<unknown> { + const text = await getText(url); + try { + return JSON.parse(text); + } catch { + throw new Error('Could not parse JSON from ' + url.toString()); + } +} + +export function getText(url: URL): Promise<string> { + return new Promise((resolve, reject) => { + const request = httpRequest( + url, + 'GET', + response => { + let data = ''; + if (response.statusCode && response.statusCode >= 400) { + return reject(new Error(`Got status code ${response.statusCode}`)); + } + response.on('data', chunk => { + data += chunk; + }); + response.on('end', () => { + try { + return resolve(String(data)); + } catch { + return reject(new Error('Chrome version not found')); + } + }); + }, + false + ); + request.on('error', err => { + reject(err); + }); + }); +} diff --git a/remote/test/puppeteer/packages/browsers/src/install.ts b/remote/test/puppeteer/packages/browsers/src/install.ts new file mode 100644 index 0000000000..375c75babc --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/install.ts @@ -0,0 +1,271 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {existsSync} from 'fs'; +import {mkdir, unlink} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + type Browser, + type BrowserPlatform, + downloadUrls, +} from './browser-data/browser-data.js'; +import {Cache, InstalledBrowser} from './Cache.js'; +import {debug} from './debug.js'; +import {detectBrowserPlatform} from './detectPlatform.js'; +import {unpackArchive} from './fileUtil.js'; +import {downloadFile, headHttpRequest} from './httpUtil.js'; + +const debugInstall = debug('puppeteer:browsers:install'); + +const times = new Map<string, [number, number]>(); +function debugTime(label: string) { + times.set(label, process.hrtime()); +} + +function debugTimeEnd(label: string) { + const end = process.hrtime(); + const start = times.get(label); + if (!start) { + return; + } + const duration = + end[0] * 1000 + end[1] / 1e6 - (start[0] * 1000 + start[1] / 1e6); // calculate duration in milliseconds + debugInstall(`Duration for ${label}: ${duration}ms`); +} + +/** + * @public + */ +export interface InstallOptions { + /** + * Determines the path to download browsers to. + */ + cacheDir: string; + /** + * Determines which platform the browser will be suited for. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * Determines which browser to install. + */ + browser: Browser; + /** + * Determines which buildId to download. BuildId should uniquely identify + * binaries and they are used for caching. + */ + buildId: string; + /** + * Provides information about the progress of the download. + */ + downloadProgressCallback?: ( + downloadedBytes: number, + totalBytes: number + ) => void; + /** + * Determines the host that will be used for downloading. + * + * @defaultValue Either + * + * - https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing or + * - https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central + * + */ + baseUrl?: string; + /** + * Whether to unpack and install browser archives. + * + * @defaultValue `true` + */ + unpack?: boolean; +} + +/** + * @public + */ +export function install( + options: InstallOptions & {unpack?: true} +): Promise<InstalledBrowser>; +/** + * @public + */ +export function install( + options: InstallOptions & {unpack: false} +): Promise<string>; +export async function install( + options: InstallOptions +): Promise<InstalledBrowser | string> { + options.platform ??= detectBrowserPlatform(); + options.unpack ??= true; + if (!options.platform) { + throw new Error( + `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})` + ); + } + const url = getDownloadUrl( + options.browser, + options.platform, + options.buildId, + options.baseUrl + ); + const fileName = url.toString().split('/').pop(); + assert(fileName, `A malformed download URL was found: ${url}.`); + const cache = new Cache(options.cacheDir); + const browserRoot = cache.browserRoot(options.browser); + const archivePath = path.join(browserRoot, `${options.buildId}-${fileName}`); + if (!existsSync(browserRoot)) { + await mkdir(browserRoot, {recursive: true}); + } + + if (!options.unpack) { + if (existsSync(archivePath)) { + return archivePath; + } + debugInstall(`Downloading binary from ${url}`); + debugTime('download'); + await downloadFile(url, archivePath, options.downloadProgressCallback); + debugTimeEnd('download'); + return archivePath; + } + + const outputPath = cache.installationDir( + options.browser, + options.platform, + options.buildId + ); + if (existsSync(outputPath)) { + return new InstalledBrowser( + cache, + options.browser, + options.buildId, + options.platform + ); + } + try { + debugInstall(`Downloading binary from ${url}`); + try { + debugTime('download'); + await downloadFile(url, archivePath, options.downloadProgressCallback); + } finally { + debugTimeEnd('download'); + } + + debugInstall(`Installing ${archivePath} to ${outputPath}`); + try { + debugTime('extract'); + await unpackArchive(archivePath, outputPath); + } finally { + debugTimeEnd('extract'); + } + } finally { + if (existsSync(archivePath)) { + await unlink(archivePath); + } + } + return new InstalledBrowser( + cache, + options.browser, + options.buildId, + options.platform + ); +} + +/** + * @public + */ +export interface UninstallOptions { + /** + * Determines the platform for the browser binary. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * The path to the root of the cache directory. + */ + cacheDir: string; + /** + * Determines which browser to uninstall. + */ + browser: Browser; + /** + * The browser build to uninstall + */ + buildId: string; +} + +/** + * + * @public + */ +export async function uninstall(options: UninstallOptions): Promise<void> { + options.platform ??= detectBrowserPlatform(); + if (!options.platform) { + throw new Error( + `Cannot detect the browser platform for: ${os.platform()} (${os.arch()})` + ); + } + + new Cache(options.cacheDir).uninstall( + options.browser, + options.platform, + options.buildId + ); +} + +/** + * @public + */ +export interface GetInstalledBrowsersOptions { + /** + * The path to the root of the cache directory. + */ + cacheDir: string; +} + +/** + * Returns metadata about browsers installed in the cache directory. + * + * @public + */ +export async function getInstalledBrowsers( + options: GetInstalledBrowsersOptions +): Promise<InstalledBrowser[]> { + return new Cache(options.cacheDir).getInstalledBrowsers(); +} + +/** + * @public + */ +export async function canDownload(options: InstallOptions): Promise<boolean> { + options.platform ??= detectBrowserPlatform(); + if (!options.platform) { + throw new Error( + `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})` + ); + } + return await headHttpRequest( + getDownloadUrl( + options.browser, + options.platform, + options.buildId, + options.baseUrl + ) + ); +} + +function getDownloadUrl( + browser: Browser, + platform: BrowserPlatform, + buildId: string, + baseUrl?: string +): URL { + return new URL(downloadUrls[browser](platform, buildId, baseUrl)); +} diff --git a/remote/test/puppeteer/packages/browsers/src/launch.ts b/remote/test/puppeteer/packages/browsers/src/launch.ts new file mode 100644 index 0000000000..dfb0fbf633 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/launch.ts @@ -0,0 +1,479 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import childProcess from 'child_process'; +import {accessSync} from 'fs'; +import os from 'os'; +import readline from 'readline'; + +import { + type Browser, + type BrowserPlatform, + resolveSystemExecutablePath, + type ChromeReleaseChannel, +} from './browser-data/browser-data.js'; +import {Cache} from './Cache.js'; +import {debug} from './debug.js'; +import {detectBrowserPlatform} from './detectPlatform.js'; + +const debugLaunch = debug('puppeteer:browsers:launcher'); + +/** + * @public + */ +export interface ComputeExecutablePathOptions { + /** + * Root path to the storage directory. + */ + cacheDir: string; + /** + * Determines which platform the browser will be suited for. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * Determines which browser to launch. + */ + browser: Browser; + /** + * Determines which buildId to download. BuildId should uniquely identify + * binaries and they are used for caching. + */ + buildId: string; +} + +/** + * @public + */ +export function computeExecutablePath( + options: ComputeExecutablePathOptions +): string { + return new Cache(options.cacheDir).computeExecutablePath(options); +} + +/** + * @public + */ +export interface SystemOptions { + /** + * Determines which platform the browser will be suited for. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * Determines which browser to launch. + */ + browser: Browser; + /** + * Release channel to look for on the system. + */ + channel: ChromeReleaseChannel; +} + +/** + * @public + */ +export function computeSystemExecutablePath(options: SystemOptions): string { + options.platform ??= detectBrowserPlatform(); + if (!options.platform) { + throw new Error( + `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})` + ); + } + const path = resolveSystemExecutablePath( + options.browser, + options.platform, + options.channel + ); + try { + accessSync(path); + } catch (error) { + throw new Error( + `Could not find Google Chrome executable for channel '${options.channel}' at '${path}'.` + ); + } + return path; +} + +/** + * @public + */ +export interface LaunchOptions { + executablePath: string; + pipe?: boolean; + dumpio?: boolean; + args?: string[]; + env?: Record<string, string | undefined>; + handleSIGINT?: boolean; + handleSIGTERM?: boolean; + handleSIGHUP?: boolean; + detached?: boolean; + onExit?: () => Promise<void>; +} + +/** + * @public + */ +export function launch(opts: LaunchOptions): Process { + return new Process(opts); +} + +/** + * @public + */ +export const CDP_WEBSOCKET_ENDPOINT_REGEX = + /^DevTools listening on (ws:\/\/.*)$/; + +/** + * @public + */ +export const WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX = + /^WebDriver BiDi listening on (ws:\/\/.*)$/; + +/** + * @public + */ +export class Process { + #executablePath; + #args: string[]; + #browserProcess: childProcess.ChildProcess; + #exited = false; + // The browser process can be closed externally or from the driver process. We + // need to invoke the hooks only once though but we don't know how many times + // we will be invoked. + #hooksRan = false; + #onExitHook = async () => {}; + #browserProcessExiting: Promise<void>; + + constructor(opts: LaunchOptions) { + this.#executablePath = opts.executablePath; + this.#args = opts.args ?? []; + + opts.pipe ??= false; + opts.dumpio ??= false; + opts.handleSIGINT ??= true; + opts.handleSIGTERM ??= true; + opts.handleSIGHUP ??= true; + // On non-windows platforms, `detached: true` makes child process a + // leader of a new process group, making it possible to kill child + // process tree with `.kill(-pid)` command. @see + // https://nodejs.org/api/child_process.html#child_process_options_detached + opts.detached ??= process.platform !== 'win32'; + + const stdio = this.#configureStdio({ + pipe: opts.pipe, + dumpio: opts.dumpio, + }); + + const env = opts.env || {}; + + debugLaunch(`Launching ${this.#executablePath} ${this.#args.join(' ')}`, { + detached: opts.detached, + env: Object.keys(env).reduce<Record<string, string | undefined>>( + (res, key) => { + if (key.toLowerCase().startsWith('puppeteer_')) { + res[key] = env[key]; + } + return res; + }, + {} + ), + stdio, + }); + + this.#browserProcess = childProcess.spawn( + this.#executablePath, + this.#args, + { + detached: opts.detached, + env, + stdio, + } + ); + + debugLaunch(`Launched ${this.#browserProcess.pid}`); + if (opts.dumpio) { + this.#browserProcess.stderr?.pipe(process.stderr); + this.#browserProcess.stdout?.pipe(process.stdout); + } + process.on('exit', this.#onDriverProcessExit); + if (opts.handleSIGINT) { + process.on('SIGINT', this.#onDriverProcessSignal); + } + if (opts.handleSIGTERM) { + process.on('SIGTERM', this.#onDriverProcessSignal); + } + if (opts.handleSIGHUP) { + process.on('SIGHUP', this.#onDriverProcessSignal); + } + if (opts.onExit) { + this.#onExitHook = opts.onExit; + } + this.#browserProcessExiting = new Promise((resolve, reject) => { + this.#browserProcess.once('exit', async () => { + debugLaunch(`Browser process ${this.#browserProcess.pid} onExit`); + this.#clearListeners(); + this.#exited = true; + try { + await this.#runHooks(); + } catch (err) { + reject(err); + return; + } + resolve(); + }); + }); + } + + async #runHooks() { + if (this.#hooksRan) { + return; + } + this.#hooksRan = true; + await this.#onExitHook(); + } + + get nodeProcess(): childProcess.ChildProcess { + return this.#browserProcess; + } + + #configureStdio(opts: { + pipe: boolean; + dumpio: boolean; + }): Array<'ignore' | 'pipe'> { + if (opts.pipe) { + if (opts.dumpio) { + return ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; + } else { + return ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; + } + } else { + if (opts.dumpio) { + return ['pipe', 'pipe', 'pipe']; + } else { + return ['pipe', 'ignore', 'pipe']; + } + } + } + + #clearListeners(): void { + process.off('exit', this.#onDriverProcessExit); + process.off('SIGINT', this.#onDriverProcessSignal); + process.off('SIGTERM', this.#onDriverProcessSignal); + process.off('SIGHUP', this.#onDriverProcessSignal); + } + + #onDriverProcessExit = (_code: number) => { + this.kill(); + }; + + #onDriverProcessSignal = (signal: string): void => { + switch (signal) { + case 'SIGINT': + this.kill(); + process.exit(130); + case 'SIGTERM': + case 'SIGHUP': + void this.close(); + break; + } + }; + + async close(): Promise<void> { + await this.#runHooks(); + if (!this.#exited) { + this.kill(); + } + return await this.#browserProcessExiting; + } + + hasClosed(): Promise<void> { + return this.#browserProcessExiting; + } + + kill(): void { + debugLaunch(`Trying to kill ${this.#browserProcess.pid}`); + // If the process failed to launch (for example if the browser executable path + // is invalid), then the process does not get a pid assigned. A call to + // `proc.kill` would error, as the `pid` to-be-killed can not be found. + if ( + this.#browserProcess && + this.#browserProcess.pid && + pidExists(this.#browserProcess.pid) + ) { + try { + debugLaunch(`Browser process ${this.#browserProcess.pid} exists`); + if (process.platform === 'win32') { + try { + childProcess.execSync( + `taskkill /pid ${this.#browserProcess.pid} /T /F` + ); + } catch (error) { + debugLaunch( + `Killing ${this.#browserProcess.pid} using taskkill failed`, + error + ); + // taskkill can fail to kill the process e.g. due to missing permissions. + // Let's kill the process via Node API. This delays killing of all child + // processes of `this.proc` until the main Node.js process dies. + this.#browserProcess.kill(); + } + } else { + // on linux the process group can be killed with the group id prefixed with + // a minus sign. The process group id is the group leader's pid. + const processGroupId = -this.#browserProcess.pid; + + try { + process.kill(processGroupId, 'SIGKILL'); + } catch (error) { + debugLaunch( + `Killing ${this.#browserProcess.pid} using process.kill failed`, + error + ); + // Killing the process group can fail due e.g. to missing permissions. + // Let's kill the process via Node API. This delays killing of all child + // processes of `this.proc` until the main Node.js process dies. + this.#browserProcess.kill('SIGKILL'); + } + } + } catch (error) { + throw new Error( + `${PROCESS_ERROR_EXPLANATION}\nError cause: ${ + isErrorLike(error) ? error.stack : error + }` + ); + } + } + this.#clearListeners(); + } + + waitForLineOutput(regex: RegExp, timeout = 0): Promise<string> { + if (!this.#browserProcess.stderr) { + throw new Error('`browserProcess` does not have stderr.'); + } + const rl = readline.createInterface(this.#browserProcess.stderr); + let stderr = ''; + + return new Promise((resolve, reject) => { + rl.on('line', onLine); + rl.on('close', onClose); + this.#browserProcess.on('exit', onClose); + this.#browserProcess.on('error', onClose); + const timeoutId = + timeout > 0 ? setTimeout(onTimeout, timeout) : undefined; + + const cleanup = (): void => { + if (timeoutId) { + clearTimeout(timeoutId); + } + rl.off('line', onLine); + rl.off('close', onClose); + this.#browserProcess.off('exit', onClose); + this.#browserProcess.off('error', onClose); + }; + + function onClose(error?: Error): void { + cleanup(); + reject( + new Error( + [ + `Failed to launch the browser process!${ + error ? ' ' + error.message : '' + }`, + stderr, + '', + 'TROUBLESHOOTING: https://pptr.dev/troubleshooting', + '', + ].join('\n') + ) + ); + } + + function onTimeout(): void { + cleanup(); + reject( + new TimeoutError( + `Timed out after ${timeout} ms while waiting for the WS endpoint URL to appear in stdout!` + ) + ); + } + + function onLine(line: string): void { + stderr += line + '\n'; + const match = line.match(regex); + if (!match) { + return; + } + cleanup(); + // The RegExp matches, so this will obviously exist. + resolve(match[1]!); + } + }); + } +} + +const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary. +This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser. +Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed. +If you think this is a bug, please report it on the Puppeteer issue tracker.`; + +/** + * @internal + */ +function pidExists(pid: number): boolean { + try { + return process.kill(pid, 0); + } catch (error) { + if (isErrnoException(error)) { + if (error.code && error.code === 'ESRCH') { + return false; + } + } + throw error; + } +} + +/** + * @internal + */ +export interface ErrorLike extends Error { + name: string; + message: string; +} + +/** + * @internal + */ +export function isErrorLike(obj: unknown): obj is ErrorLike { + return ( + typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj + ); +} +/** + * @internal + */ +export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException { + return ( + isErrorLike(obj) && + ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj) + ); +} + +/** + * @public + */ +export class TimeoutError extends Error { + /** + * @internal + */ + constructor(message?: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/main-cli.ts b/remote/test/puppeteer/packages/browsers/src/main-cli.ts new file mode 100644 index 0000000000..9919a4dfb7 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/main-cli.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CLI} from './CLI.js'; + +void new CLI().run(process.argv); diff --git a/remote/test/puppeteer/packages/browsers/src/main.ts b/remote/test/puppeteer/packages/browsers/src/main.ts new file mode 100644 index 0000000000..df93de530d --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/main.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type { + LaunchOptions, + ComputeExecutablePathOptions as Options, + SystemOptions, +} from './launch.js'; +export { + launch, + computeExecutablePath, + computeSystemExecutablePath, + TimeoutError, + CDP_WEBSOCKET_ENDPOINT_REGEX, + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, + Process, +} from './launch.js'; +export type { + InstallOptions, + GetInstalledBrowsersOptions, + UninstallOptions, +} from './install.js'; +export { + install, + getInstalledBrowsers, + canDownload, + uninstall, +} from './install.js'; +export {detectBrowserPlatform} from './detectPlatform.js'; +export type {ProfileOptions} from './browser-data/browser-data.js'; +export { + resolveBuildId, + Browser, + BrowserPlatform, + ChromeReleaseChannel, + createProfile, +} from './browser-data/browser-data.js'; +export {CLI, makeProgressCallback} from './CLI.js'; +export {Cache, InstalledBrowser} from './Cache.js'; diff --git a/remote/test/puppeteer/packages/browsers/src/tsconfig.cjs.json b/remote/test/puppeteer/packages/browsers/src/tsconfig.cjs.json new file mode 100644 index 0000000000..acb1968862 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "../lib/cjs" + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/tsconfig.esm.json b/remote/test/puppeteer/packages/browsers/src/tsconfig.esm.json new file mode 100644 index 0000000000..a824bc8cb8 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/tsconfig.esm.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../lib/esm" + } +} diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/chrome-headless-shell-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/chrome-headless-shell-data.spec.ts new file mode 100644 index 0000000000..65008b5edb --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/chrome-headless-shell-data.spec.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import path from 'path'; + +import {BrowserPlatform} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + resolveDownloadUrl, + relativeExecutablePath, + resolveBuildId, +} from '../../../lib/cjs/browser-data/chrome-headless-shell.js'; + +describe('chrome-headless-shell', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/linux64/chrome-headless-shell-linux64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/mac-x64/chrome-headless-shell-mac-x64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/mac-arm64/chrome-headless-shell-mac-arm64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/win32/chrome-headless-shell-win32.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/win64/chrome-headless-shell-win64.zip' + ); + }); + + // TODO: once no new releases happen for the milestone, we can use the exact match. + it('should resolve milestones', async () => { + assert((await resolveBuildId('118'))?.startsWith('118.0')); + }); + + it('should resolve build prefix', async () => { + assert.strictEqual(await resolveBuildId('118.0.5950'), '118.0.5950.0'); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), + path.join('chrome-headless-shell-linux64', 'chrome-headless-shell') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '12372323'), + path.join('chrome-headless-shell-mac-x64/', 'chrome-headless-shell') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), + path.join('chrome-headless-shell-mac-arm64', 'chrome-headless-shell') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), + path.join('chrome-headless-shell-win32', 'chrome-headless-shell.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), + path.join('chrome-headless-shell-win64', 'chrome-headless-shell.exe') + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/cli.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/cli.spec.ts new file mode 100644 index 0000000000..445d0f700e --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/cli.spec.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {CLI} from '../../../lib/cjs/CLI.js'; +import { + createMockedReadlineInterface, + setupTestServer, + getServerUrl, +} from '../utils.js'; +import {testChromeHeadlessShellBuildId} from '../versions.js'; + +describe('chrome-headless-shell CLI', function () { + this.timeout(90000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(async () => { + await new CLI(tmpDir, createMockedReadlineInterface('yes')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + `--base-url=${getServerUrl()}`, + ]); + }); + + it('should download chrome-headless-shell binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `chrome-headless-shell@${testChromeHeadlessShellBuildId}`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chrome-headless-shell', + `linux-${testChromeHeadlessShellBuildId}`, + 'chrome-headless-shell-linux64', + 'chrome-headless-shell' + ) + ) + ); + + await new CLI(tmpDir, createMockedReadlineInterface('no')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chrome-headless-shell', + `linux-${testChromeHeadlessShellBuildId}`, + 'chrome-headless-shell-linux64', + 'chrome-headless-shell' + ) + ) + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/install.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/install.spec.ts new file mode 100644 index 0000000000..88f9fae7fc --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/install.spec.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + install, + canDownload, + Browser, + BrowserPlatform, + Cache, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer} from '../utils.js'; +import {testChromeDriverBuildId} from '../versions.js'; + +/** + * Tests in this spec use real download URLs and unpack live browser archives + * so it requires the network access. + */ +describe('ChromeDriver install', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + new Cache(tmpDir).clear(); + }); + + it('should check if a buildId can be downloaded', async () => { + assert.ok( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }) + ); + }); + + it('should report if a buildId is not downloadable', async () => { + assert.strictEqual( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: 'unknown', + baseUrl: getServerUrl(), + }), + false + ); + }); + + it('should download and unpack the binary', async function () { + this.timeout(60000); + const expectedOutputPath = path.join( + tmpDir, + 'chromedriver', + `${BrowserPlatform.LINUX}-${testChromeDriverBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + let browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + // Second iteration should be no-op. + browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + assert.ok(fs.existsSync(browser.executablePath)); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome/chrome-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome/chrome-data.spec.ts new file mode 100644 index 0000000000..510afa8454 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome/chrome-data.spec.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import path from 'path'; + +import { + BrowserPlatform, + ChromeReleaseChannel, +} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + resolveDownloadUrl, + relativeExecutablePath, + resolveSystemExecutablePath, + resolveBuildId, +} from '../../../lib/cjs/browser-data/chrome.js'; + +describe('Chrome', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/linux64/chrome-linux64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/mac-x64/chrome-mac-x64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/mac-arm64/chrome-mac-arm64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/win32/chrome-win32.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/win64/chrome-win64.zip' + ); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), + path.join('chrome-linux64', 'chrome') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '12372323'), + path.join( + 'chrome-mac-x64', + 'Google Chrome for Testing.app', + 'Contents', + 'MacOS', + 'Google Chrome for Testing' + ) + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), + path.join( + 'chrome-mac-arm64', + 'Google Chrome for Testing.app', + 'Contents', + 'MacOS', + 'Google Chrome for Testing' + ) + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), + path.join('chrome-win32', 'chrome.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), + path.join('chrome-win64', 'chrome.exe') + ); + }); + + it('should resolve system executable path', () => { + process.env['PROGRAMFILES'] = 'C:\\ProgramFiles'; + try { + assert.strictEqual( + resolveSystemExecutablePath( + BrowserPlatform.WIN32, + ChromeReleaseChannel.DEV + ), + 'C:\\ProgramFiles\\Google\\Chrome Dev\\Application\\chrome.exe' + ); + } finally { + delete process.env['PROGRAMFILES']; + } + + assert.strictEqual( + resolveSystemExecutablePath( + BrowserPlatform.MAC, + ChromeReleaseChannel.BETA + ), + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta' + ); + assert.throws(() => { + assert.strictEqual( + resolveSystemExecutablePath( + BrowserPlatform.LINUX, + ChromeReleaseChannel.CANARY + ), + path.join('chrome-linux', 'chrome') + ); + }, new Error(`Unable to detect browser executable path for 'canary' on linux.`)); + }); + + it('should resolve milestones', async () => { + assert.strictEqual(await resolveBuildId('115'), '115.0.5790.170'); + }); + + it('should resolve build prefix', async () => { + assert.strictEqual(await resolveBuildId('115.0.5790'), '115.0.5790.170'); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome/cli.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome/cli.spec.ts new file mode 100644 index 0000000000..bdda9d9aa9 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome/cli.spec.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {CLI} from '../../../lib/cjs/CLI.js'; +import { + createMockedReadlineInterface, + setupTestServer, + getServerUrl, +} from '../utils.js'; +import {testChromeBuildId} from '../versions.js'; + +describe('Chrome CLI', function () { + this.timeout(90000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(async () => { + await new CLI(tmpDir, createMockedReadlineInterface('yes')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + `--base-url=${getServerUrl()}`, + ]); + }); + + it('should download Chrome binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `chrome@${testChromeBuildId}`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chrome', + `linux-${testChromeBuildId}`, + 'chrome-linux64', + 'chrome' + ) + ) + ); + + await new CLI(tmpDir, createMockedReadlineInterface('no')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chrome', + `linux-${testChromeBuildId}`, + 'chrome-linux64', + 'chrome' + ) + ) + ); + }); + + // Skipped because the current latest is not published yet. + it.skip('should download latest Chrome binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `chrome@latest`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome/install.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome/install.spec.ts new file mode 100644 index 0000000000..8103ff3612 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome/install.spec.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import http from 'http'; +import https from 'https'; +import os from 'os'; +import path from 'path'; + +import { + install, + canDownload, + Browser, + BrowserPlatform, + Cache, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer} from '../utils.js'; +import {testChromeBuildId} from '../versions.js'; + +/** + * Tests in this spec use real download URLs and unpack live browser archives + * so it requires the network access. + */ +describe('Chrome install', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + new Cache(tmpDir).clear(); + }); + + it('should check if a buildId can be downloaded', async () => { + assert.ok( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }) + ); + }); + + it('should report if a buildId is not downloadable', async () => { + assert.strictEqual( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: 'unknown', + baseUrl: getServerUrl(), + }), + false + ); + }); + + it('should download a buildId that is a zip archive', async function () { + this.timeout(60000); + const expectedOutputPath = path.join( + tmpDir, + 'chrome', + `${BrowserPlatform.LINUX}-${testChromeBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + let browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + // Second iteration should be no-op. + browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + // Should discover installed browsers. + const cache = new Cache(tmpDir); + const installed = cache.getInstalledBrowsers(); + assert.deepStrictEqual(browser, installed[0]); + assert.deepStrictEqual( + browser!.executablePath, + installed[0]?.executablePath + ); + }); + + it('throws on invalid URL', async function () { + const expectedOutputPath = path.join( + tmpDir, + 'chrome', + `${BrowserPlatform.LINUX}-${testChromeBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + + async function installThatThrows(): Promise<unknown> { + try { + await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: 'https://127.0.0.1', + }); + return undefined; + } catch (err) { + return err; + } + } + assert.ok(await installThatThrows()); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + }); + + describe('with proxy', () => { + const proxyUrl = new URL(`http://localhost:54321`); + let proxyServer: http.Server; + let proxiedRequestUrls: string[] = []; + let proxiedRequestHosts: string[] = []; + + beforeEach(() => { + proxiedRequestUrls = []; + proxiedRequestHosts = []; + proxyServer = http + .createServer( + ( + originalRequest: http.IncomingMessage, + originalResponse: http.ServerResponse + ) => { + const url = originalRequest.url as string; + const proxyRequest = ( + url.startsWith('http:') ? http : https + ).request( + url, + { + method: originalRequest.method, + rejectUnauthorized: false, + }, + proxyResponse => { + originalResponse.writeHead( + proxyResponse.statusCode as number, + proxyResponse.headers + ); + proxyResponse.pipe(originalResponse, {end: true}); + } + ); + originalRequest.pipe(proxyRequest, {end: true}); + proxiedRequestUrls.push(url); + proxiedRequestHosts.push(originalRequest.headers?.host || ''); + } + ) + .listen({ + port: proxyUrl.port, + hostname: proxyUrl.hostname, + }); + + process.env['HTTPS_PROXY'] = proxyUrl.toString(); + process.env['HTTP_PROXY'] = proxyUrl.toString(); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + proxyServer.close(error => { + if (error) { + reject(error); + } else { + resolve(undefined); + } + }); + }); + delete process.env['HTTP_PROXY']; + delete process.env['HTTPS_PROXY']; + }); + + it('can send canDownload requests via a proxy', async () => { + assert.strictEqual( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }), + true + ); + assert.deepStrictEqual(proxiedRequestUrls, [ + getServerUrl() + '/113.0.5672.0/linux64/chrome-linux64.zip', + ]); + assert.deepStrictEqual(proxiedRequestHosts, [ + getServerUrl().replace('http://', ''), + ]); + }); + + it('can download via a proxy', async function () { + this.timeout(120000); + const expectedOutputPath = path.join( + tmpDir, + 'chrome', + `${BrowserPlatform.LINUX}-${testChromeBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + const browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + assert.deepStrictEqual(proxiedRequestUrls, [ + getServerUrl() + '/113.0.5672.0/linux64/chrome-linux64.zip', + ]); + assert.deepStrictEqual(proxiedRequestHosts, [ + getServerUrl().replace('http://', ''), + ]); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome/launch.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome/launch.spec.ts new file mode 100644 index 0000000000..c420d9e0b6 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome/launch.spec.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + CDP_WEBSOCKET_ENDPOINT_REGEX, + computeExecutablePath, + launch, + install, + Browser, + BrowserPlatform, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer, clearCache} from '../utils.js'; +import {testChromeBuildId} from '../versions.js'; + +describe('Chrome', () => { + it('should compute executable path for Chrome', () => { + assert.strictEqual( + computeExecutablePath({ + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: '123', + cacheDir: '.cache', + }), + path.join('.cache', 'chrome', 'linux-123', 'chrome-linux64', 'chrome') + ); + }); + + describe('launcher', function () { + setupTestServer(); + + this.timeout(60000); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + }); + + afterEach(() => { + clearCache(tmpDir); + }); + + function getArgs() { + return [ + '--allow-pre-commit-input', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-component-update', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,DialMediaRouteProvider', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--enable-automation', + '--enable-features=NetworkServiceInProcess2', + '--export-tagged-pdf', + '--force-color-profile=srgb', + '--headless=new', + '--metrics-recording-only', + '--no-first-run', + '--password-store=basic', + '--remote-debugging-port=9222', + '--use-mock-keychain', + `--user-data-dir=${path.join(tmpDir, 'profile')}`, + 'about:blank', + ]; + } + + it('should launch a Chrome browser', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROME, + buildId: testChromeBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + await process.close(); + }); + + it('should allow parsing stderr output of the browser process', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROME, + buildId: testChromeBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + const url = await process.waitForLineOutput(CDP_WEBSOCKET_ENDPOINT_REGEX); + await process.close(); + assert.ok(url.startsWith('ws://127.0.0.1:9222/devtools/browser')); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromedriver/chromedriver-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/chromedriver-data.spec.ts new file mode 100644 index 0000000000..62522d88f4 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/chromedriver-data.spec.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import path from 'path'; + +import {BrowserPlatform} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + resolveDownloadUrl, + relativeExecutablePath, + resolveBuildId, +} from '../../../lib/cjs/browser-data/chromedriver.js'; + +describe('ChromeDriver', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/linux64/chromedriver-linux64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/mac-x64/chromedriver-mac-x64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/mac-arm64/chromedriver-mac-arm64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/win32/chromedriver-win32.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/win64/chromedriver-win64.zip' + ); + }); + + it('should resolve milestones', async () => { + assert.strictEqual(await resolveBuildId('115'), '115.0.5790.170'); + }); + + it('should resolve build prefix', async () => { + assert.strictEqual(await resolveBuildId('115.0.5790'), '115.0.5790.170'); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), + path.join('chromedriver-linux64', 'chromedriver') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '12372323'), + path.join('chromedriver-mac-x64/', 'chromedriver') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), + path.join('chromedriver-mac-arm64', 'chromedriver') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), + path.join('chromedriver-win32', 'chromedriver.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), + path.join('chromedriver-win64', 'chromedriver.exe') + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromedriver/cli.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/cli.spec.ts new file mode 100644 index 0000000000..d407062a88 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/cli.spec.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {CLI} from '../../../lib/cjs/CLI.js'; +import { + createMockedReadlineInterface, + setupTestServer, + getServerUrl, +} from '../utils.js'; +import {testChromeDriverBuildId} from '../versions.js'; + +describe('ChromeDriver CLI', function () { + this.timeout(90000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(async () => { + await new CLI(tmpDir, createMockedReadlineInterface('yes')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + `--base-url=${getServerUrl()}`, + ]); + }); + + it('should download ChromeDriver binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `chromedriver@${testChromeDriverBuildId}`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chromedriver', + `linux-${testChromeDriverBuildId}`, + 'chromedriver-linux64', + 'chromedriver' + ) + ) + ); + + await new CLI(tmpDir, createMockedReadlineInterface('no')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chromedriver', + `linux-${testChromeDriverBuildId}`, + 'chromedriver-linux64', + 'chromedriver' + ) + ) + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromedriver/install.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/install.spec.ts new file mode 100644 index 0000000000..88f9fae7fc --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/install.spec.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + install, + canDownload, + Browser, + BrowserPlatform, + Cache, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer} from '../utils.js'; +import {testChromeDriverBuildId} from '../versions.js'; + +/** + * Tests in this spec use real download URLs and unpack live browser archives + * so it requires the network access. + */ +describe('ChromeDriver install', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + new Cache(tmpDir).clear(); + }); + + it('should check if a buildId can be downloaded', async () => { + assert.ok( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }) + ); + }); + + it('should report if a buildId is not downloadable', async () => { + assert.strictEqual( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: 'unknown', + baseUrl: getServerUrl(), + }), + false + ); + }); + + it('should download and unpack the binary', async function () { + this.timeout(60000); + const expectedOutputPath = path.join( + tmpDir, + 'chromedriver', + `${BrowserPlatform.LINUX}-${testChromeDriverBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + let browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + // Second iteration should be no-op. + browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + assert.ok(fs.existsSync(browser.executablePath)); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromium/chromium-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromium/chromium-data.spec.ts new file mode 100644 index 0000000000..601efccc47 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromium/chromium-data.spec.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import path from 'path'; + +import {BrowserPlatform} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + resolveDownloadUrl, + relativeExecutablePath, +} from '../../../lib/cjs/browser-data/chromium.js'; + +describe('Chromium', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Linux_x64/1083080/chrome-linux.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Mac/1083080/chrome-mac.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Mac_Arm/1083080/chrome-mac.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Win/1083080/chrome-win.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Win_x64/1083080/chrome-win.zip' + ); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), + path.join('chrome-linux', 'chrome') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '12372323'), + path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), + path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), + path.join('chrome-win', 'chrome.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), + path.join('chrome-win', 'chrome.exe') + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromium/launch.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromium/launch.spec.ts new file mode 100644 index 0000000000..8cf7c8255b --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromium/launch.spec.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + CDP_WEBSOCKET_ENDPOINT_REGEX, + computeExecutablePath, + launch, + install, + Browser, + BrowserPlatform, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer, clearCache} from '../utils.js'; +import {testChromiumBuildId} from '../versions.js'; + +describe('Chromium', () => { + it('should compute executable path for Chromium', () => { + assert.strictEqual( + computeExecutablePath({ + browser: Browser.CHROMIUM, + platform: BrowserPlatform.LINUX, + buildId: '123', + cacheDir: '.cache', + }), + path.join('.cache', 'chromium', 'linux-123', 'chrome-linux', 'chrome') + ); + }); + + describe('launcher', function () { + setupTestServer(); + + this.timeout(120000); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + await install({ + cacheDir: tmpDir, + browser: Browser.CHROMIUM, + buildId: testChromiumBuildId, + baseUrl: getServerUrl(), + }); + }); + + afterEach(() => { + clearCache(tmpDir); + }); + + function getArgs() { + return [ + '--allow-pre-commit-input', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-component-update', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,DialMediaRouteProvider', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--enable-automation', + '--enable-features=NetworkServiceInProcess2', + '--export-tagged-pdf', + '--force-color-profile=srgb', + '--headless=new', + '--metrics-recording-only', + '--no-first-run', + '--password-store=basic', + '--remote-debugging-port=9222', + '--use-mock-keychain', + `--user-data-dir=${path.join(tmpDir, 'profile')}`, + 'about:blank', + ]; + } + + it('should launch a Chromium browser', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROMIUM, + buildId: testChromiumBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + await process.close(); + }); + + it('should allow parsing stderr output of the browser process', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROMIUM, + buildId: testChromiumBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + const url = await process.waitForLineOutput(CDP_WEBSOCKET_ENDPOINT_REGEX); + await process.close(); + assert.ok(url.startsWith('ws://127.0.0.1:9222/devtools/browser')); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/firefox/cli.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/firefox/cli.spec.ts new file mode 100644 index 0000000000..134b432641 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/firefox/cli.spec.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import sinon from 'sinon'; + +import {CLI} from '../../../lib/cjs/CLI.js'; +import * as httpUtil from '../../../lib/cjs/httpUtil.js'; +import { + createMockedReadlineInterface, + getServerUrl, + setupTestServer, +} from '../utils.js'; +import {testFirefoxBuildId} from '../versions.js'; + +describe('Firefox CLI', function () { + this.timeout(90000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(async () => { + await new CLI(tmpDir, createMockedReadlineInterface('yes')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + `--base-url=${getServerUrl()}`, + ]); + + sinon.restore(); + }); + + it('should download Firefox binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `firefox@${testFirefoxBuildId}`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + assert.ok( + fs.existsSync( + path.join(tmpDir, 'firefox', `linux-${testFirefoxBuildId}`, 'firefox') + ) + ); + }); + + it('should download latest Firefox binaries', async () => { + sinon + .stub(httpUtil, 'getJSON') + .returns(Promise.resolve({FIREFOX_NIGHTLY: testFirefoxBuildId})); + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `firefox@latest`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `firefox`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/firefox/firefox-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/firefox/firefox-data.spec.ts new file mode 100644 index 0000000000..d0bb056090 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/firefox/firefox-data.spec.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {BrowserPlatform} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + createProfile, + relativeExecutablePath, + resolveDownloadUrl, +} from '../../../lib/cjs/browser-data/firefox.js'; + +describe('Firefox', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.linux-x86_64.tar.bz2' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.mac.dmg' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.mac.dmg' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.win32.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.win64.zip' + ); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '111.0a1'), + path.join('firefox', 'firefox') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '111.0a1'), + path.join('Firefox Nightly.app', 'Contents', 'MacOS', 'firefox') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '111.0a1'), + path.join('Firefox Nightly.app', 'Contents', 'MacOS', 'firefox') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '111.0a1'), + path.join('firefox', 'firefox.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '111.0a1'), + path.join('firefox', 'firefox.exe') + ); + }); + + describe('profile', () => { + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { + force: true, + recursive: true, + maxRetries: 5, + }); + }); + + it('should create a profile', async () => { + await createProfile({ + preferences: { + test: 1, + }, + path: tmpDir, + }); + const text = fs.readFileSync(path.join(tmpDir, 'user.js'), 'utf-8'); + assert.ok( + text.includes(`user_pref("toolkit.startup.max_resumed_crashes", -1);`) + ); // default preference. + assert.ok(text.includes(`user_pref("test", 1);`)); // custom preference. + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/firefox/install.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/firefox/install.spec.ts new file mode 100644 index 0000000000..1bada43729 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/firefox/install.spec.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {install, Browser, BrowserPlatform} from '../../../lib/cjs/main.js'; +import {setupTestServer, getServerUrl, clearCache} from '../utils.js'; +import {testFirefoxBuildId} from '../versions.js'; + +/** + * Tests in this spec use real download URLs and unpack live browser archives + * so it requires the network access. + */ +describe('Firefox install', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + clearCache(tmpDir); + }); + + it('should download a buildId that is a bzip2 archive', async function () { + this.timeout(90000); + const expectedOutputPath = path.join( + tmpDir, + 'firefox', + `${BrowserPlatform.LINUX}-${testFirefoxBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + const browser = await install({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + platform: BrowserPlatform.LINUX, + buildId: testFirefoxBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + }); + + // install relies on the `hdiutil` utility on MacOS. + // The utility is not available on other platforms. + (os.platform() === 'darwin' ? it : it.skip)( + 'should download a buildId that is a dmg archive', + async function () { + this.timeout(180000); + const expectedOutputPath = path.join( + tmpDir, + 'firefox', + `${BrowserPlatform.MAC}-${testFirefoxBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + const browser = await install({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + platform: BrowserPlatform.MAC, + buildId: testFirefoxBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + } + ); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/firefox/launch.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/firefox/launch.spec.ts new file mode 100644 index 0000000000..3c62c87448 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/firefox/launch.spec.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + computeExecutablePath, + launch, + install, + Browser, + BrowserPlatform, + createProfile, +} from '../../../lib/cjs/main.js'; +import {setupTestServer, getServerUrl, clearCache} from '../utils.js'; +import {testFirefoxBuildId} from '../versions.js'; + +describe('Firefox', () => { + it('should compute executable path for Firefox', () => { + assert.strictEqual( + computeExecutablePath({ + browser: Browser.FIREFOX, + platform: BrowserPlatform.LINUX, + buildId: '123', + cacheDir: '.cache', + }), + path.join('.cache', 'firefox', 'linux-123', 'firefox', 'firefox') + ); + }); + + describe('launcher', function () { + this.timeout(120000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + await install({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + buildId: testFirefoxBuildId, + baseUrl: getServerUrl(), + }); + }); + + afterEach(() => { + clearCache(tmpDir); + }); + + it('should launch a Firefox browser', async () => { + const userDataDir = path.join(tmpDir, 'profile'); + function getArgs(): string[] { + const firefoxArguments = ['--no-remote']; + switch (os.platform()) { + case 'darwin': + firefoxArguments.push('--foreground'); + break; + case 'win32': + firefoxArguments.push('--wait-for-browser'); + break; + } + firefoxArguments.push('--profile', userDataDir); + firefoxArguments.push('--headless'); + firefoxArguments.push('about:blank'); + return firefoxArguments; + } + await createProfile(Browser.FIREFOX, { + path: userDataDir, + preferences: {}, + }); + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + buildId: testFirefoxBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + await process.close(); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/mocha-utils.ts b/remote/test/puppeteer/packages/browsers/test/src/mocha-utils.ts new file mode 100644 index 0000000000..245a0048b2 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/mocha-utils.ts @@ -0,0 +1,8 @@ +import debug from 'debug'; + +export const mochaHooks = { + async beforeAll(): Promise<void> { + // Enable logging for Debug + debug.enable('puppeteer:*'); + }, +}; diff --git a/remote/test/puppeteer/packages/browsers/test/src/tsconfig.json b/remote/test/puppeteer/packages/browsers/test/src/tsconfig.json new file mode 100644 index 0000000000..03eae4a458 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "../build", + }, + "references": [{"path": "../../tsconfig.json"}], +} diff --git a/remote/test/puppeteer/packages/browsers/test/src/tsdoc.json b/remote/test/puppeteer/packages/browsers/test/src/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/browsers/test/src/uninstall.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/uninstall.spec.ts new file mode 100644 index 0000000000..0ef8a20fde --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/uninstall.spec.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + install, + uninstall, + Browser, + BrowserPlatform, + Cache, +} from '../../lib/cjs/main.js'; + +import {getServerUrl, setupTestServer} from './utils.js'; +import {testChromeBuildId} from './versions.js'; + +describe('common', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + new Cache(tmpDir).clear(); + }); + + it('should uninstall a browser', async function () { + this.timeout(60000); + const expectedOutputPath = path.join( + tmpDir, + 'chrome', + `${BrowserPlatform.LINUX}-${testChromeBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + const browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + + await uninstall({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/utils.ts b/remote/test/puppeteer/packages/browsers/test/src/utils.ts new file mode 100644 index 0000000000..bae231423e --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/utils.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {execSync} from 'child_process'; +import os from 'os'; +import path from 'path'; +import * as readline from 'readline'; +import {Writable, Readable} from 'stream'; + +import {TestServer} from '@pptr/testserver'; + +import {isErrorLike} from '../../lib/cjs/launch.js'; +import {Cache} from '../../lib/cjs/main.js'; + +export function createMockedReadlineInterface( + input: string +): readline.Interface { + const readable = Readable.from([input]); + const writable = new Writable({ + write(_chunk, _encoding, callback) { + // Suppress the output to keep the test clean + callback(); + }, + }); + + return readline.createInterface({ + input: readable, + output: writable, + }); +} + +const startServer = async () => { + const assetsPath = path.join(__dirname, '..', '.cache', 'server'); + return await TestServer.create(assetsPath); +}; + +interface ServerState { + server: TestServer; +} + +const state: Partial<ServerState> = {}; + +export function setupTestServer(): void { + before(async () => { + state.server = await startServer(); + }); + + after(async () => { + await state.server!.stop(); + state.server = undefined; + }); +} + +export function getServerUrl(): string { + return `http://localhost:${state.server!.port}`; +} + +export function clearCache(tmpDir: string): void { + try { + new Cache(tmpDir).clear(); + } catch (err) { + if (os.platform() === 'win32') { + console.log(execSync('tasklist').toString('utf-8')); + // Sometimes on Windows the folder cannot be removed due to unknown reasons. + // We suppress the error to avoud flakiness. + if (isErrorLike(err) && err.message.includes('EBUSY')) { + return; + } + } + throw err; + } +} diff --git a/remote/test/puppeteer/packages/browsers/test/src/versions.ts b/remote/test/puppeteer/packages/browsers/test/src/versions.ts new file mode 100644 index 0000000000..3e13b8fc61 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/versions.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const testChromeBuildId = '113.0.5672.0'; +export const testChromiumBuildId = '1083080'; +export const testFirefoxBuildId = '123.0a1'; +export const testChromeDriverBuildId = '115.0.5763.0'; +export const testChromeHeadlessShellBuildId = '118.0.5950.0'; diff --git a/remote/test/puppeteer/packages/browsers/tools/downloadTestBrowsers.mjs b/remote/test/puppeteer/packages/browsers/tools/downloadTestBrowsers.mjs new file mode 100644 index 0000000000..e9c4ec963a --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/tools/downloadTestBrowsers.mjs @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Downloads test browser binaries to test/.cache/server folder that + * mirrors the structure of the download server. + */ + +import {existsSync, mkdirSync, copyFileSync, rmSync} from 'fs'; +import {normalize, join, dirname} from 'path'; + +import {downloadPaths} from '../lib/esm/browser-data/browser-data.js'; +import * as versions from '../test/build/versions.js'; + +import {BrowserPlatform, install} from '@puppeteer/browsers'; + +function getBrowser(str) { + const regex = /test(.+)BuildId/; + const match = str.match(regex); + + if (match && match[1]) { + const lowercased = match[1].toLowerCase(); + if (lowercased === 'chromeheadlessshell') { + return 'chrome-headless-shell'; + } + return lowercased; + } else { + return null; + } +} + +const cacheDir = normalize(join('.', 'test', '.cache')); + +for (const version of Object.keys(versions)) { + const browser = getBrowser(version); + if (!browser) { + continue; + } + + const buildId = versions[version]; + + for (const platform of Object.values(BrowserPlatform)) { + const targetPath = join( + cacheDir, + 'server', + ...downloadPaths[browser](platform, buildId) + ); + + if (existsSync(targetPath)) { + continue; + } + + const archivePath = await install({ + browser, + buildId, + platform, + cacheDir: join(cacheDir, 'tmp'), + unpack: false, + }); + + mkdirSync(dirname(targetPath), { + recursive: true, + }); + copyFileSync(archivePath, targetPath); + } +} + +rmSync(join(cacheDir, 'tmp'), { + recursive: true, + force: true, + maxRetries: 10, +}); diff --git a/remote/test/puppeteer/packages/browsers/tools/updateVersions.mjs b/remote/test/puppeteer/packages/browsers/tools/updateVersions.mjs new file mode 100644 index 0000000000..9fb704baf5 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/tools/updateVersions.mjs @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; + +import actions from '@actions/core'; + +import {testFirefoxBuildId} from '../test/build/versions.js'; + +const filePath = './test/src/versions.ts'; + +const getVersion = async () => { + // https://stackoverflow.com/a/1732454/96656 + const response = await fetch( + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/' + ); + const html = await response.text(); + const re = /firefox-(.*)\.en-US\.langpack\.xpi">/; + const match = re.exec(html)[1]; + return match; +}; + +const patch = (input, version) => { + const output = input.replace(/testFirefoxBuildId = '([^']+)';/, match => { + return `testFirefoxBuildId = '${version}';`; + }); + return output; +}; + +const version = await getVersion(); + +if (testFirefoxBuildId !== version) { + actions.setOutput( + 'commit', + `chore: update Firefox testing pin to ${version}` + ); + const contents = await fs.readFile(filePath, 'utf8'); + const patched = patch(contents, version); + fs.writeFile(filePath, patched); +} diff --git a/remote/test/puppeteer/packages/browsers/tsconfig.json b/remote/test/puppeteer/packages/browsers/tsconfig.json new file mode 100644 index 0000000000..b662532a01 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [ + {"path": "src/tsconfig.esm.json"}, + {"path": "src/tsconfig.cjs.json"}, + ], +} diff --git a/remote/test/puppeteer/packages/browsers/tsdoc.json b/remote/test/puppeteer/packages/browsers/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/.eslintignore b/remote/test/puppeteer/packages/ng-schematics/.eslintignore new file mode 100644 index 0000000000..8424d7004d --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.eslintignore @@ -0,0 +1,5 @@ +# Ignore File that will be copied to Angular +/files/ + +# Ignore sandbox enviroment +./sandbox/ diff --git a/remote/test/puppeteer/packages/ng-schematics/.gitignore b/remote/test/puppeteer/packages/ng-schematics/.gitignore new file mode 100644 index 0000000000..9dad45cdd5 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.gitignore @@ -0,0 +1,3 @@ + +# Sandbox +sandbox/ diff --git a/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs b/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs new file mode 100644 index 0000000000..be9bc29919 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs @@ -0,0 +1,6 @@ +module.exports = { + logLevel: 'debug', + spec: 'test/build/**/*.spec.js', + exit: !!process.env.CI, + reporter: process.env.CI ? 'spec' : 'dot', +}; diff --git a/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md b/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md new file mode 100644 index 0000000000..a483c4f2fb --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md @@ -0,0 +1,110 @@ +# Changelog + +## [0.5.6](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.5...ng-schematics-v0.5.6) (2024-01-16) + + +### Bug Fixes + +* jest config issue on Windows ([3711f86](https://github.com/puppeteer/puppeteer/commit/3711f86dca4140da9e830bd7a46f4eca43cd5f4b)) + +## [0.5.5](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.4...ng-schematics-v0.5.5) (2023-12-19) + + +### Bug Fixes + +* update documentation for ng-schematics ([#11533](https://github.com/puppeteer/puppeteer/issues/11533)) ([744e894](https://github.com/puppeteer/puppeteer/commit/744e8944ac62b9d7284fa260c5c796fa1b83b5ef)) + +## [0.5.4](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.3...ng-schematics-v0.5.4) (2023-12-06) + + +### Bug Fixes + +* get port from created server ([#11495](https://github.com/puppeteer/puppeteer/issues/11495)) ([d2f4b9c](https://github.com/puppeteer/puppeteer/commit/d2f4b9ca53642ac9ccae9a22fd3138698990387b)) + +## [0.5.3](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.2...ng-schematics-v0.5.3) (2023-12-04) + + +### Bug Fixes + +* ng-schematics install Windows ([#11487](https://github.com/puppeteer/puppeteer/issues/11487)) ([02af748](https://github.com/puppeteer/puppeteer/commit/02af7482d9bf2163b90dfe623b0af18c513d5a3b)) + +## [0.5.2](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.1...ng-schematics-v0.5.2) (2023-11-16) + + +### Bug Fixes + +* run post-install hooks ([#11403](https://github.com/puppeteer/puppeteer/issues/11403)) ([3f6ca24](https://github.com/puppeteer/puppeteer/commit/3f6ca249ed898eee25015a6fd0ce7cf774ad31b2)) + +## [0.5.1](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.0...ng-schematics-v0.5.1) (2023-11-13) + + +### Bug Fixes + +* multi-app project extend root `tsconfig.json` ([#11374](https://github.com/puppeteer/puppeteer/issues/11374)) ([1b2d920](https://github.com/puppeteer/puppeteer/commit/1b2d920fe638f3aad704ab8f21d1e4f4099b6d44)) +* support Angular 17 new template ([#11375](https://github.com/puppeteer/puppeteer/issues/11375)) ([64f7bf0](https://github.com/puppeteer/puppeteer/commit/64f7bf0af442369a07352b11555ec3f612eb62b8)) + +## [0.5.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.4.0...ng-schematics-v0.5.0) (2023-08-22) + + +### Features + +* **ng-schematics:** reduce the user options and better defaults ([35dc2d8](https://github.com/puppeteer/puppeteer/commit/35dc2d884052b27a3f9c70b8646f95743be7b84d)) +* **ng-schematics:** release version 0.5.0 ([#10768](https://github.com/puppeteer/puppeteer/issues/10768)) ([42fdd0a](https://github.com/puppeteer/puppeteer/commit/42fdd0a733acb2a9af3878bfa8927252f68ed465)) + + +### Bug Fixes + +* **ng-schematics:** builder is responsible for resolving commands ([683e181](https://github.com/puppeteer/puppeteer/commit/683e18189c0aedad7deb9007055a1a38801bbf08)) +* **ng-schematics:** don't install for library projects ([1376b77](https://github.com/puppeteer/puppeteer/commit/1376b77a7ab2260c2fd236c3cf31abbd544193e8)) + +## [0.4.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.4.0) (2023-08-08) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.3.0) (2023-08-03) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.3.0) (2023-08-02) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.2.0...ng-schematics-v0.3.0) (2023-06-29) + + +### Features + +* add Test command ([#10443](https://github.com/puppeteer/puppeteer/issues/10443)) ([2d8993b](https://github.com/puppeteer/puppeteer/commit/2d8993b45b0a0c5943907fe69f865e1064a23d3c)) + + +### Bug Fixes + +* `port` option to run dev and e2e side-by-side ([#10458](https://github.com/puppeteer/puppeteer/issues/10458)) ([a43b346](https://github.com/puppeteer/puppeteer/commit/a43b346bfc7f0071fcead1abb7d7b46dcf3c27f9)) +* use Node test reporter ([#10464](https://github.com/puppeteer/puppeteer/issues/10464)) ([f778b1e](https://github.com/puppeteer/puppeteer/commit/f778b1e2a70f3d507ab2012d2918f5ed241a8d21)) + +## [0.2.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.1.0...ng-schematics-v0.2.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) + +### Features + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) ([7405d65](https://github.com/puppeteer/puppeteer/commit/7405d6585aa09b240fbab09aa360674d4442b3d9)) + +## 0.1.0 (2022-11-23) + + +### Features + +* **ng-schematics:** Release @puppeteer/ng-schematics ([#9244](https://github.com/puppeteer/puppeteer/issues/9244)) ([be33929](https://github.com/puppeteer/puppeteer/commit/be33929770e473992ad49029e6d038d36591e108)) diff --git a/remote/test/puppeteer/packages/ng-schematics/README.md b/remote/test/puppeteer/packages/ng-schematics/README.md new file mode 100644 index 0000000000..975f74a704 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/README.md @@ -0,0 +1,230 @@ +# Puppeteer Angular Schematic + +Adds Puppeteer-based e2e tests to your Angular project. + +## Getting started + +Run the command below in an Angular CLI app directory and follow the prompts. + +> Note this will add the schematic as a dependency to your project. + +```bash +ng add @puppeteer/ng-schematics +``` + +Or you can use the same command followed by the [options](#options) below. + +Currently, this schematic supports the following test runners: + +- [**Jasmine**](https://jasmine.github.io/) +- [**Jest**](https://jestjs.io/) +- [**Mocha**](https://mochajs.org/) +- [**Node Test Runner**](https://nodejs.org/api/test.html) + +With the schematics installed you can run E2E tests: + +```bash +ng e2e +``` + +### Options + +When adding schematics to your project you can to provide following options: + +| Option | Description | Value | Required | +| --------------- | ------------------------------------------------------ | ------------------------------------------ | -------- | +| `--test-runner` | The testing framework to install along side Puppeteer. | `"jasmine"`, `"jest"`, `"mocha"`, `"node"` | `true` | + +## Creating a single test file + +Puppeteer Angular Schematic exposes a method to create a single test file. + +```bash +ng generate @puppeteer/ng-schematics:e2e "<TestName>" +``` + +### Running test server and dev server at the same time + +By default the E2E test will run the app on the same port as `ng start`. +To avoid this you can specify the port the an the `angular.json` +Update either `e2e` or `puppeteer` (depending on the initial setup) to: + +```json +{ + "e2e": { + "builder": "@puppeteer/ng-schematics:puppeteer", + "options": { + "commands": [...], + "devServerTarget": "sandbox:serve", + "testRunner": "<TestRunner>", + "port": 8080 + }, + ... +} +``` + +Now update the E2E test file `utils.ts` baseUrl to: + +```ts +const baseUrl = 'http://localhost:8080'; +``` + +## Contributing + +Check out our [contributing guide](https://pptr.dev/contributing) to get an overview of what you need to develop in the Puppeteer repo. + +### Sandbox smoke tests + +To make integration easier smoke test can be run with a single command, that will create a fresh install of Angular (single application and a milti application projects). Then it will install the schematics inside them and run the initial e2e tests: + +```bash +node tools/smoke.mjs +``` + +### Unit Testing + +The schematics utilize `@angular-devkit/schematics/testing` for verifying correct file creation and `package.json` updates. To execute the test suit: + +```bash +npm run test +``` + +## Migrating from Protractor + +### Entry point + +Puppeteer has its own [`browser`](https://pptr.dev/api/puppeteer.browser) that exposes the browser process. +A more closes comparison for Protractor's `browser` would be Puppeteer's [`page`](https://pptr.dev/api/puppeteer.page). + +```ts +// Testing framework specific imports + +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('<Test Name>', function () { + setupBrowserHooks(); + it('is running', async function () { + const {page} = getBrowserState(); + // Query elements + await page + .locator('my-component') + // Click on the element once found + .click(); + }); +}); +``` + +### Getting element properties + +You can easily get any property of the element. + +```ts +// Testing framework specific imports + +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('<Test Name>', function () { + setupBrowserHooks(); + it('is running', async function () { + const {page} = getBrowserState(); + // Query elements + const elementText = await page + .locator('.my-component') + .map(button => button.innerText) + // Wait for element to show up + .wait(); + + // Assert via assertion library + }); +}); +``` + +### Query Selectors + +Puppeteer supports multiple types of selectors, namely, the CSS, ARIA, text, XPath and pierce selectors. +The following table shows Puppeteer's equivalents to [Protractor By](https://www.protractortest.org/#/api?view=ProtractorBy). + +> For improved reliability and reduced flakiness try our +> **Experimental** [Locators API](https://pptr.dev/guides/locators) + +| By | Protractor code | Puppeteer querySelector | +| ----------------- | --------------------------------------------- | ------------------------------------------------------------ | +| CSS (Single) | `$(by.css('<CSS>'))` | `page.$('<CSS>')` | +| CSS (Multiple) | `$$(by.css('<CSS>'))` | `page.$$('<CSS>')` | +| Id | `$(by.id('<ID>'))` | `page.$('#<ID>')` | +| CssContainingText | `$(by.cssContainingText('<CSS>', '<TEXT>'))` | `page.$('<CSS> ::-p-text(<TEXT>)')` ` | +| DeepCss | `$(by.deepCss('<CSS>'))` | `page.$(':scope >>> <CSS>')` | +| XPath | `$(by.xpath('<XPATH>'))` | `page.$('::-p-xpath(<XPATH>)')` | +| JS | `$(by.js('document.querySelector("<CSS>")'))` | `page.evaluateHandle(() => document.querySelector('<CSS>'))` | + +> For advanced use cases such as Protractor's `by.addLocator` you can check Puppeteer's [Custom selectors](https://pptr.dev/guides/query-selectors#custom-selectors). + +### Actions Selectors + +Puppeteer allows you to all necessary actions to allow test your application. + +```ts +// Click on the element. +element(locator).click(); +// Puppeteer equivalent +await page.locator(locator).click(); + +// Send keys to the element (usually an input). +element(locator).sendKeys('my text'); +// Puppeteer equivalent +await page.locator(locator).fill('my text'); + +// Clear the text in an element (usually an input). +element(locator).clear(); +// Puppeteer equivalent +await page.locator(locator).fill(''); + +// Get the value of an attribute, for example, get the value of an input. +element(locator).getAttribute('value'); +// Puppeteer equivalent +const element = await page.locator(locator).waitHandle(); +const value = await element.getProperty('value'); +``` + +### Example + +Sample Protractor test: + +```ts +describe('Protractor Demo', function () { + it('should add one and two', function () { + browser.get('http://juliemr.github.io/protractor-demo/'); + element(by.model('first')).sendKeys(1); + element(by.model('second')).sendKeys(2); + + element(by.id('gobutton')).click(); + + expect(element(by.binding('latest')).getText()).toEqual('3'); + }); +}); +``` + +Sample Puppeteer migration: + +```ts +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('Puppeteer Demo', function () { + setupBrowserHooks(); + it('should add one and two', function () { + const {page} = getBrowserState(); + await page.goto('http://juliemr.github.io/protractor-demo/'); + + await page.locator('.form-inline > input:nth-child(1)').fill('1'); + await page.locator('.form-inline > input:nth-child(2)').fill('2'); + await page.locator('#gobutton').fill('2'); + + const result = await page + .locator('.table tbody td:last-of-type') + .map(header => header.innerText) + .wait(); + + expect(result).toEqual('3'); + }); +}); +``` diff --git a/remote/test/puppeteer/packages/ng-schematics/package.json b/remote/test/puppeteer/packages/ng-schematics/package.json new file mode 100644 index 0000000000..29db1dcdc9 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/package.json @@ -0,0 +1,71 @@ +{ + "name": "@puppeteer/ng-schematics", + "version": "0.5.6", + "description": "Puppeteer Angular schematics", + "scripts": { + "build": "wireit", + "clean": "../../tools/clean.js", + "dev:test": "npm run test --watch", + "dev": "npm run build --watch", + "unit": "wireit" + }, + "wireit": { + "build": { + "command": "tsc -b && node tools/copySchemaFiles.mjs", + "clean": "if-file-deleted", + "files": [ + "tsconfig.json", + "tsconfig.test.json", + "src/**", + "test/src/**" + ], + "output": [ + "lib/**", + "test/build/**", + "*.tsbuildinfo" + ] + }, + "build:test": { + "command": "tsc -b test/tsconfig.json" + }, + "unit": { + "command": "node --test --test-reporter spec test/build", + "dependencies": [ + "build", + "build:test" + ] + } + }, + "keywords": [ + "angular", + "puppeteer", + "schematics" + ], + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/ng-schematics" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=16.13.2" + }, + "dependencies": { + "@angular-devkit/architect": "^0.1701.1", + "@angular-devkit/core": "^17.0.7", + "@angular-devkit/schematics": "^17.0.7" + }, + "devDependencies": { + "@schematics/angular": "^17.0.7", + "@angular/cli": "^17.0.7" + }, + "files": [ + "lib", + "!*.tsbuildinfo" + ], + "ng-add": { + "save": "devDependencies" + }, + "schematics": "./lib/schematics/collection.json", + "builders": "./lib/builders/builders.json" +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json b/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json new file mode 100644 index 0000000000..41079f7731 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/architect/src/builders-schema.json", + "builders": { + "puppeteer": { + "implementation": "./puppeteer", + "schema": "./puppeteer/schema.json", + "description": "Run e2e test with Puppeteer" + } + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts new file mode 100644 index 0000000000..82a1e8e7da --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts @@ -0,0 +1,200 @@ +import {spawn} from 'child_process'; +import {normalize, join} from 'path'; + +import { + createBuilder, + type BuilderContext, + type BuilderOutput, + targetFromTargetString, + type BuilderRun, +} from '@angular-devkit/architect'; +import type {JsonObject} from '@angular-devkit/core'; + +import {TestRunner} from '../../schematics/utils/types.js'; + +import type {PuppeteerBuilderOptions} from './types.js'; + +const terminalStyles = { + cyan: '\u001b[36;1m', + green: '\u001b[32m', + red: '\u001b[31m', + bold: '\u001b[1m', + reverse: '\u001b[7m', + clear: '\u001b[0m', +}; + +export function getCommandForRunner(runner: TestRunner): [string, ...string[]] { + switch (runner) { + case TestRunner.Jasmine: + return [`jasmine`, '--config=./e2e/jasmine.json']; + case TestRunner.Jest: + return [`jest`, '-c', 'e2e/jest.config.js']; + case TestRunner.Mocha: + return [`mocha`, '--config=./e2e/.mocharc.js']; + case TestRunner.Node: + return ['node', '--test', '--test-reporter', 'spec', 'e2e/build/']; + } + + throw new Error(`Unknown test runner ${runner}!`); +} + +function getExecutable(command: string[]) { + const executable = command.shift()!; + const debugError = `Error running '${executable}' with arguments '${command.join( + ' ' + )}'.`; + + return { + executable, + args: command, + debugError, + error: 'Please look at the output above to determine the issue!', + }; +} + +function updateExecutablePath(command: string, root?: string) { + if (command === TestRunner.Node) { + return command; + } + + let path = 'node_modules/.bin/'; + if (root && root !== '') { + const nested = root + .split('/') + .map(() => { + return '../'; + }) + .join(''); + path = `${nested}${path}${command}`; + } else { + path = `./${path}${command}`; + } + + return normalize(path); +} + +async function executeCommand( + context: BuilderContext, + command: string[], + env: NodeJS.ProcessEnv = {} +) { + let project: JsonObject; + if (context.target) { + project = await context.getProjectMetadata(context.target.project); + command[0] = updateExecutablePath(command[0]!, String(project['root'])); + } + + await new Promise(async (resolve, reject) => { + context.logger.debug(`Trying to execute command - ${command.join(' ')}.`); + const {executable, args, debugError, error} = getExecutable(command); + let path = context.workspaceRoot; + if (context.target) { + path = join(path, (project['root'] as string | undefined) ?? ''); + } + + const child = spawn(executable, args, { + cwd: path, + stdio: 'inherit', + shell: true, + env: { + ...process.env, + ...env, + }, + }); + + child.on('error', message => { + context.logger.debug(debugError); + console.log(message); + reject(error); + }); + + child.on('exit', code => { + if (code === 0) { + resolve(true); + } else { + reject(error); + } + }); + }); +} + +function message( + message: string, + context: BuilderContext, + type: 'info' | 'success' | 'error' = 'info' +): void { + let style: string; + switch (type) { + case 'info': + style = terminalStyles.reverse + terminalStyles.cyan; + break; + case 'success': + style = terminalStyles.reverse + terminalStyles.green; + break; + case 'error': + style = terminalStyles.red; + break; + } + context.logger.info( + `${terminalStyles.bold}${style}${message}${terminalStyles.clear}` + ); +} + +async function startServer( + options: PuppeteerBuilderOptions, + context: BuilderContext +): Promise<BuilderRun> { + context.logger.debug('Trying to start server.'); + const target = targetFromTargetString(options.devServerTarget); + const defaultServerOptions = await context.getTargetOptions(target); + + const overrides = { + watch: false, + host: defaultServerOptions['host'], + port: options.port ?? defaultServerOptions['port'], + } as JsonObject; + + message(' Spawning test server ⚙️ ... \n', context); + const server = await context.scheduleTarget(target, overrides); + const result = await server.result; + if (!result.success) { + throw new Error('Failed to spawn server! Stopping tests...'); + } + + return server; +} + +async function executeE2ETest( + options: PuppeteerBuilderOptions, + context: BuilderContext +): Promise<BuilderOutput> { + let server: BuilderRun | null = null; + try { + message('\n Building tests 🛠️ ... \n', context); + await executeCommand(context, [`tsc`, '-p', 'e2e/tsconfig.json']); + + server = await startServer(options, context); + const result = await server.result; + + message('\n Running tests 🧪 ... \n', context); + const testRunnerCommand = getCommandForRunner(options.testRunner); + await executeCommand(context, testRunnerCommand, { + baseUrl: result['baseUrl'], + }); + + message('\n 🚀 Test ran successfully! 🚀 ', context, 'success'); + return {success: true}; + } catch (error) { + message('\n 🛑 Test failed! 🛑 ', context, 'error'); + if (error instanceof Error) { + return {success: false, error: error.message}; + } + return {success: false, error: error as string}; + } finally { + if (server) { + await server.stop(); + } + } +} + +export default createBuilder<PuppeteerBuilderOptions>(executeE2ETest); diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json new file mode 100644 index 0000000000..2693d19cce --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json @@ -0,0 +1,26 @@ +{ + "title": "Puppeteer", + "description": "Options for Puppeteer Angular Schematics", + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "array", + "item": { + "type": "string" + } + }, + "description": "Commands to execute in the repo. Commands prefixed with `./node_modules/bin` (Exception: 'node')." + }, + "devServerTarget": { + "type": "string", + "description": "Angular target that spawns the server." + }, + "port": { + "type": ["number", "null"], + "description": "Port to run the test server on." + } + }, + "additionalProperties": true +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts new file mode 100644 index 0000000000..6258a955c0 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JsonObject} from '@angular-devkit/core'; + +import type {TestRunner} from '../../schematics/utils/types.js'; + +export interface PuppeteerBuilderOptions extends JsonObject { + testRunner: TestRunner; + devServerTarget: string; + port: number | null; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json new file mode 100644 index 0000000000..00bede45e5 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json @@ -0,0 +1,20 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Add Puppeteer to an Angular project", + "factory": "./ng-add/index#ngAdd", + "schema": "./ng-add/schema.json" + }, + "e2e": { + "description": "Create a single test file", + "factory": "./e2e/index#e2e", + "schema": "./e2e/schema.json" + }, + "config": { + "description": "Eject Puppeteer config file", + "factory": "./config/index#config", + "schema": "./config/schema.json" + } + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs new file mode 100644 index 0000000000..0da14a80d8 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs @@ -0,0 +1,4 @@ +/** + * @type {import("puppeteer").Configuration} + */ +export {}; diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts new file mode 100644 index 0000000000..b01d98e33e --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + chain, + type Rule, + type SchematicContext, + type Tree, +} from '@angular-devkit/schematics'; + +import {addFilesSingle} from '../utils/files.js'; +import {TestRunner, type AngularProject} from '../utils/types.js'; + +// You don't have to export the function as default. You can also have more than one rule +// factory per file. +export function config(): Rule { + return (tree: Tree, context: SchematicContext) => { + return chain([addPuppeteerConfig()])(tree, context); + }; +} + +function addPuppeteerConfig(): Rule { + return (_tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding Puppeteer config file.'); + + return addFilesSingle('', {root: ''} as AngularProject, { + // No-op here to fill types + options: { + testRunner: TestRunner.Jasmine, + port: 4200, + }, + applyPath: './files', + relativeToWorkspacePath: `/`, + }); + }; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json new file mode 100644 index 0000000000..8d45751bb1 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "Puppeteer", + "title": "Puppeteer Config Schema", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template new file mode 100644 index 0000000000..ca90f258b8 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template @@ -0,0 +1,18 @@ +<% if(testRunner == 'node') { %> +import * as assert from 'assert'; +import {describe, it} from 'node:test'; +<% } %><% if(testRunner == 'mocha') { %> +import * as assert from 'assert'; +<% } %> +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('<%= classify(name) %>', function () { + <% if(route) { %> + setupBrowserHooks('<%= route %>'); + <% } else { %> + setupBrowserHooks(); + <% } %> + it('', async function () { + const {page} = getBrowserState(); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts new file mode 100644 index 0000000000..cf1f634f94 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + chain, + type Rule, + type SchematicContext, + SchematicsException, + type Tree, +} from '@angular-devkit/schematics'; + +import {addCommonFiles} from '../utils/files.js'; +import {getApplicationProjects} from '../utils/json.js'; +import { + TestRunner, + type SchematicsSpec, + type AngularProject, + type PuppeteerSchematicsConfig, +} from '../utils/types.js'; + +// You don't have to export the function as default. You can also have more than one rule +// factory per file. +export function e2e(userArgs: Record<string, string>): Rule { + const options = parseUserTestArgs(userArgs); + + return (tree: Tree, context: SchematicContext) => { + return chain([addE2EFile(options)])(tree, context); + }; +} + +function parseUserTestArgs(userArgs: Record<string, string>): SchematicsSpec { + const options: Partial<SchematicsSpec> = { + ...userArgs, + }; + if ('p' in userArgs) { + options['project'] = userArgs['p']; + } + if ('n' in userArgs) { + options['name'] = userArgs['n']; + } + if ('r' in userArgs) { + options['route'] = userArgs['r']; + } + + if (options['route'] && options['route'].startsWith('/')) { + options['route'] = options['route'].substring(1); + } + + return options as SchematicsSpec; +} + +function findTestingOption< + Property extends keyof PuppeteerSchematicsConfig['options'], +>( + [name, project]: [string, AngularProject | undefined], + property: Property +): PuppeteerSchematicsConfig['options'][Property] { + if (!project) { + throw new Error(`Project "${name}" not found.`); + } + + const e2e = project.architect?.e2e; + const puppeteer = project.architect?.puppeteer; + const builder = '@puppeteer/ng-schematics:puppeteer'; + + if (e2e?.builder === builder) { + return e2e.options[property]; + } else if (puppeteer?.builder === builder) { + return puppeteer.options[property]; + } + + throw new Error(`Can't find property "${property}" for project "${name}".`); +} + +function addE2EFile(options: SchematicsSpec): Rule { + return async (tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding Spec file.'); + + const projects = getApplicationProjects(tree); + const projectNames = Object.keys(projects) as [string, ...string[]]; + const foundProject: [string, AngularProject | undefined] | undefined = + projectNames.length === 1 + ? [projectNames[0], projects[projectNames[0]]] + : Object.entries(projects).find(([name, project]) => { + return options.project + ? options.project === name + : project.root === ''; + }); + if (!foundProject) { + throw new SchematicsException( + `Project not found! Please run "ng generate @puppeteer/ng-schematics:test <Test> <Project>"` + ); + } + + const testRunner = findTestingOption(foundProject, 'testRunner'); + const port = findTestingOption(foundProject, 'port'); + + context.logger.debug('Creating Spec file.'); + + return addCommonFiles( + {[foundProject[0]]: foundProject[1]} as Record<string, AngularProject>, + { + options: { + name: options.name, + route: options.route, + testRunner, + // Node test runner does not support glob patterns + // It looks for files `*.test.js` + ext: testRunner === TestRunner.Node ? 'test' : 'e2e', + port, + }, + } + ); + }; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json new file mode 100644 index 0000000000..7752c9ceef --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "Puppeteer", + "title": "Puppeteer E2E Schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "alias": "n", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "Name for spec to be created:" + }, + "project": { + "type": "string", + "$default": { + "$source": "argv", + "index": 1 + }, + "alias": "p" + }, + "route": { + "type": "string", + "$default": { + "$source": "argv", + "index": 1 + }, + "alias": "r" + } + }, + "required": [] +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template new file mode 100644 index 0000000000..f038b2eb67 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template @@ -0,0 +1,2 @@ +# Compiled e2e tests output +build/ diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template new file mode 100644 index 0000000000..60637d0fa7 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template @@ -0,0 +1,20 @@ +<% if(testRunner == 'node') { %> +import * as assert from 'assert'; +import {describe, it} from 'node:test'; +<% } %><% if(testRunner == 'mocha') { %> +import * as assert from 'assert'; +<% } %> +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('App test', function () { + setupBrowserHooks(); + it('is running', async function () { + const {page} = getBrowserState(); + const element = await page.locator('::-p-text(<%= project %>)').wait(); +<% if(testRunner == 'jasmine' || testRunner == 'jest') { %> + expect(element).not.toBeNull(); +<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %> + assert.ok(element); +<% } %> + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template new file mode 100644 index 0000000000..2136f99a3a --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template @@ -0,0 +1,60 @@ +<% if(testRunner == 'node') { %> +import {before, beforeEach, after, afterEach} from 'node:test'; +<% } %> +import * as puppeteer from 'puppeteer'; + +const baseUrl = process.env['baseUrl'] ?? '<%= baseUrl %>'; +let browser: puppeteer.Browser; +let page: puppeteer.Page; + +export function setupBrowserHooks(path = ''): void { +<% if(testRunner == 'jasmine' || testRunner == 'jest') { %> + beforeAll(async () => { + browser = await puppeteer.launch({ + headless: 'new' + }); + }); +<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %> + before(async () => { + browser = await puppeteer.launch({ + headless: 'new' + }); + }); +<% } %> + + beforeEach(async () => { + page = await browser.newPage(); + await page.goto(`${baseUrl}${path}`); + }); + + afterEach(async () => { + await page?.close(); + }); + +<% if(testRunner == 'jasmine' || testRunner == 'jest') { %> + afterAll(async () => { + await browser?.close(); + }); +<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %> + after(async () => { + await browser?.close(); + }); +<% } %> +} + +export function getBrowserState(): { + browser: puppeteer.Browser; + page: puppeteer.Page; + baseUrl: string; +} { + if (!browser) { + throw new Error( + 'No browser state found! Ensure `setupBrowserHooks()` is called.' + ); + } + return { + browser, + page, + baseUrl, + }; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template new file mode 100644 index 0000000000..38501b89ef --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template @@ -0,0 +1,10 @@ +{ + "extends": "<%= tsConfigPath %>", + "compilerOptions": { + "module": "CommonJS", + "rootDir": "tests/", + "outDir": "build/", + "types": ["<%= testRunner %>"] + }, + "include": ["tests/**/*.ts"] +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json new file mode 100644 index 0000000000..ad5dc6fbce --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json @@ -0,0 +1,10 @@ +{ + "spec_dir": "e2e", + "spec_files": ["**/*[eE]2[eE].js"], + "helpers": ["helpers/**/*.?(m)js"], + "env": { + "failSpecWithNoExpectations": true, + "stopSpecOnExpectationFailure": false, + "random": true + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js new file mode 100644 index 0000000000..ee21c6737e --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js @@ -0,0 +1,10 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +/** @type {import('jest').Config} */ +module.exports = { + testMatch: ['<rootDir>/build/**/*.e2e.js'], + testEnvironment: 'node', +}; diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js new file mode 100644 index 0000000000..28c1839674 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js @@ -0,0 +1,4 @@ +module.exports = { + spec: './e2e/build/**/*.e2e.js', + timeout: 5000, +}; diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts new file mode 100644 index 0000000000..1f962e0cfc --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + chain, + type Rule, + type SchematicContext, + type Tree, +} from '@angular-devkit/schematics'; +import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks'; +import {of} from 'rxjs'; +import {concatMap, map, scan} from 'rxjs/operators'; + +import { + addCommonFiles as addCommonFilesHelper, + addFrameworkFiles, + getNgCommandName, + hasE2ETester, +} from '../utils/files.js'; +import {getApplicationProjects} from '../utils/json.js'; +import { + addPackageJsonDependencies, + addPackageJsonScripts, + getDependenciesFromOptions, + getPackageLatestNpmVersion, + DependencyType, + type NodePackage, + updateAngularJsonScripts, +} from '../utils/packages.js'; +import {TestRunner, type SchematicsOptions} from '../utils/types.js'; + +const DEFAULT_PORT = 4200; + +// You don't have to export the function as default. You can also have more than one rule +// factory per file. +export function ngAdd(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + return chain([ + addDependencies(options), + addCommonFiles(options), + addOtherFiles(options), + updateScripts(), + updateAngularConfig(options), + ])(tree, context); + }; +} + +function addDependencies(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding dependencies to "package.json"'); + const dependencies = getDependenciesFromOptions(options); + + return of(...dependencies).pipe( + concatMap((packageName: string) => { + return getPackageLatestNpmVersion(packageName); + }), + scan((array, nodePackage) => { + array.push(nodePackage); + return array; + }, [] as NodePackage[]), + map(packages => { + context.logger.debug('Updating dependencies...'); + addPackageJsonDependencies(tree, packages, DependencyType.Dev); + context.addTask( + new NodePackageInstallTask({ + // Trigger Post-Install hooks to download the browser + allowScripts: true, + }) + ); + + return tree; + }) + ); + }; +} + +function updateScripts(): Rule { + return (tree: Tree, context: SchematicContext): Tree => { + context.logger.debug('Updating "package.json" scripts'); + const projects = getApplicationProjects(tree); + const projectsKeys = Object.keys(projects); + + if (projectsKeys.length === 1) { + const name = getNgCommandName(projects); + const prefix = hasE2ETester(projects) ? `run ${projectsKeys[0]}:` : ''; + return addPackageJsonScripts(tree, [ + { + name, + script: `ng ${prefix}${name}`, + }, + ]); + } + return tree; + }; +} + +function addCommonFiles(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding Puppeteer base files.'); + const projects = getApplicationProjects(tree); + + return addCommonFilesHelper(projects, { + options: { + ...options, + port: DEFAULT_PORT, + ext: options.testRunner === TestRunner.Node ? 'test' : 'e2e', + }, + }); + }; +} + +function addOtherFiles(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding Puppeteer additional files.'); + const projects = getApplicationProjects(tree); + + return addFrameworkFiles(projects, { + options: { + ...options, + port: DEFAULT_PORT, + }, + }); + }; +} + +function updateAngularConfig(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext): Tree => { + context.logger.debug('Updating "angular.json".'); + + return updateAngularJsonScripts(tree, options); + }; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json new file mode 100644 index 0000000000..0fa581f1a7 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "Puppeteer", + "title": "Puppeteer Install Schema", + "type": "object", + "properties": { + "testRunner": { + "type": "string", + "enum": ["jasmine", "jest", "mocha", "node"], + "default": "jasmine", + "alias": "t", + "x-prompt": { + "message": "Which test runners do you wish to use?", + "type": "list", + "items": [ + { + "value": "jasmine", + "label": "Use Jasmine [https://jasmine.github.io/]" + }, + { + "value": "jest", + "label": "Use Jest [https://jestjs.io/]" + }, + { + "value": "mocha", + "label": "Use Mocha [https://mochajs.org/]" + }, + { + "value": "node", + "label": "Use Node Test Runner [https://nodejs.org/api/test.html]" + } + ] + } + } + }, + "required": [] +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts new file mode 100644 index 0000000000..4d255062b4 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {relative, resolve} from 'path'; + +import {getSystemPath, normalize, strings} from '@angular-devkit/core'; +import type {Rule} from '@angular-devkit/schematics'; +import { + apply, + applyTemplates, + chain, + mergeWith, + move, + url, +} from '@angular-devkit/schematics'; + +import type {AngularProject, TestRunner} from './types.js'; + +export interface FilesOptions { + options: { + testRunner: TestRunner; + port: number; + name?: string; + exportConfig?: boolean; + ext?: string; + route?: string; + }; + applyPath: string; + relativeToWorkspacePath: string; + movePath?: string; +} + +export function addFilesToProjects( + projects: Record<string, AngularProject>, + options: FilesOptions +): Rule { + return chain( + Object.keys(projects).map(name => { + return addFilesSingle(name, projects[name] as AngularProject, options); + }) + ); +} + +export function addFilesSingle( + name: string, + project: AngularProject, + {options, applyPath, movePath, relativeToWorkspacePath}: FilesOptions +): Rule { + const projectPath = resolve(getSystemPath(normalize(project.root))); + const workspacePath = resolve(getSystemPath(normalize(''))); + + const relativeToWorkspace = relative( + `${projectPath}${relativeToWorkspacePath}`, + workspacePath + ); + + const baseUrl = getProjectBaseUrl(project, options.port); + const tsConfigPath = getTsConfigPath(project); + + return mergeWith( + apply(url(applyPath), [ + move(movePath ? `${project.root}${movePath}` : project.root), + applyTemplates({ + ...options, + ...strings, + root: project.root ? `${project.root}/` : project.root, + baseUrl, + tsConfigPath, + project: name, + relativeToWorkspace, + }), + ]) + ); +} + +function getProjectBaseUrl(project: AngularProject, port: number): string { + let options = {protocol: 'http', port, host: 'localhost'}; + + if (project.architect?.serve?.options) { + const projectOptions = project.architect?.serve?.options; + const projectPort = port !== 4200 ? port : projectOptions?.port ?? port; + options = {...options, ...projectOptions, port: projectPort}; + options.protocol = projectOptions.ssl ? 'https' : 'http'; + } + + return `${options.protocol}://${options.host}:${options.port}/`; +} + +function getTsConfigPath(project: AngularProject): string { + const filename = 'tsconfig.json'; + + if (!project.root) { + return `../${filename}`; + } + + const nested = project.root + .split('/') + .map(() => { + return '../'; + }) + .join(''); + + // Prepend a single `../` as we put the test inside `e2e` folder + return `../${nested}${filename}`; +} + +export function addCommonFiles( + projects: Record<string, AngularProject>, + filesOptions: Omit<FilesOptions, 'applyPath' | 'relativeToWorkspacePath'> +): Rule { + const options: FilesOptions = { + ...filesOptions, + applyPath: './files/common', + relativeToWorkspacePath: `/`, + }; + + return addFilesToProjects(projects, options); +} + +export function addFrameworkFiles( + projects: Record<string, AngularProject>, + filesOptions: Omit<FilesOptions, 'applyPath' | 'relativeToWorkspacePath'> +): Rule { + const testRunner = filesOptions.options.testRunner; + const options: FilesOptions = { + ...filesOptions, + applyPath: `./files/${testRunner}`, + relativeToWorkspacePath: `/`, + }; + + return addFilesToProjects(projects, options); +} + +export function hasE2ETester( + projects: Record<string, AngularProject> +): boolean { + return Object.values(projects).some((project: AngularProject) => { + return Boolean(project.architect?.e2e); + }); +} + +export function getNgCommandName( + projects: Record<string, AngularProject> +): string { + if (!hasE2ETester(projects)) { + return 'e2e'; + } + return 'puppeteer'; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts new file mode 100644 index 0000000000..1a38d638a7 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {SchematicsException, type Tree} from '@angular-devkit/schematics'; + +import type {AngularJson, AngularProject} from './types.js'; + +export function getJsonFileAsObject( + tree: Tree, + path: string +): Record<string, unknown> { + try { + const buffer = tree.read(path) as Buffer; + const content = buffer.toString(); + return JSON.parse(content); + } catch { + throw new SchematicsException(`Unable to retrieve file at ${path}.`); + } +} + +export function getObjectAsJson(object: Record<string, unknown>): string { + return JSON.stringify(object, null, 2); +} + +export function getAngularConfig(tree: Tree): AngularJson { + return getJsonFileAsObject(tree, './angular.json') as unknown as AngularJson; +} + +export function getApplicationProjects( + tree: Tree +): Record<string, AngularProject> { + const {projects} = getAngularConfig(tree); + + const applications: Record<string, AngularProject> = {}; + for (const key in projects) { + const project = projects[key]!; + if (project.projectType === 'application') { + applications[key] = project; + } + } + return applications; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts new file mode 100644 index 0000000000..6ef8ef6002 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {get} from 'https'; + +import type {Tree} from '@angular-devkit/schematics'; + +import {getNgCommandName} from './files.js'; +import { + getAngularConfig, + getApplicationProjects, + getJsonFileAsObject, + getObjectAsJson, +} from './json.js'; +import {type SchematicsOptions, TestRunner} from './types.js'; +export interface NodePackage { + name: string; + version: string; +} +export interface NodeScripts { + name: string; + script: string; +} + +export enum DependencyType { + Default = 'dependencies', + Dev = 'devDependencies', + Peer = 'peerDependencies', + Optional = 'optionalDependencies', +} + +export function getPackageLatestNpmVersion(name: string): Promise<NodePackage> { + return new Promise(resolve => { + let version = 'latest'; + + return get(`https://registry.npmjs.org/${name}`, res => { + let data = ''; + + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + try { + const response = JSON.parse(data); + version = response?.['dist-tags']?.latest ?? version; + } catch { + } finally { + resolve({ + name, + version, + }); + } + }); + }).on('error', () => { + resolve({ + name, + version, + }); + }); + }); +} + +function updateJsonValues( + json: Record<string, any>, + target: string, + updates: Array<{name: string; value: any}>, + overwrite = false +) { + updates.forEach(({name, value}) => { + if (!json[target][name] || overwrite) { + json[target] = { + ...json[target], + [name]: value, + }; + } + }); +} + +export function addPackageJsonDependencies( + tree: Tree, + packages: NodePackage[], + type: DependencyType, + overwrite?: boolean, + fileLocation = './package.json' +): Tree { + const packageJson = getJsonFileAsObject(tree, fileLocation); + + updateJsonValues( + packageJson, + type, + packages.map(({name, version}) => { + return {name, value: version}; + }), + overwrite + ); + + tree.overwrite(fileLocation, getObjectAsJson(packageJson)); + + return tree; +} + +export function getDependenciesFromOptions( + options: SchematicsOptions +): string[] { + const dependencies = ['puppeteer']; + + switch (options.testRunner) { + case TestRunner.Jasmine: + dependencies.push('jasmine'); + break; + case TestRunner.Jest: + dependencies.push('jest', '@types/jest'); + break; + case TestRunner.Mocha: + dependencies.push('mocha', '@types/mocha'); + break; + case TestRunner.Node: + dependencies.push('@types/node'); + break; + } + + return dependencies; +} + +export function addPackageJsonScripts( + tree: Tree, + scripts: NodeScripts[], + overwrite?: boolean, + fileLocation = './package.json' +): Tree { + const packageJson = getJsonFileAsObject(tree, fileLocation); + + updateJsonValues( + packageJson, + 'scripts', + scripts.map(({name, script}) => { + return {name, value: script}; + }), + overwrite + ); + + tree.overwrite(fileLocation, getObjectAsJson(packageJson)); + + return tree; +} + +export function updateAngularJsonScripts( + tree: Tree, + options: SchematicsOptions, + overwrite = true +): Tree { + const angularJson = getAngularConfig(tree); + const projects = getApplicationProjects(tree); + const name = getNgCommandName(projects); + + Object.keys(projects).forEach(project => { + const e2eScript = [ + { + name, + value: { + builder: '@puppeteer/ng-schematics:puppeteer', + options: { + devServerTarget: `${project}:serve`, + testRunner: options.testRunner, + }, + configurations: { + production: { + devServerTarget: `${project}:serve:production`, + }, + }, + }, + }, + ]; + + updateJsonValues( + angularJson['projects'][project]!, + 'architect', + e2eScript, + overwrite + ); + }); + + tree.overwrite('./angular.json', getObjectAsJson(angularJson as any)); + + return tree; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts new file mode 100644 index 0000000000..7d66e0f0fa --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum TestRunner { + Jasmine = 'jasmine', + Jest = 'jest', + Mocha = 'mocha', + Node = 'node', +} + +export interface SchematicsOptions { + testRunner: TestRunner; +} + +export interface PuppeteerSchematicsConfig { + builder: string; + options: { + port: number; + testRunner: TestRunner; + }; +} +export interface AngularProject { + projectType: 'application' | 'library'; + root: string; + architect: { + e2e?: PuppeteerSchematicsConfig; + puppeteer?: PuppeteerSchematicsConfig; + serve: { + options: { + ssl: string; + port: number; + }; + }; + }; +} +export interface AngularJson { + projects: Record<string, AngularProject>; +} + +export interface SchematicsSpec { + name: string; + project?: string; + route?: string; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts new file mode 100644 index 0000000000..e4ec03ed54 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts @@ -0,0 +1,30 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + buildTestingTree, + getMultiApplicationFile, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: config', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('config', 'single'); + expect(tree.files).toContain('/.puppeteerrc.mjs'); + }); + }); + + void describe('Multi projects', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('config', 'multi'); + expect(tree.files).toContain('/.puppeteerrc.mjs'); + expect(tree.files).not.toContain( + getMultiApplicationFile('.puppeteerrc.mjs') + ); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts new file mode 100644 index 0000000000..8ae211cd59 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts @@ -0,0 +1,111 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + buildTestingTree, + getMultiApplicationFile, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: e2e', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.files).not.toContain('/e2e/tests/my-test.test.ts'); + }); + + void it('should create Node file', async () => { + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + testRunner: 'node', + }); + expect(tree.files).not.toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/my-test.test.ts'); + }); + + void it('should create file with route', async () => { + const route = 'home'; + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + route, + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.readContent('/e2e/tests/my-test.e2e.ts')).toContain( + `setupBrowserHooks('${route}');` + ); + }); + + void it('should create with route with starting slash', async () => { + const route = '/home'; + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + route, + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.readContent('/e2e/tests/my-test.e2e.ts')).toContain( + `setupBrowserHooks('home');` + ); + }); + }); + + void describe('Multi projects', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/my-test.test.ts') + ); + }); + + void it('should create Node file', async () => { + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + testRunner: 'node', + }); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.test.ts') + ); + }); + + void it('should create file with route', async () => { + const route = 'home'; + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + route, + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect( + tree.readContent(getMultiApplicationFile('e2e/tests/my-test.e2e.ts')) + ).toContain(`setupBrowserHooks('${route}');`); + }); + + void it('should create with route with starting slash', async () => { + const route = '/home'; + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + route, + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect( + tree.readContent(getMultiApplicationFile('e2e/tests/my-test.e2e.ts')) + ).toContain(`setupBrowserHooks('home');`); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts new file mode 100644 index 0000000000..d912c5dc3d --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts @@ -0,0 +1,260 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + MULTI_LIBRARY_OPTIONS, + buildTestingTree, + getAngularJsonScripts, + getMultiApplicationFile, + getMultiLibraryFile, + getPackageJson, + runSchematic, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: ng-add', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add'); + const {devDependencies, scripts} = getPackageJson(tree); + const {builder, configurations} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/tsconfig.json'); + expect(tree.files).toContain('/e2e/tests/app.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/utils.ts'); + expect(devDependencies).toContain('puppeteer'); + expect(scripts['e2e']).toBe('ng e2e'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + expect(configurations).toEqual({ + production: { + devServerTarget: 'sandbox:serve:production', + }, + }); + }); + void it('should update create proper "ng" command for non default tester', async () => { + let tree = await buildTestingTree('ng-add', 'single'); + // Re-run schematic to have e2e populated + tree = await runSchematic(tree, 'ng-add'); + const {scripts} = getPackageJson(tree); + const {builder} = getAngularJsonScripts(tree, false); + + expect(scripts['puppeteer']).toBe('ng run sandbox:puppeteer'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + }); + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'single'); + + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + void it('should create Jasmine files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'jasmine', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/jasmine.json'); + expect(devDependencies).toContain('jasmine'); + expect(options['testRunner']).toBe('jasmine'); + }); + void it('should create Jest files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'jest', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/jest.config.js'); + expect(devDependencies).toContain('jest'); + expect(devDependencies).toContain('@types/jest'); + expect(options['testRunner']).toBe('jest'); + }); + void it('should create Mocha files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'mocha', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/.mocharc.js'); + expect(devDependencies).toContain('mocha'); + expect(devDependencies).toContain('@types/mocha'); + expect(options['testRunner']).toBe('mocha'); + }); + void it('should create Node files', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'node', + }); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/.gitignore'); + expect(tree.files).not.toContain('/e2e/tests/app.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/app.test.ts'); + expect(options['testRunner']).toBe('node'); + }); + void it('should create TypeScript files', async () => { + const tree = await buildTestingTree('ng-add', 'single'); + const tsConfigPath = '/e2e/tsconfig.json'; + const tsConfig = tree.readJson(tsConfigPath); + + expect(tree.files).toContain(tsConfigPath); + expect(tsConfig).toMatchObject({ + extends: '../tsconfig.json', + compilerOptions: { + module: 'CommonJS', + }, + }); + }); + void it('should not create port value', async () => { + const tree = await buildTestingTree('ng-add'); + + const {options} = getAngularJsonScripts(tree); + expect(options['port']).toBeUndefined(); + }); + }); + + void describe('Multi projects Application', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const {devDependencies, scripts} = getPackageJson(tree); + const {builder, configurations} = getAngularJsonScripts(tree); + + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tsconfig.json') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/utils.ts') + ); + expect(devDependencies).toContain('puppeteer'); + expect(scripts['e2e']).toBe('ng e2e'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + expect(configurations).toEqual({ + production: { + devServerTarget: 'sandbox:serve:production', + }, + }); + }); + void it('should update create proper "ng" command for non default tester', async () => { + let tree = await buildTestingTree('ng-add', 'multi'); + // Re-run schematic to have e2e populated + tree = await runSchematic(tree, 'ng-add'); + const {scripts} = getPackageJson(tree); + const {builder} = getAngularJsonScripts(tree, false); + + expect(scripts['puppeteer']).toBe('ng run sandbox:puppeteer'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + }); + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'multi'); + + expect(files).not.toContain(getMultiApplicationFile('.puppeteerrc.cjs')); + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + void it('should create Jasmine files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'jasmine', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/jasmine.json')); + expect(devDependencies).toContain('jasmine'); + expect(options['testRunner']).toBe('jasmine'); + }); + void it('should create Jest files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'jest', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain( + getMultiApplicationFile('e2e/jest.config.js') + ); + expect(devDependencies).toContain('jest'); + expect(devDependencies).toContain('@types/jest'); + expect(options['testRunner']).toBe('jest'); + }); + void it('should create Mocha files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'mocha', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/.mocharc.js')); + expect(devDependencies).toContain('mocha'); + expect(devDependencies).toContain('@types/mocha'); + expect(options['testRunner']).toBe('mocha'); + }); + void it('should create Node files', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'node', + }); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/.gitignore')); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/app.test.ts') + ); + expect(options['testRunner']).toBe('node'); + }); + void it('should create TypeScript files', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const tsConfigPath = getMultiApplicationFile('e2e/tsconfig.json'); + const tsConfig = tree.readJson(tsConfigPath); + + expect(tree.files).toContain(tsConfigPath); + expect(tsConfig).toMatchObject({ + extends: '../../../tsconfig.json', + compilerOptions: { + module: 'CommonJS', + }, + }); + }); + void it('should not create port value', async () => { + const tree = await buildTestingTree('ng-add'); + + const {options} = getAngularJsonScripts(tree); + expect(options['port']).toBeUndefined(); + }); + }); + + void describe('Multi projects Library', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const config = getAngularJsonScripts( + tree, + true, + MULTI_LIBRARY_OPTIONS.name + ); + + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tsconfig.json') + ); + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tests/utils.ts') + ); + expect(config).toBeUndefined(); + }); + + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'multi'); + + expect(files).not.toContain(getMultiLibraryFile('.puppeteerrc.cjs')); + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts new file mode 100644 index 0000000000..503cbd5cec --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts @@ -0,0 +1,147 @@ +import https from 'https'; +import {before, after} from 'node:test'; +import {join} from 'path'; + +import type {JsonObject} from '@angular-devkit/core'; +import { + SchematicTestRunner, + type UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import sinon from 'sinon'; + +const WORKSPACE_OPTIONS = { + name: 'workspace', + newProjectRoot: 'projects', + version: '14.0.0', +}; + +const SINGLE_APPLICATION_OPTIONS = { + name: 'sandbox', + directory: '.', + createApplication: true, + version: '14.0.0', +}; + +const MULTI_APPLICATION_OPTIONS = { + name: SINGLE_APPLICATION_OPTIONS.name, +}; + +export const MULTI_LIBRARY_OPTIONS = { + name: 'components', +}; + +export function setupHttpHooks(): void { + // Stop outgoing Request for version fetching + before(() => { + const httpsGetStub = sinon.stub(https, 'get'); + httpsGetStub.returns({ + on: (_: string, callback: () => void) => { + callback(); + }, + } as any); + }); + + after(() => { + sinon.restore(); + }); +} + +export function getAngularJsonScripts( + tree: UnitTestTree, + isDefault = true, + name = SINGLE_APPLICATION_OPTIONS.name +): { + builder: string; + configurations: Record<string, any>; + options: Record<string, any>; +} { + const angularJson = tree.readJson('angular.json') as any; + const e2eScript = isDefault ? 'e2e' : 'puppeteer'; + return angularJson['projects']?.[name]?.['architect'][e2eScript]; +} + +export function getPackageJson(tree: UnitTestTree): { + scripts: Record<string, string>; + devDependencies: string[]; +} { + const packageJson = tree.readJson('package.json') as JsonObject; + return { + scripts: packageJson['scripts'] as any, + devDependencies: Object.keys( + packageJson['devDependencies'] as Record<string, string> + ), + }; +} + +export function getMultiApplicationFile(file: string): string { + return `/${WORKSPACE_OPTIONS.newProjectRoot}/${MULTI_APPLICATION_OPTIONS.name}/${file}`; +} +export function getMultiLibraryFile(file: string): string { + return `/${WORKSPACE_OPTIONS.newProjectRoot}/${MULTI_LIBRARY_OPTIONS.name}/${file}`; +} + +export async function buildTestingTree( + command: 'ng-add' | 'e2e' | 'config', + type: 'single' | 'multi' = 'single', + userOptions?: Record<string, unknown> +): Promise<UnitTestTree> { + const runner = new SchematicTestRunner( + 'schematics', + join(__dirname, '../../lib/schematics/collection.json') + ); + const options = { + testRunner: 'jasmine', + ...userOptions, + }; + let workingTree: UnitTestTree; + + // Build workspace + if (type === 'single') { + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'ng-new', + SINGLE_APPLICATION_OPTIONS + ); + } else { + // Build workspace + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'workspace', + WORKSPACE_OPTIONS + ); + // Build dummy application + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'application', + MULTI_APPLICATION_OPTIONS, + workingTree + ); + // Build dummy library + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'library', + MULTI_LIBRARY_OPTIONS, + workingTree + ); + } + + if (command !== 'ng-add') { + // We want to create update the proper files with `ng-add` + // First else the angular.json will have wrong data + workingTree = await runner.runSchematic('ng-add', options, workingTree); + } + + return await runner.runSchematic(command, options, workingTree); +} + +export async function runSchematic( + tree: UnitTestTree, + command: 'ng-add' | 'test', + options?: Record<string, any> +): Promise<UnitTestTree> { + const runner = new SchematicTestRunner( + 'schematics', + join(__dirname, '../../lib/schematics/collection.json') + ); + return await runner.runSchematic(command, options, tree); +} diff --git a/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json b/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json new file mode 100644 index 0000000000..3d45f9cc54 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "src/", + "outDir": "build/", + "types": ["node"], + }, + "include": ["src/**/*"], + "references": [{"path": "../tsconfig.json"}], +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs new file mode 100644 index 0000000000..2bd88f229a --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import url from 'url'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +/** + * + * @param {String} directory + * @param {String[]} files + */ +async function findSchemaFiles(directory, files = []) { + const items = await fs.readdir(directory); + const promises = []; + // Match any listing that has no *.* format + // Ignore files folder + const regEx = /^.*\.[^\s]*$/; + + items.forEach(item => { + if (!item.match(regEx)) { + promises.push(findSchemaFiles(`${directory}/${item}`, files)); + } else if (item.endsWith('.json') || directory.includes('files')) { + files.push(`${directory}/${item}`); + } + }); + + await Promise.all(promises); + + return files; +} + +async function copySchemaFiles() { + const srcDir = path.join(__dirname, '..', 'src'); + const outputDir = path.join(__dirname, '..', 'lib'); + const files = await findSchemaFiles(srcDir); + + const moves = files.map(file => { + const to = file.replace(srcDir, outputDir); + + return {from: file, to}; + }); + + // Because fs.cp is Experimental (recursive support) + // We need to create directories first and copy the files + await Promise.all( + moves.map(({to}) => { + const dir = path.dirname(to); + return fs.mkdir(dir, {recursive: true}); + }) + ); + await Promise.all( + moves.map(({from, to}) => { + return fs.copyFile(from, to); + }) + ); +} + +copySchemaFiles(); diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs new file mode 100644 index 0000000000..985200881e --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {spawn} from 'child_process'; +import {randomUUID} from 'crypto'; +import {readFile, writeFile} from 'fs/promises'; +import {join} from 'path'; +import {cwd} from 'process'; + +class AngularProject { + static ports = new Set(); + static randomPort() { + const min = 4000; + const max = 9876; + return Math.floor(Math.random() * (max - min + 1) + min); + } + static port() { + const port = AngularProject.randomPort(); + if (AngularProject.ports.has(port)) { + return AngularProject.port(); + } + return port; + } + + static #scripts = testRunner => { + return { + // Builds the ng-schematics before running them + 'build:schematics': 'npm run --prefix ../../ build', + // Deletes all files created by Puppeteer Ng-Schematics to avoid errors + 'delete:file': + 'rm -f .puppeteerrc.cjs && rm -f tsconfig.e2e.json && rm -R -f e2e/', + // Runs the Puppeteer Ng-Schematics against the sandbox + schematics: 'schematics ../../:ng-add --dry-run=false', + 'schematics:e2e': 'schematics ../../:e2e --dry-run=false', + 'schematics:config': 'schematics ../../:config --dry-run=false', + 'schematics:smoke': `schematics ../../:ng-add --dry-run=false --test-runner="${testRunner}" && ng e2e`, + }; + }; + /** Folder name */ + #name; + /** E2E test runner to use */ + #runner; + + constructor(runner, name) { + this.#runner = runner ?? 'node'; + this.#name = name ?? randomUUID(); + } + + get runner() { + return this.#runner; + } + + get name() { + return this.#name; + } + + async executeCommand(command, options) { + const [executable, ...args] = command.split(' '); + await new Promise((resolve, reject) => { + const createProcess = spawn(executable, args, { + shell: true, + ...options, + }); + + createProcess.stdout.on('data', data => { + data = data + .toString() + // Replace new lines with a prefix including the test runner + .replace(/(?:\r\n?|\n)(?=.*[\r\n])/g, `\n${this.#runner} - `); + console.log(`${this.#runner} - ${data}`); + }); + + createProcess.on('error', message => { + console.error(`Running ${command} exited with error:`, message); + reject(message); + }); + + createProcess.on('exit', code => { + if (code === 0) { + resolve(true); + } else { + reject(); + } + }); + }); + } + + async create() { + await this.createProject(); + await this.updatePackageJson(); + } + + async updatePackageJson() { + const packageJsonFile = join(cwd(), `/sandbox/${this.#name}/package.json`); + const packageJson = JSON.parse(await readFile(packageJsonFile)); + packageJson['scripts'] = { + ...packageJson['scripts'], + ...AngularProject.#scripts(this.#runner), + }; + await writeFile(packageJsonFile, JSON.stringify(packageJson, null, 2)); + } + + get commandOptions() { + return { + ...process.env, + cwd: join(cwd(), `/sandbox/${this.#name}/`), + }; + } + + async runNpmScripts(command) { + await this.executeCommand(`npm run ${command}`, this.commandOptions); + } + + async runSchematics() { + await this.runNpmScripts('schematics'); + } + + async runSchematicsE2E() { + await this.runNpmScripts('schematics:e2e'); + } + + async runSchematicsConfig() { + await this.runNpmScripts('schematics:config'); + } + + async runSmoke() { + await this.runNpmScripts( + `schematics:smoke -- --port=${AngularProject.port()}` + ); + } +} + +export class AngularProjectSingle extends AngularProject { + async createProject() { + await this.executeCommand( + `ng new ${this.name} --directory=sandbox/${this.name} --defaults --skip-git` + ); + } +} + +export class AngularProjectMulti extends AngularProject { + async createProject() { + await this.executeCommand( + `ng new ${this.name} --create-application=false --directory=sandbox/${this.name} --defaults --skip-git` + ); + + await this.executeCommand( + `ng generate application core --style=css --routing=true`, + this.commandOptions + ); + await this.executeCommand( + `ng generate application admin --style=css --routing=false`, + this.commandOptions + ); + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs new file mode 100644 index 0000000000..8ae9907266 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ok} from 'node:assert'; +import {execSync} from 'node:child_process'; +import {parseArgs} from 'node:util'; + +import {AngularProjectMulti, AngularProjectSingle} from './projects.mjs'; + +const {values: args} = parseArgs({ + options: { + testRunner: { + type: 'string', + short: 't', + default: undefined, + }, + name: { + type: 'string', + short: 'n', + default: undefined, + }, + }, +}); + +if (process.env.CI) { + // Need to install in CI + execSync('npm install -g @angular/cli@latest @angular-devkit/schematics-cli'); + const runners = ['node', 'jest', 'jasmine', 'mocha']; + const groups = []; + + for (const runner of runners) { + groups.push([ + new AngularProjectSingle(runner), + new AngularProjectMulti(runner), + ]); + } + + const angularProjects = await Promise.allSettled( + groups.flat().map(async project => { + return await project.create(); + }) + ); + ok( + angularProjects.every(project => { + return project.status === 'fulfilled'; + }), + 'Building of 1 or more projects failed!' + ); + + for await (const runnerGroup of groups) { + const smokeResults = await Promise.allSettled( + runnerGroup.map(async project => { + return await project.runSmoke(); + }) + ); + ok( + smokeResults.every(project => { + return project.status === 'fulfilled'; + }), + `Smoke test for ${runnerGroup[0].runner} failed!` + ); + } +} else { + const single = new AngularProjectSingle(args.testRunner, args.name); + const multi = new AngularProjectMulti(args.testRunner, args.name); + + await Promise.all([single.create(), multi.create()]); + await Promise.all([single.runSmoke(), multi.runSmoke()]); +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tsconfig.json b/remote/test/puppeteer/packages/ng-schematics/tsconfig.json new file mode 100644 index 0000000000..40529c7d17 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "tsconfig", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmitOnError": true, + "rootDir": "src/", + "outDir": "lib/", + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "types": ["node"], + }, + "include": ["src/**/*"], + "exclude": ["src/**/files/**/*"], +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tsdoc.json b/remote/test/puppeteer/packages/ng-schematics/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/.gitignore b/remote/test/puppeteer/packages/puppeteer-core/.gitignore new file mode 100644 index 0000000000..42061c01a1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/.gitignore @@ -0,0 +1 @@ +README.md
\ No newline at end of file diff --git a/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md b/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md new file mode 100644 index 0000000000..341d706fb4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md @@ -0,0 +1,1926 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.0.1 to 1.1.0 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.4 to 1.4.5 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.5.1 to 1.6.0 + +## [21.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.9.0...puppeteer-core-v21.10.0) (2024-01-29) + + +### Features + +* add experimental browser.debugInfo ([#11748](https://github.com/puppeteer/puppeteer/issues/11748)) ([f88e1da](https://github.com/puppeteer/puppeteer/commit/f88e1da6385bc72e9ffde8514c28e4a0ff9e396a)) +* download chrome-headless-shell by default and use it for the old headless mode ([#11754](https://github.com/puppeteer/puppeteer/issues/11754)) ([ce894a2](https://github.com/puppeteer/puppeteer/commit/ce894a2ffce4bc44bd11f12d1f0543e003a97e02)) + + +### Bug Fixes + +* set viewport for element screenshots ([#11772](https://github.com/puppeteer/puppeteer/issues/11772)) ([9cd6673](https://github.com/puppeteer/puppeteer/commit/9cd66731d148afff9c2f873c1383fbe367cc5fb2)) + +## [21.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.8.0...puppeteer-core-v21.9.0) (2024-01-24) + + +### Features + +* roll to Chrome 121.0.6167.85 (r1233107) ([#11743](https://github.com/puppeteer/puppeteer/issues/11743)) ([0eec94c](https://github.com/puppeteer/puppeteer/commit/0eec94cf57288528ecd0a084a71311b181864f7b)) + +## [21.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.7.0...puppeteer-core-v21.8.0) (2024-01-24) + + +### Features + +* roll to Chrome 120.0.6099.109 (r1217362) ([#11733](https://github.com/puppeteer/puppeteer/issues/11733)) ([415cfac](https://github.com/puppeteer/puppeteer/commit/415cfaca202126b64ff496e4318cae64c4f14e89)) + + +### Bug Fixes + +* expose function for Firefox BiDi ([#11660](https://github.com/puppeteer/puppeteer/issues/11660)) ([cf879b8](https://github.com/puppeteer/puppeteer/commit/cf879b82f6c10302fcafe186b315fe7807107c31)) +* wait for WebDriver BiDi browser to close gracefully ([#11636](https://github.com/puppeteer/puppeteer/issues/11636)) ([cc3aeeb](https://github.com/puppeteer/puppeteer/commit/cc3aeeb6eae4663198466755f23746ef821408ae)) + + +### Reverts + +* refactor: adopt `core/UserContext` on `BidiBrowserContext` ([#11721](https://github.com/puppeteer/puppeteer/issues/11721)) ([d17a9df](https://github.com/puppeteer/puppeteer/commit/d17a9df0278be34c206701d8dfc1fb62af3637b3)) + +## [21.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.6.1...puppeteer-core-v21.7.0) (2024-01-04) + + +### Features + +* allow converting other targets to pages ([#11604](https://github.com/puppeteer/puppeteer/issues/11604)) ([66aa770](https://github.com/puppeteer/puppeteer/commit/66aa77003880a1458e14b47a3ed87856fd3a1933)) +* support fetching request POST data ([#11598](https://github.com/puppeteer/puppeteer/issues/11598)) ([80143de](https://github.com/puppeteer/puppeteer/commit/80143def9606ec5f2018dde618c00784442c5c1d)) +* support timeouts per CDP command ([#11595](https://github.com/puppeteer/puppeteer/issues/11595)) ([c660d40](https://github.com/puppeteer/puppeteer/commit/c660d4001d610854399d7ecb551c4eb56a7f840a)) + + +### Bug Fixes + +* change viewportHeight in screencast ([#11583](https://github.com/puppeteer/puppeteer/issues/11583)) ([107b833](https://github.com/puppeteer/puppeteer/commit/107b8337e5eebc5e31a57663ba1345be81fb486e)) +* disable GFX sanity window for Firefox and enable WebDriver BiDi CI jobs for Windows ([#11578](https://github.com/puppeteer/puppeteer/issues/11578)) ([e41a265](https://github.com/puppeteer/puppeteer/commit/e41a2656d9e1f3f037b298457fbd6c6e08f5a371)) +* improve reliability of exposeFunction ([#11600](https://github.com/puppeteer/puppeteer/issues/11600)) ([b0c5392](https://github.com/puppeteer/puppeteer/commit/b0c5392cb36eed2ed4ae4864587885b6059f4cfb)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.9.0 to 1.9.1 + +## [21.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.6.0...puppeteer-core-v21.6.1) (2023-12-13) + + +### Bug Fixes + +* emulate if captureBeyondViewport is false ([#11525](https://github.com/puppeteer/puppeteer/issues/11525)) ([b6d1163](https://github.com/puppeteer/puppeteer/commit/b6d1163f7f33d80fd43fa4915789d3689ea2369f)) +* ensure fission.bfcacheInParent is disabled for cdp in Firefox ([#11522](https://github.com/puppeteer/puppeteer/issues/11522)) ([b4a6524](https://github.com/puppeteer/puppeteer/commit/b4a65245b0ad01b2b634473ebb4d8bb2d7e420f7)) + +## [21.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.5.2...puppeteer-core-v21.6.0) (2023-12-05) + + +### Features + +* BiDi implementation of `Puppeteer.connect` for Firefox ([#11451](https://github.com/puppeteer/puppeteer/issues/11451)) ([be081ba](https://github.com/puppeteer/puppeteer/commit/be081ba17a9bbac70c13cafa81f1038f0ecfda70)) +* experimental WebDriver BiDi support with Firefox ([#11412](https://github.com/puppeteer/puppeteer/issues/11412)) ([8aba033](https://github.com/puppeteer/puppeteer/commit/8aba033dde1a306e37f6033d6f6ff36387e1aac3)) +* implement the Puppeteer CLI ([#11344](https://github.com/puppeteer/puppeteer/issues/11344)) ([53fb69b](https://github.com/puppeteer/puppeteer/commit/53fb69bf7f2bf06fa4fd7bb6d3cf21382386f6e7)) + + +### Bug Fixes + +* end WebDriver BiDi session on disconnect ([#11470](https://github.com/puppeteer/puppeteer/issues/11470)) ([a66d029](https://github.com/puppeteer/puppeteer/commit/a66d0296077a82179a2182281a5040fd96d3843c)) +* remove CDP-specific preferences from defaults for Firefox ([#11477](https://github.com/puppeteer/puppeteer/issues/11477)) ([f8c9469](https://github.com/puppeteer/puppeteer/commit/f8c94699c7f5b15c7bb96f299c2c8217d74230cd)) +* warn about launch Chrome using Node x64 on arm64 Macs ([#11471](https://github.com/puppeteer/puppeteer/issues/11471)) ([957a829](https://github.com/puppeteer/puppeteer/commit/957a8293bb1444fd51fd5673002a7781e8127c9d)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.8.0 to 1.9.0 + +## [21.5.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.5.1...puppeteer-core-v21.5.2) (2023-11-15) + + +### Bug Fixes + +* add --disable-field-trial-config ([#11352](https://github.com/puppeteer/puppeteer/issues/11352)) ([cbc33be](https://github.com/puppeteer/puppeteer/commit/cbc33bea40b8801b8eeb3277fc15d04900715795)) +* add --disable-infobars ([#11377](https://github.com/puppeteer/puppeteer/issues/11377)) ([0a41f8d](https://github.com/puppeteer/puppeteer/commit/0a41f8d01e85ff732fdd2e50468bc746d7bc6475)) +* mitt types should not be exported ([#11371](https://github.com/puppeteer/puppeteer/issues/11371)) ([4bf2a09](https://github.com/puppeteer/puppeteer/commit/4bf2a09a13450c530b24288d65791fd5c4d4dce7)) + +## [21.5.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.5.0...puppeteer-core-v21.5.1) (2023-11-09) + + +### Bug Fixes + +* better debugging for WaitTask ([#11330](https://github.com/puppeteer/puppeteer/issues/11330)) ([d2480b0](https://github.com/puppeteer/puppeteer/commit/d2480b022d74b7071b515408a31c6e82448e3c9e)) + +## [21.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.4.1...puppeteer-core-v21.5.0) (2023-11-02) + + +### Features + +* roll to Chrome 119.0.6045.105 (r1204232) ([#11287](https://github.com/puppeteer/puppeteer/issues/11287)) ([325fa8b](https://github.com/puppeteer/puppeteer/commit/325fa8b1b16a9dafd5bb320e49984d24044fa3d7)) + + +### Bug Fixes + +* ignore unordered frames ([#11283](https://github.com/puppeteer/puppeteer/issues/11283)) ([ce4e485](https://github.com/puppeteer/puppeteer/commit/ce4e485d1b1e9d4e223890ee0fc2475a1ad71bc3)) +* Type for ElementHandle.screenshot ([#11274](https://github.com/puppeteer/puppeteer/issues/11274)) ([22aeff1](https://github.com/puppeteer/puppeteer/commit/22aeff1eac9d22048330a16aa3c41293133911e4)) + +## [21.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.4.0...puppeteer-core-v21.4.1) (2023-10-23) + + +### Bug Fixes + +* do not pass --{enable,disable}-features twice when user-provided ([#11230](https://github.com/puppeteer/puppeteer/issues/11230)) ([edec7d5](https://github.com/puppeteer/puppeteer/commit/edec7d53f8190381ade7db145ad7e7d6dba2ee13)) +* remove circular import in IsolatedWorld ([#11228](https://github.com/puppeteer/puppeteer/issues/11228)) ([3edce3a](https://github.com/puppeteer/puppeteer/commit/3edce3aee9521654d7a285f4068a5e60bfb52245)) +* remove import cycle ([#11227](https://github.com/puppeteer/puppeteer/issues/11227)) ([525f13c](https://github.com/puppeteer/puppeteer/commit/525f13cd18b39cc951a84aa51b2d852758e6f0d2)) +* remove import cycle in connection ([#11225](https://github.com/puppeteer/puppeteer/issues/11225)) ([60f1b78](https://github.com/puppeteer/puppeteer/commit/60f1b788a6304504f504b0be9f02cb768e2803f8)) +* remove import cycle in query handlers ([#11234](https://github.com/puppeteer/puppeteer/issues/11234)) ([954c75f](https://github.com/puppeteer/puppeteer/commit/954c75f9a9879e2e68935c17d7eb777b1f9f808a)) +* remove more import cycles ([#11231](https://github.com/puppeteer/puppeteer/issues/11231)) ([b9ce89e](https://github.com/puppeteer/puppeteer/commit/b9ce89e460702ad85314685c600a4e5267f4db9b)) +* typo in screencast error message ([#11213](https://github.com/puppeteer/puppeteer/issues/11213)) ([25b90b2](https://github.com/puppeteer/puppeteer/commit/25b90b2b542c4693150b67dc0c690b99f4ccfc95)) + +## [21.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.8...puppeteer-core-v21.4.0) (2023-10-20) + + +### Features + +* added tagged (accessible) PDFs option ([#11182](https://github.com/puppeteer/puppeteer/issues/11182)) ([0316863](https://github.com/puppeteer/puppeteer/commit/031686339136873c555a19ffb871f7140a2c39d9)) +* enable tab targets ([#11099](https://github.com/puppeteer/puppeteer/issues/11099)) ([8324c16](https://github.com/puppeteer/puppeteer/commit/8324c1634883d97ed83f32a1e62acc9b5e64e0bd)) +* implement screencasting ([#11084](https://github.com/puppeteer/puppeteer/issues/11084)) ([f060d46](https://github.com/puppeteer/puppeteer/commit/f060d467c00457e6be6878e0789d0df2ac4aae50)) +* merge user-provided --{disable,enable}-features in args ([#11152](https://github.com/puppeteer/puppeteer/issues/11152)) ([2b578e4](https://github.com/puppeteer/puppeteer/commit/2b578e4a096aa94d792cc2da2da41fee061a77b8)), closes [#11072](https://github.com/puppeteer/puppeteer/issues/11072) +* roll to Chrome 118.0.5993.70 (r1192594) ([#11123](https://github.com/puppeteer/puppeteer/issues/11123)) ([91d14c8](https://github.com/puppeteer/puppeteer/commit/91d14c8c86f5be48c8e0937fd209bea643d60b45)) + + +### Bug Fixes + +* `Page.waitForDevicePrompt` crash ([#11153](https://github.com/puppeteer/puppeteer/issues/11153)) ([257be15](https://github.com/puppeteer/puppeteer/commit/257be15d83a46038a65d47977d4d847c54506517)) +* add InlineTextBox as a non-element a11y role ([#11142](https://github.com/puppeteer/puppeteer/issues/11142)) ([8aa6cb3](https://github.com/puppeteer/puppeteer/commit/8aa6cb37d2443ff7fe2a1fd5d5adafdde4e9d165)) +* disable ProcessPerSiteUpToMainFrameThreshold in Chrome ([#11139](https://github.com/puppeteer/puppeteer/issues/11139)) ([9347aae](https://github.com/puppeteer/puppeteer/commit/9347aae12e996604cea871acc9d007cbf338542e)) +* make sure discovery happens before auto-attach ([#11100](https://github.com/puppeteer/puppeteer/issues/11100)) ([9ce204e](https://github.com/puppeteer/puppeteer/commit/9ce204e27ed091bde5aa5bc9f82da41c80534bde)) +* synchronize frame tree with the events processing ([#11112](https://github.com/puppeteer/puppeteer/issues/11112)) ([d63f0cf](https://github.com/puppeteer/puppeteer/commit/d63f0cfc61e8ba2233eee8b2f3b99d8619a0acaf)) +* update TextQuerySelector cache on subtree update ([#11200](https://github.com/puppeteer/puppeteer/issues/11200)) ([4206e76](https://github.com/puppeteer/puppeteer/commit/4206e76c3e4647ea6290f16127764d1a2f337dcf)) +* xpath queries should be atomic ([#11101](https://github.com/puppeteer/puppeteer/issues/11101)) ([6098bab](https://github.com/puppeteer/puppeteer/commit/6098bab2ba68276c85a974e17c9fe3bdac8c4c58)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.7.1 to 1.8.0 + +## [21.3.8](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.7...puppeteer-core-v21.3.8) (2023-10-06) + + +### Bug Fixes + +* avoid double subscription to frame manager in Page ([#11091](https://github.com/puppeteer/puppeteer/issues/11091)) ([5887649](https://github.com/puppeteer/puppeteer/commit/5887649891ea9cf1d7b3afbcf7196620ceb20ab2)) +* update file chooser events ([#11057](https://github.com/puppeteer/puppeteer/issues/11057)) ([317f820](https://github.com/puppeteer/puppeteer/commit/317f82055b2f4dd68db136a3d52c5712425fa339)) + +## [21.3.7](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.6...puppeteer-core-v21.3.7) (2023-10-05) + + +### Bug Fixes + +* roll to Chrome 117.0.5938.149 (r1181205) ([#11077](https://github.com/puppeteer/puppeteer/issues/11077)) ([0c0e516](https://github.com/puppeteer/puppeteer/commit/0c0e516d736665a27f7773f66a0f9c362daa73aa)) + +## [21.3.6](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.5...puppeteer-core-v21.3.6) (2023-09-28) + + +### Bug Fixes + +* remove the flag disabling bfcache ([#11047](https://github.com/puppeteer/puppeteer/issues/11047)) ([b0d7375](https://github.com/puppeteer/puppeteer/commit/b0d73755193e7c60deb70df120859b5db87e7817)) + +## [21.3.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.4...puppeteer-core-v21.3.5) (2023-09-26) + + +### Bug Fixes + +* set defaults in screenshot ([#11021](https://github.com/puppeteer/puppeteer/issues/11021)) ([ace1230](https://github.com/puppeteer/puppeteer/commit/ace1230e41aad6168dc85b9bc1f7c04d9dce5527)) + +## [21.3.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.3...puppeteer-core-v21.3.4) (2023-09-22) + + +### Bug Fixes + +* avoid structuredClone for Node 16 ([#11006](https://github.com/puppeteer/puppeteer/issues/11006)) ([25eca9a](https://github.com/puppeteer/puppeteer/commit/25eca9a747c122b3096b0f2d01b3323339d57dd9)) + +## [21.3.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.2...puppeteer-core-v21.3.3) (2023-09-22) + + +### Bug Fixes + +* do not export bidi and fix import from the entrypoint ([#10998](https://github.com/puppeteer/puppeteer/issues/10998)) ([88c78de](https://github.com/puppeteer/puppeteer/commit/88c78dea41eb7690d67343298c150194fe145763)) + +## [21.3.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.1...puppeteer-core-v21.3.2) (2023-09-22) + + +### Bug Fixes + +* handle missing detach events for restored bfcache targets ([#10967](https://github.com/puppeteer/puppeteer/issues/10967)) ([7bcdfcb](https://github.com/puppeteer/puppeteer/commit/7bcdfcb7e9e75feca0a8de692926ea25ca8fbed0)) +* roll to Chrome 117.0.5938.92 (r1181205) ([#10989](https://github.com/puppeteer/puppeteer/issues/10989)) ([d048cd9](https://github.com/puppeteer/puppeteer/commit/d048cd965f0707dd9b2a3276f02c563b69f6fac4)) + +## [21.3.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.0...puppeteer-core-v21.3.1) (2023-09-19) + + +### Bug Fixes + +* make `CDPSessionEvent.SessionAttached` public ([#10941](https://github.com/puppeteer/puppeteer/issues/10941)) ([cfed7b9](https://github.com/puppeteer/puppeteer/commit/cfed7b93ec23e92ec11632f1cd90f00dac754739)) + +## [21.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.2.1...puppeteer-core-v21.3.0) (2023-09-19) + + +### Features + +* implement `Browser.connected` ([#10927](https://github.com/puppeteer/puppeteer/issues/10927)) ([a4345a4](https://github.com/puppeteer/puppeteer/commit/a4345a477f58541f5d95da11ffee74abe24c12bf)) +* implement `BrowserContext.closed` ([#10928](https://github.com/puppeteer/puppeteer/issues/10928)) ([2292078](https://github.com/puppeteer/puppeteer/commit/2292078969fa46a27d5759989cd44a4d48beb310)) +* implement improved Drag n' Drop APIs ([#10651](https://github.com/puppeteer/puppeteer/issues/10651)) ([9342bac](https://github.com/puppeteer/puppeteer/commit/9342bac2639702090f39fc1e3a97d43a934f3f0b)) +* implement typed events ([#10889](https://github.com/puppeteer/puppeteer/issues/10889)) ([9b6f1de](https://github.com/puppeteer/puppeteer/commit/9b6f1de8b99445c661c5aebcf041fe90daf469b9)) +* roll to Chrome 117.0.5938.62 (r1181205) ([#10893](https://github.com/puppeteer/puppeteer/issues/10893)) ([4b8d20d](https://github.com/puppeteer/puppeteer/commit/4b8d20d0edeccaa3028e0c1c0b63c022cfabcee2)) + + +### Bug Fixes + +* fix line/column number in errors ([#10926](https://github.com/puppeteer/puppeteer/issues/10926)) ([a0e57f7](https://github.com/puppeteer/puppeteer/commit/a0e57f7eb230ba6a659c2d418da8d3f67add2d00)) +* handle frame manager init without unhandled rejection ([#10902](https://github.com/puppeteer/puppeteer/issues/10902)) ([ea14834](https://github.com/puppeteer/puppeteer/commit/ea14834fdf1c7c1afa45bdd1fb5339380f4631a2)) +* remove explicit resource management from types ([#10918](https://github.com/puppeteer/puppeteer/issues/10918)) ([a1b1bff](https://github.com/puppeteer/puppeteer/commit/a1b1bffb7258f1dec3b0a2e9ce068baf2cc3db19)) +* roll to Chrome 117.0.5938.88 (r1181205) ([#10920](https://github.com/puppeteer/puppeteer/issues/10920)) ([b7bcc9a](https://github.com/puppeteer/puppeteer/commit/b7bcc9a733a3ac376397a32c3f62eb68101bedf9)) + +## [21.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.2.0...puppeteer-core-v21.2.1) (2023-09-13) + + +### Bug Fixes + +* use supported node range for types ([#10896](https://github.com/puppeteer/puppeteer/issues/10896)) ([2d851c1](https://github.com/puppeteer/puppeteer/commit/2d851c1398e5efcdabdb5304dc78e68cbd3fadd2)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.7.0 to 1.7.1 + +## [21.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.1.1...puppeteer-core-v21.2.0) (2023-09-12) + + +### Features + +* expose DevTools as a target ([#10812](https://github.com/puppeteer/puppeteer/issues/10812)) ([a540085](https://github.com/puppeteer/puppeteer/commit/a540085176d92bd160a12ebc54606dbacd064979)) + + +### Bug Fixes + +* add --disable-search-engine-choice-screen to default arguments ([#10880](https://github.com/puppeteer/puppeteer/issues/10880)) ([d08ad5f](https://github.com/puppeteer/puppeteer/commit/d08ad5fbbe3be4349dd6132c209895f8436ae9e6)) +* apply viewport emulation to prerender targets ([#10804](https://github.com/puppeteer/puppeteer/issues/10804)) ([14f0ab7](https://github.com/puppeteer/puppeteer/commit/14f0ab7397053db5591823c716e142c684f25b44)) +* implement `throwIfDetached` ([#10826](https://github.com/puppeteer/puppeteer/issues/10826)) ([538bb73](https://github.com/puppeteer/puppeteer/commit/538bb73ea7e280cacf15fc1d2100251d8e17f906)) +* LifecycleWatcher sub frames handling ([#10841](https://github.com/puppeteer/puppeteer/issues/10841)) ([06c1588](https://github.com/puppeteer/puppeteer/commit/06c1588016e1ebef5ed8f079dc34507f6d781e07)) +* make network manager multi session ([#10793](https://github.com/puppeteer/puppeteer/issues/10793)) ([085936b](https://github.com/puppeteer/puppeteer/commit/085936bd7e17ed5a8085311f5b212c7b9ca96a0d)) +* make page.goBack work with bfcache in tab mode ([#10818](https://github.com/puppeteer/puppeteer/issues/10818)) ([22daf18](https://github.com/puppeteer/puppeteer/commit/22daf1861fc358acf4d84c360049736c22249f92)) +* only a single disable features flag is allowed ([#10887](https://github.com/puppeteer/puppeteer/issues/10887)) ([4852e22](https://github.com/puppeteer/puppeteer/commit/4852e222b771ed9b95596657f70e45c1d5b9790d)) +* trimCache should remove Firefox too ([#10872](https://github.com/puppeteer/puppeteer/issues/10872)) ([acdd7d3](https://github.com/puppeteer/puppeteer/commit/acdd7d3cd5529bc934edbb8479bdb950cc7d8a6a)) + +## [21.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.1.0...puppeteer-core-v21.1.1) (2023-08-28) + + +### Bug Fixes + +* **locators:** do not retry via catchError ([#10762](https://github.com/puppeteer/puppeteer/issues/10762)) ([8f9388f](https://github.com/puppeteer/puppeteer/commit/8f9388f2ce5220ad9b3c05fb3f3d9a86fac894dc)) + +## [21.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.0.3...puppeteer-core-v21.1.0) (2023-08-18) + + +### Features + +* roll to Chrome 116.0.5845.96 (r1160321) ([#10735](https://github.com/puppeteer/puppeteer/issues/10735)) ([e12b558](https://github.com/puppeteer/puppeteer/commit/e12b558f505aab13f38030a7b748261bdeadc48b)) + + +### Bug Fixes + +* locator.fill should work for textareas ([#10737](https://github.com/puppeteer/puppeteer/issues/10737)) ([fc08a7d](https://github.com/puppeteer/puppeteer/commit/fc08a7dd54226878300f3a4b52fb16aeb5cc93e8)) +* relative ordering of events and command responses should be ensured ([#10725](https://github.com/puppeteer/puppeteer/issues/10725)) ([81ecb60](https://github.com/puppeteer/puppeteer/commit/81ecb60190f89389abb6d8834158f38ff7317ec8)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.6.0 to 1.7.0 + +## [21.0.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.0.1...puppeteer-core-v21.0.2) (2023-08-08) + + +### Bug Fixes + +* destroy puppeteer utility on context destruction ([#10672](https://github.com/puppeteer/puppeteer/issues/10672)) ([8b8770c](https://github.com/puppeteer/puppeteer/commit/8b8770c004ba842496e0ca4845642fe82a211051)) +* roll to Chrome 115.0.5790.170 (r1148114) ([#10677](https://github.com/puppeteer/puppeteer/issues/10677)) ([e5af57e](https://github.com/puppeteer/puppeteer/commit/e5af57ebd0187c296bc44426c1b931f57442732e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.5.0 to 1.5.1 + +## [21.0.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.0.0...puppeteer-core-v21.0.1) (2023-08-03) + + +### Bug Fixes + +* use handle frame instead of page ([#10676](https://github.com/puppeteer/puppeteer/issues/10676)) ([1b44b91](https://github.com/puppeteer/puppeteer/commit/1b44b911d3633df89bd6106aaf7accb49230934d)) + +## [21.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.9.0...puppeteer-core-v21.0.0) (2023-08-02) + + +### ⚠ BREAKING CHANGES + +* use Target for filters ([#10601](https://github.com/puppeteer/puppeteer/issues/10601)) + +### Features + +* add page.createCDPSession method ([#10515](https://github.com/puppeteer/puppeteer/issues/10515)) ([d0c5b8e](https://github.com/puppeteer/puppeteer/commit/d0c5b8e08905f3802705a1a90d7cc8fa04bc82db)) +* implement `Locator.prototype.filter` ([#10631](https://github.com/puppeteer/puppeteer/issues/10631)) ([e73d35d](https://github.com/puppeteer/puppeteer/commit/e73d35def0718468fe854ac2ef5f4a8beafb2fb3)) +* implement `Locator.prototype.map` ([#10630](https://github.com/puppeteer/puppeteer/issues/10630)) ([47eecf5](https://github.com/puppeteer/puppeteer/commit/47eecf5bb11daba0114ad04282beb01c85eb9405)) +* implement `Locator.prototype.wait` ([#10629](https://github.com/puppeteer/puppeteer/issues/10629)) ([5d34d42](https://github.com/puppeteer/puppeteer/commit/5d34d42d1536cbe7cf2ba1aa8670d909c4e6a6fc)) +* implement `Locator.prototype.waitHandle` ([#10650](https://github.com/puppeteer/puppeteer/issues/10650)) ([fdada74](https://github.com/puppeteer/puppeteer/commit/fdada74ba7265b3571ebdf60ae301b64d13a8226)) +* implement function locators ([#10632](https://github.com/puppeteer/puppeteer/issues/10632)) ([6ad92f7](https://github.com/puppeteer/puppeteer/commit/6ad92f7f84f477b22674f52f0a145a500c3aa152)) +* implement immutable locator operations ([#10638](https://github.com/puppeteer/puppeteer/issues/10638)) ([34be28d](https://github.com/puppeteer/puppeteer/commit/34be28db5d9971cf16d9741b0141357df3cbf74c)) + + +### Bug Fixes + +* remove typescript from peer dependencies ([#10593](https://github.com/puppeteer/puppeteer/issues/10593)) ([c60572a](https://github.com/puppeteer/puppeteer/commit/c60572a1ca36ea5946d287bd629ac31798d84cb0)) +* roll to Chrome 115.0.5790.102 (r1148114) ([#10608](https://github.com/puppeteer/puppeteer/issues/10608)) ([8649c53](https://github.com/puppeteer/puppeteer/commit/8649c53a706e5a09ae5e16849eb29a793cec5bec)) + + +### Code Refactoring + +* use Target for filters ([#10601](https://github.com/puppeteer/puppeteer/issues/10601)) ([44712d1](https://github.com/puppeteer/puppeteer/commit/44712d1e6efcb3fa49c27b1195d17c0c1c92a0ca)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.6 to 1.5.0 + +## [20.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.8.3...puppeteer-core-v20.9.0) (2023-07-20) + + +### Features + +* add autofill support ([#10565](https://github.com/puppeteer/puppeteer/issues/10565)) ([6c9306a](https://github.com/puppeteer/puppeteer/commit/6c9306a72e0f7195a4a6c300645f6089845c9abc)) +* roll to Chrome 115.0.5790.98 (r1148114) ([#10584](https://github.com/puppeteer/puppeteer/issues/10584)) ([830f926](https://github.com/puppeteer/puppeteer/commit/830f926d486675701720b5c147f597364f3e8f7b)) + + +### Bug Fixes + +* update the target to ES2022 ([#10574](https://github.com/puppeteer/puppeteer/issues/10574)) ([88439f9](https://github.com/puppeteer/puppeteer/commit/88439f913ed4159cdc8be573f2dbda0b1f615301)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.5 to 1.4.6 + +## [20.8.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.8.2...puppeteer-core-v20.8.3) (2023-07-18) + + +### Bug Fixes + +* **locators:** reject the race if there are only failures ([#10567](https://github.com/puppeteer/puppeteer/issues/10567)) ([e3dd596](https://github.com/puppeteer/puppeteer/commit/e3dd5968cae196b64d958c161fed3d1b39aed3f6)) +* prevent erroneous new main frame ([#10549](https://github.com/puppeteer/puppeteer/issues/10549)) ([cb46413](https://github.com/puppeteer/puppeteer/commit/cb46413d87f10970f4088b7d58e02a65c5ccd27e)) + +## [20.8.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.8.0...puppeteer-core-v20.8.1) (2023-07-11) + + +### Bug Fixes + +* remove test metadata files ([#10520](https://github.com/puppeteer/puppeteer/issues/10520)) ([cbf4f2a](https://github.com/puppeteer/puppeteer/commit/cbf4f2a66912f24849ae8c88fc1423851dcc4aa7)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.3 to 1.4.4 + +## [20.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.4...puppeteer-core-v20.8.0) (2023-07-06) + + +### Features + +* **screenshot:** enable optimizeForSpeed ([#10492](https://github.com/puppeteer/puppeteer/issues/10492)) ([87aaed4](https://github.com/puppeteer/puppeteer/commit/87aaed4807e5240dec7b25273e44c1ce5e884336)) + + +### Bug Fixes + +* add an internal page.locatorRace ([#10512](https://github.com/puppeteer/puppeteer/issues/10512)) ([56a97dd](https://github.com/puppeteer/puppeteer/commit/56a97dd2fb1cbf36e4f3344f7d22afd6e7ef2380)) + +## [20.7.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.3...puppeteer-core-v20.7.4) (2023-06-29) + + +### Bug Fixes + +* fix escaping algo for P selectors ([#10474](https://github.com/puppeteer/puppeteer/issues/10474)) ([84a956f](https://github.com/puppeteer/puppeteer/commit/84a956f56ba9ce74e9dd0f95ff40fdd14be87b1d)) +* fix the util import in Connection.ts ([#10450](https://github.com/puppeteer/puppeteer/issues/10450)) ([61f4525](https://github.com/puppeteer/puppeteer/commit/61f4525ae306810404af9083d2e7440403c02722)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.2 to 1.4.3 + +## [20.7.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.2...puppeteer-core-v20.7.3) (2023-06-20) + + +### Bug Fixes + +* add parenthesis to JS values in interpolateFunction ([#10426](https://github.com/puppeteer/puppeteer/issues/10426)) ([fbdcc0d](https://github.com/puppeteer/puppeteer/commit/fbdcc0d6469abe7115723347a9f161628074d41e)) +* added clipboard permission that was not exposed ([#10119](https://github.com/puppeteer/puppeteer/issues/10119)) ([c06e15f](https://github.com/puppeteer/puppeteer/commit/c06e15fb5bd7ec21db2d883ccf63ef8fe98c7f4d)) +* include src into published package ([#10415](https://github.com/puppeteer/puppeteer/issues/10415)) ([d1ffad0](https://github.com/puppeteer/puppeteer/commit/d1ffad059ae66104842b92dc814d362c123b9646)) +* WaitForNetworkIdle and Deferred.race ([#10411](https://github.com/puppeteer/puppeteer/issues/10411)) ([138cc5c](https://github.com/puppeteer/puppeteer/commit/138cc5c961da698bf7ca635c9947058df4b2ec72)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.1 to 1.4.2 + +## [20.7.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.1...puppeteer-core-v20.7.2) (2023-06-16) + + +### Bug Fixes + +* roll to Chrome 114.0.5735.133 (r1135570) ([#10384](https://github.com/puppeteer/puppeteer/issues/10384)) ([9311558](https://github.com/puppeteer/puppeteer/commit/93115587c94278e0a5309429d3f23a52ed24e22d)) + +## [20.7.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.0...puppeteer-core-v20.7.1) (2023-06-13) + + +### Bug Fixes + +* avoid importing puppeteer-core.js ([#10376](https://github.com/puppeteer/puppeteer/issues/10376)) ([3171c12](https://github.com/puppeteer/puppeteer/commit/3171c12a0c16b283e6b65b1ed3d801b089a6e28b)) + +## [20.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.6.0...puppeteer-core-v20.7.0) (2023-06-13) + + +### Features + +* add `reset` to mouse ([#10340](https://github.com/puppeteer/puppeteer/issues/10340)) ([35aedc0](https://github.com/puppeteer/puppeteer/commit/35aedc0dbbd80818e6f83ff9f0777dc3ea2588f0)) + + +### Bug Fixes + +* Locator.scroll in race ([#10363](https://github.com/puppeteer/puppeteer/issues/10363)) ([ba28724](https://github.com/puppeteer/puppeteer/commit/ba28724952b41ea653830a75efc4c73b234ea354)) +* mark CDPSessionOnMessageObject as internal ([#10373](https://github.com/puppeteer/puppeteer/issues/10373)) ([7cb6059](https://github.com/puppeteer/puppeteer/commit/7cb6059bcc36f8dc3739a8df9119c658146ac100)) +* specify the context id when adding bindings ([#10366](https://github.com/puppeteer/puppeteer/issues/10366)) ([c2d3488](https://github.com/puppeteer/puppeteer/commit/c2d3488ad8c0453312557ba28e6ade9c32464f17)) + +## [20.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.5.0...puppeteer-core-v20.6.0) (2023-06-09) + + +### Features + +* add `page.removeExposedFunction` ([#10297](https://github.com/puppeteer/puppeteer/issues/10297)) ([4d0dbbc](https://github.com/puppeteer/puppeteer/commit/4d0dbbc517f388a3fe984ec569bc1bad28d91494)) +* **chrome:** roll to Chrome 114.0.5735.45 (r1135570) ([#10302](https://github.com/puppeteer/puppeteer/issues/10302)) ([021402d](https://github.com/puppeteer/puppeteer/commit/021402d1363accabc05f75ea1004451a90e1dfca)) +* implement Locator.race ([#10337](https://github.com/puppeteer/puppeteer/issues/10337)) ([9c35e9a](https://github.com/puppeteer/puppeteer/commit/9c35e9ab1f92e99aab8dabcd17f687befd6aad81)) +* implement Locators ([#10305](https://github.com/puppeteer/puppeteer/issues/10305)) ([1f978f5](https://github.com/puppeteer/puppeteer/commit/1f978f5fc5f0580859ad423e952595979f50d5a9)) + + +### Bug Fixes + +* content() not showing comments outside html tag ([#10293](https://github.com/puppeteer/puppeteer/issues/10293)) ([9abd48a](https://github.com/puppeteer/puppeteer/commit/9abd48a062a4a30fb93d0b555f2fa03d3dc410f3)) +* ensure stack trace contains one line ([#10317](https://github.com/puppeteer/puppeteer/issues/10317)) ([bc0b04b](https://github.com/puppeteer/puppeteer/commit/bc0b04beef3244280e6569a233173d512adaa9d8)) +* roll to Chrome 114.0.5735.90 (r1135570) ([#10329](https://github.com/puppeteer/puppeteer/issues/10329)) ([60acefc](https://github.com/puppeteer/puppeteer/commit/60acefc1d6d719ed6c5053d6b9ad734306d08c4a)) +* send capabilities property in session.new command ([#10311](https://github.com/puppeteer/puppeteer/issues/10311)) ([e8d044c](https://github.com/puppeteer/puppeteer/commit/e8d044cb8dcb689cc066ffa18a1e3c9366f57902)) + +## [20.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.4.0...puppeteer-core-v20.5.0) (2023-05-31) + + +### Features + +* Page.removeScriptToEvaluateOnNewDocument ([#10250](https://github.com/puppeteer/puppeteer/issues/10250)) ([b5a124f](https://github.com/puppeteer/puppeteer/commit/b5a124ff738a03fa7eb5755b441af5b773447449)) + + +### Bug Fixes + +* bind trimCache to the instance ([#10270](https://github.com/puppeteer/puppeteer/issues/10270)) ([50e72a4](https://github.com/puppeteer/puppeteer/commit/50e72a4d1164af7d53e31b8b83117f695ede7ae4)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.0 to 1.4.1 + +## [20.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.3.0...puppeteer-core-v20.4.0) (2023-05-24) + + +### Features + +* Page.setBypassServiceWorker ([#10229](https://github.com/puppeteer/puppeteer/issues/10229)) ([81f73a5](https://github.com/puppeteer/puppeteer/commit/81f73a55f31892e55219ef9d37e235e988731fc1)) + + +### Bug Fixes + +* stacktraces should not throw errors ([#10231](https://github.com/puppeteer/puppeteer/issues/10231)) ([557ec24](https://github.com/puppeteer/puppeteer/commit/557ec24cfc084440197da67581bf9782f10eb346)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.3.0 to 1.4.0 + +## [20.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.2.1...puppeteer-core-v20.3.0) (2023-05-22) + + +### Features + +* add an ability to trim cache for Puppeteer ([#10199](https://github.com/puppeteer/puppeteer/issues/10199)) ([1ad32ec](https://github.com/puppeteer/puppeteer/commit/1ad32ec9948ca3e07e15548a562c8f3c633b3dc3)) + + +### Bug Fixes + +* ElementHandle dragAndDrop should fail when interception is disabled ([#10209](https://github.com/puppeteer/puppeteer/issues/10209)) ([bcf5fd8](https://github.com/puppeteer/puppeteer/commit/bcf5fd87aeeb822203c3388e8aa6dadaa0107690)) + +## [20.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.2.0...puppeteer-core-v20.2.1) (2023-05-15) + + +### Bug Fixes + +* use encode/decodeURIComponent ([#10183](https://github.com/puppeteer/puppeteer/issues/10183)) ([d0c68ff](https://github.com/puppeteer/puppeteer/commit/d0c68ff002df37907968d3b999a8273590ac7c97)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.2.0 to 1.3.0 + +## [20.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.1.2...puppeteer-core-v20.2.0) (2023-05-11) + + +### Features + +* implement detailed errors for evaluation ([#10114](https://github.com/puppeteer/puppeteer/issues/10114)) ([317fa73](https://github.com/puppeteer/puppeteer/commit/317fa732f920382f9b3f6dea4e31ed31b04e25da)) + + +### Bug Fixes + +* downloadPath should be used by the install script ([#10163](https://github.com/puppeteer/puppeteer/issues/10163)) ([4398f66](https://github.com/puppeteer/puppeteer/commit/4398f66f281f1ffe5be81b529fc4751edfaf761d)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.1.0 to 1.2.0 + +## [20.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.1.0...puppeteer-core-v20.1.1) (2023-05-05) + + +### Bug Fixes + +* rename PUPPETEER_DOWNLOAD_HOST to PUPPETEER_DOWNLOAD_BASE_URL ([#10130](https://github.com/puppeteer/puppeteer/issues/10130)) ([9758cae](https://github.com/puppeteer/puppeteer/commit/9758cae029f90908c4b5340561d9c51c26aa2f21)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.0.0 to 1.0.1 + +## [20.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.0.0...puppeteer-core-v20.1.0) (2023-05-03) + + +### Features + +* **chrome:** roll to Chrome 113.0.5672.63 (r1121455) ([#10116](https://github.com/puppeteer/puppeteer/issues/10116)) ([19f4334](https://github.com/puppeteer/puppeteer/commit/19f43348a884edfc3e73ab60e41a9757239df013)) + +## [20.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.11.1...puppeteer-core-v20.0.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) + +### Features + +* add AbortSignal to waitForFunction ([#10078](https://github.com/puppeteer/puppeteer/issues/10078)) ([4dd4cb9](https://github.com/puppeteer/puppeteer/commit/4dd4cb929242a6b1a621fd461edd3167d40e1c4c)) +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) ([7405d65](https://github.com/puppeteer/puppeteer/commit/7405d6585aa09b240fbab09aa360674d4442b3d9)) +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) ([df4d60c](https://github.com/puppeteer/puppeteer/commit/df4d60c187aa11c4ad783827242e9511f4ec2aab)) + + +### Bug Fixes + +* use AbortSignal.throwIfAborted ([#10105](https://github.com/puppeteer/puppeteer/issues/10105)) ([575f00a](https://github.com/puppeteer/puppeteer/commit/575f00a31d0278f7ff27096e770ff84399cd9993)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.5.0 to 1.0.0 + +## [19.11.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.11.0...puppeteer-core-v19.11.1) (2023-04-25) + + +### Bug Fixes + +* implement click `count` ([#10069](https://github.com/puppeteer/puppeteer/issues/10069)) ([8124a7d](https://github.com/puppeteer/puppeteer/commit/8124a7d5bfc1cfa8cb579271f78ce586efc62b8e)) +* implement flag for disabling headless warning ([#10073](https://github.com/puppeteer/puppeteer/issues/10073)) ([cfe9bbc](https://github.com/puppeteer/puppeteer/commit/cfe9bbc852d014b31c754950590b6b6c96573eeb)) + +## [19.11.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.10.1...puppeteer-core-v19.11.0) (2023-04-24) + + +### Features + +* add warn for `headless: true` ([#10039](https://github.com/puppeteer/puppeteer/issues/10039)) ([23d6a95](https://github.com/puppeteer/puppeteer/commit/23d6a95cf10c90f8aba2b12d7b02a73072e20382)) + + +### Bug Fixes + +* infer last pressed button in mouse move ([#10067](https://github.com/puppeteer/puppeteer/issues/10067)) ([a6eaac4](https://github.com/puppeteer/puppeteer/commit/a6eaac4c39d4b0ab3ab1a3c2f319a70fde393edb)) + +## [19.10.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.10.0...puppeteer-core-v19.10.1) (2023-04-21) + + +### Bug Fixes + +* move fs.js to the node folder ([#10055](https://github.com/puppeteer/puppeteer/issues/10055)) ([704624e](https://github.com/puppeteer/puppeteer/commit/704624eb2045a7e38ed14044d6863a2871e9d7e2)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.4.1 to 0.5.0 + +## [19.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.9.1...puppeteer-core-v19.10.0) (2023-04-20) + + +### Features + +* support AbortController in waitForSelector ([#10018](https://github.com/puppeteer/puppeteer/issues/10018)) ([9109b76](https://github.com/puppeteer/puppeteer/commit/9109b76276c9d86a2c521c72fc5b7189979279ca)) +* **webworker:** expose WebWorker.client ([#10042](https://github.com/puppeteer/puppeteer/issues/10042)) ([c125128](https://github.com/puppeteer/puppeteer/commit/c12512822a546e7bfdefd2c68f020aab2a308f4f)) + + +### Bug Fixes + +* continue requests without network instrumentation ([#10046](https://github.com/puppeteer/puppeteer/issues/10046)) ([8283823](https://github.com/puppeteer/puppeteer/commit/8283823cb860528a938e84cb5ba2b5f4cf980e83)) +* install bindings once ([#10049](https://github.com/puppeteer/puppeteer/issues/10049)) ([690aec1](https://github.com/puppeteer/puppeteer/commit/690aec1b5cb4e7e574abde9c533c6c0954e6f1aa)) + +## [19.9.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.9.0...puppeteer-core-v19.9.1) (2023-04-17) + + +### Bug Fixes + +* improve mouse actions ([#10021](https://github.com/puppeteer/puppeteer/issues/10021)) ([34db39e](https://github.com/puppeteer/puppeteer/commit/34db39e4474efee9d4579743026c3d6b6c8e494b)) + +## [19.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.5...puppeteer-core-v19.9.0) (2023-04-13) + + +### Features + +* add ElementHandle.isVisible and ElementHandle.isHidden ([#10007](https://github.com/puppeteer/puppeteer/issues/10007)) ([26c81b7](https://github.com/puppeteer/puppeteer/commit/26c81b7408a98cb9ef1aac9b57a038b699e6d518)) +* add ElementHandle.scrollIntoView ([#10005](https://github.com/puppeteer/puppeteer/issues/10005)) ([0d556a7](https://github.com/puppeteer/puppeteer/commit/0d556a71d6bcd5da501724ccbb4ce0be433768df)) + + +### Bug Fixes + +* make isIntersectingViewport work with SVG elements ([#10004](https://github.com/puppeteer/puppeteer/issues/10004)) ([656b562](https://github.com/puppeteer/puppeteer/commit/656b562c7488d4976a7a53264feef508c6b629dd)) + + +### Performance Improvements + +* amortize handle iterator ([#10002](https://github.com/puppeteer/puppeteer/issues/10002)) ([ab27f73](https://github.com/puppeteer/puppeteer/commit/ab27f738c9abb56f6083d02f7f45d2b8da9fc3f3)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.4.0 to 0.4.1 + +## [19.8.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.4...puppeteer-core-v19.8.5) (2023-04-06) + + +### Bug Fixes + +* add filter to setDiscoverTargets for Firefox ([#9693](https://github.com/puppeteer/puppeteer/issues/9693)) ([c09764e](https://github.com/puppeteer/puppeteer/commit/c09764e4c43d7a62096f430b598d63f2b688e860)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.3.3 to 0.4.0 + +## [19.8.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.3...puppeteer-core-v19.8.4) (2023-04-06) + + +### Bug Fixes + +* ignore extraInfo events if the response is served from cache ([#9983](https://github.com/puppeteer/puppeteer/issues/9983)) ([e7265c9](https://github.com/puppeteer/puppeteer/commit/e7265c9aa94e749de5745e5e98d45d4659f19d30)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.3.2 to 0.3.3 + +## [19.8.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.1...puppeteer-core-v19.8.3) (2023-04-03) + + +### Bug Fixes + +* use shadowRoot for tree walker ([#9950](https://github.com/puppeteer/puppeteer/issues/9950)) ([728547d](https://github.com/puppeteer/puppeteer/commit/728547d4608e8c601e209ede860493b1986da174)) + +## [19.8.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.0...puppeteer-core-v19.8.1) (2023-03-28) + + +### Bug Fixes + +* increase the default protocol timeout ([#9928](https://github.com/puppeteer/puppeteer/issues/9928)) ([4465f4b](https://github.com/puppeteer/puppeteer/commit/4465f4bd1900afc0b049ac863f4e372453a0c234)) + +## [19.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.5...puppeteer-core-v19.8.0) (2023-03-24) + + +### Features + +* add Page.waitForDevicePrompt ([#9299](https://github.com/puppeteer/puppeteer/issues/9299)) ([a5149d5](https://github.com/puppeteer/puppeteer/commit/a5149d52f54036a27a411bc070902b1eb3a7a629)) +* **chromium:** roll to Chromium 112.0.5614.0 (r1108766) ([#9841](https://github.com/puppeteer/puppeteer/issues/9841)) ([eddb1f6](https://github.com/puppeteer/puppeteer/commit/eddb1f6ec3958b79fea297123f7621eb7beaff04)) + + +### Bug Fixes + +* fallback to CSS ([#9876](https://github.com/puppeteer/puppeteer/issues/9876)) ([e6ec9c2](https://github.com/puppeteer/puppeteer/commit/e6ec9c295847fa0f1ec240952f0f2523bb13b7c8)) +* implement protocol-level timeouts ([#9877](https://github.com/puppeteer/puppeteer/issues/9877)) ([510b36c](https://github.com/puppeteer/puppeteer/commit/510b36c50001c95783b00dc8af42b5801ec57358)) +* viewport.deviceScaleFactor can be set to system default ([#9911](https://github.com/puppeteer/puppeteer/issues/9911)) ([022c909](https://github.com/puppeteer/puppeteer/commit/022c90932658d13ff4ae4aa51d26716f5dbe54ac)) +* waitForNavigation issue with aborted events ([#9883](https://github.com/puppeteer/puppeteer/issues/9883)) ([36c029b](https://github.com/puppeteer/puppeteer/commit/36c029b38d64a10590bfc74ecea255a58914b0d2)) + +## [19.7.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.4...puppeteer-core-v19.7.5) (2023-03-14) + + +### Bug Fixes + +* sort elements based on selector matching algorithm ([#9836](https://github.com/puppeteer/puppeteer/issues/9836)) ([9044609](https://github.com/puppeteer/puppeteer/commit/9044609be3ea78c650420533e7f6f40b83cedd99)) + + +### Performance Improvements + +* use `querySelector*` for pure CSS selectors ([#9835](https://github.com/puppeteer/puppeteer/issues/9835)) ([8aea8e0](https://github.com/puppeteer/puppeteer/commit/8aea8e047103b72c0238dde8e4777acf7897ddaa)) + +## [19.7.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.3...puppeteer-core-v19.7.4) (2023-03-10) + + +### Bug Fixes + +* call _detach on disconnect ([#9807](https://github.com/puppeteer/puppeteer/issues/9807)) ([bc1a04d](https://github.com/puppeteer/puppeteer/commit/bc1a04def8f699ad245c12ec69ac176e3e7e888d)) +* restore rimraf for puppeteer-core code ([#9815](https://github.com/puppeteer/puppeteer/issues/9815)) ([cefc4ea](https://github.com/puppeteer/puppeteer/commit/cefc4eab4750d2c1209eb36ca44f6963a4a6bf4c)) +* update troubleshooting guide links in errors ([#9821](https://github.com/puppeteer/puppeteer/issues/9821)) ([0165f06](https://github.com/puppeteer/puppeteer/commit/0165f06deef9e45862fd127a205ade5ad30ddaa3)) + +## [19.7.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.2...puppeteer-core-v19.7.3) (2023-03-06) + + +### Bug Fixes + +* update dependencies ([#9781](https://github.com/puppeteer/puppeteer/issues/9781)) ([364b23f](https://github.com/puppeteer/puppeteer/commit/364b23f8b5c7b04974f233c58e5ded9a8f912ff2)) + +## [19.7.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.1...puppeteer-core-v19.7.2) (2023-02-20) + + +### Bug Fixes + +* bump chromium-bidi to a version that does not declare mitt as a peer dependency ([#9701](https://github.com/puppeteer/puppeteer/issues/9701)) ([82916c1](https://github.com/puppeteer/puppeteer/commit/82916c102b2c399093ba9019e272207b5ce81849)) + +## [19.7.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.0...puppeteer-core-v19.7.1) (2023-02-15) + + +### Bug Fixes + +* fix circularity on JSHandle interface ([#9661](https://github.com/puppeteer/puppeteer/issues/9661)) ([eb13863](https://github.com/puppeteer/puppeteer/commit/eb138635d661d3cdaf2940959fece5aca482178a)) +* make chromium-bidi an opt peer dep ([#9667](https://github.com/puppeteer/puppeteer/issues/9667)) ([c6054ac](https://github.com/puppeteer/puppeteer/commit/c6054ac1a56c08ee7bf01321878699b7b4ab4e0b)) + +## [19.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.6.3...puppeteer-core-v19.7.0) (2023-02-13) + + +### Features + +* add touchstart, touchmove and touchend methods ([#9622](https://github.com/puppeteer/puppeteer/issues/9622)) ([c8bb11a](https://github.com/puppeteer/puppeteer/commit/c8bb11adfcf1537032730a91baa3c36a6e324926)) +* **chromium:** roll to Chromium 111.0.5556.0 (r1095492) ([#9656](https://github.com/puppeteer/puppeteer/issues/9656)) ([df59d01](https://github.com/puppeteer/puppeteer/commit/df59d010c20644da06eb4c4e28a11c4eea164aba)) + + +### Bug Fixes + +* `page.goto` error throwing on 40x/50x responses with an empty body ([#9523](https://github.com/puppeteer/puppeteer/issues/9523)) ([#9577](https://github.com/puppeteer/puppeteer/issues/9577)) ([ddb0cc1](https://github.com/puppeteer/puppeteer/commit/ddb0cc174d2a14c0948dcdaf9bae78620937c667)) + +## [19.6.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.6.2...puppeteer-core-v19.6.3) (2023-02-01) + + +### Bug Fixes + +* ignore not found contexts for console messages ([#9595](https://github.com/puppeteer/puppeteer/issues/9595)) ([390685b](https://github.com/puppeteer/puppeteer/commit/390685bbe52c22b686fc0e3119b4ac7b1073c581)) +* restore WaitTask terminate condition ([#9612](https://github.com/puppeteer/puppeteer/issues/9612)) ([e16cbc6](https://github.com/puppeteer/puppeteer/commit/e16cbc6626cffd40d0caa30801620e7293455006)) + +## [19.6.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.6.1...puppeteer-core-v19.6.2) (2023-01-27) + + +### Bug Fixes + +* atomically get Puppeteer utilities ([#9597](https://github.com/puppeteer/puppeteer/issues/9597)) ([050a7b0](https://github.com/puppeteer/puppeteer/commit/050a7b062415ebaf10bcb71c405143eacc4e5d4b)) + +## [19.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.6.0...puppeteer-core-v19.6.1) (2023-01-26) + + +### Bug Fixes + +* don't clean up previous browser versions ([#9568](https://github.com/puppeteer/puppeteer/issues/9568)) ([344bc2a](https://github.com/puppeteer/puppeteer/commit/344bc2af62e4068fe2cb8162d4b6c8242aac843b)), closes [#9533](https://github.com/puppeteer/puppeteer/issues/9533) +* mimic rejection for PuppeteerUtil on early call ([#9589](https://github.com/puppeteer/puppeteer/issues/9589)) ([1980de9](https://github.com/puppeteer/puppeteer/commit/1980de91a161523c7098a79919b20e6d8d2e5d81)) +* **revert:** use LazyArg for puppeteer utilities ([#9590](https://github.com/puppeteer/puppeteer/issues/9590)) ([6edd996](https://github.com/puppeteer/puppeteer/commit/6edd99676827de2c83f7a858e4f903b1c34e7d35)) +* use LazyArg for puppeteer utilities ([#9575](https://github.com/puppeteer/puppeteer/issues/9575)) ([496658f](https://github.com/puppeteer/puppeteer/commit/496658f02945b53096483f36cb3d64556cff045e)) + +## [19.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.5.2...puppeteer-core-v19.6.0) (2023-01-23) + + +### Features + +* **chromium:** roll to Chromium 110.0.5479.0 (r1083080) ([#9500](https://github.com/puppeteer/puppeteer/issues/9500)) ([06e816b](https://github.com/puppeteer/puppeteer/commit/06e816bbfa7b9ca84284929f654de7288c51169d)), closes [#9470](https://github.com/puppeteer/puppeteer/issues/9470) +* **page:** Adding support for referrerPolicy in `page.goto` ([#9561](https://github.com/puppeteer/puppeteer/issues/9561)) ([e3d69ec](https://github.com/puppeteer/puppeteer/commit/e3d69ec554beeac37bd206a21921d2fed3cb968c)) + + +### Bug Fixes + +* firefox revision resolution should not update chrome revision ([#9507](https://github.com/puppeteer/puppeteer/issues/9507)) ([f59bbf4](https://github.com/puppeteer/puppeteer/commit/f59bbf4014644dec6f395713e8403939aebe06ea)), closes [#9461](https://github.com/puppeteer/puppeteer/issues/9461) +* improve screenshot method types ([#9529](https://github.com/puppeteer/puppeteer/issues/9529)) ([6847f88](https://github.com/puppeteer/puppeteer/commit/6847f8835f28e97edba6fce76a4cbf85561482b9)) + +## [19.5.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.5.1...puppeteer-core-v19.5.2) (2023-01-11) + + +### Bug Fixes + +* make sure browser fetcher in launchers uses configuration ([#9493](https://github.com/puppeteer/puppeteer/issues/9493)) ([df55439](https://github.com/puppeteer/puppeteer/commit/df554397b51e97aea2765b325f9a887b50b9263a)), closes [#9470](https://github.com/puppeteer/puppeteer/issues/9470) + +## [19.5.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.5.0...puppeteer-core-v19.5.1) (2023-01-11) + + +### Bug Fixes + +* use puppeteer node for installation script ([#9489](https://github.com/puppeteer/puppeteer/issues/9489)) ([9bf90d9](https://github.com/puppeteer/puppeteer/commit/9bf90d9f4b5aeab06f8b433714712cad3259d36e)) + +## [19.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.4.1...puppeteer-core-v19.5.0) (2023-01-05) + + +### Features + +* add element validation ([#9352](https://github.com/puppeteer/puppeteer/issues/9352)) ([c7a063a](https://github.com/puppeteer/puppeteer/commit/c7a063a15274856184356e15f2ae4be41191d309)) + + +### Bug Fixes + +* **puppeteer-core:** target interceptor is not async ([#9430](https://github.com/puppeteer/puppeteer/issues/9430)) ([e3e9cc6](https://github.com/puppeteer/puppeteer/commit/e3e9cc622ac32f2067b6e74b5e8706c63169a157)) + +## [19.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.4.0...puppeteer-core-v19.4.1) (2022-12-16) + + +### Bug Fixes + +* improve a11y snapshot handling if the tree is not correct ([#9405](https://github.com/puppeteer/puppeteer/issues/9405)) ([02fe501](https://github.com/puppeteer/puppeteer/commit/02fe50194e60bd14c3a82539473a0313ab88c766)), closes [#9404](https://github.com/puppeteer/puppeteer/issues/9404) +* remove oopif expectations and fix oopif flakiness ([#9375](https://github.com/puppeteer/puppeteer/issues/9375)) ([810e0cd](https://github.com/puppeteer/puppeteer/commit/810e0cd74ecef353cfa43746c18bd5f580a3233d)) + +## [19.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.3.0...puppeteer-core-v19.4.0) (2022-12-07) + + +### Features + +* ability to send headers via ws connection to browser in node.js environment ([#9314](https://github.com/puppeteer/puppeteer/issues/9314)) ([937fffa](https://github.com/puppeteer/puppeteer/commit/937fffaedc340ea12d5f6636d3ba6598cb22e397)), closes [#7218](https://github.com/puppeteer/puppeteer/issues/7218) +* **chromium:** roll to Chromium 109.0.5412.0 (r1069273) ([#9364](https://github.com/puppeteer/puppeteer/issues/9364)) ([1875da6](https://github.com/puppeteer/puppeteer/commit/1875da61916df1fbcf98047858c01075bd9af189)), closes [#9233](https://github.com/puppeteer/puppeteer/issues/9233) +* **puppeteer-core:** keydown supports commands ([#9357](https://github.com/puppeteer/puppeteer/issues/9357)) ([b7ebc5d](https://github.com/puppeteer/puppeteer/commit/b7ebc5d9bb9b9940ffdf470e51d007f709587d40)) + + +### Bug Fixes + +* **puppeteer-core:** avoid type instantiation errors ([#9370](https://github.com/puppeteer/puppeteer/issues/9370)) ([17f31a9](https://github.com/puppeteer/puppeteer/commit/17f31a9ee408ca5a08fe6dbceb8915e710156bd3)), closes [#9369](https://github.com/puppeteer/puppeteer/issues/9369) + +## [19.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.2.2...puppeteer-core-v19.3.0) (2022-11-23) + + +### Features + +* **puppeteer-core:** Infer element type from complex selector ([#9253](https://github.com/puppeteer/puppeteer/issues/9253)) ([bef1061](https://github.com/puppeteer/puppeteer/commit/bef1061c064e5135d86a48fffd7278f3e7f4a29e)) +* **puppeteer-core:** update Chrome launcher flags ([#9239](https://github.com/puppeteer/puppeteer/issues/9239)) ([ae87bfc](https://github.com/puppeteer/puppeteer/commit/ae87bfc2b4361556e3660a1de2c6db348ce663ae)) + + +### Bug Fixes + +* remove boundary conditions for visibility ([#9249](https://github.com/puppeteer/puppeteer/issues/9249)) ([e003513](https://github.com/puppeteer/puppeteer/commit/e003513c0c049aad38e374a16dc96c3e54ab0de5)) + +## [19.2.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.2.1...puppeteer-core-v19.2.2) (2022-11-03) + + +### Bug Fixes + +* update missing product message ([#9207](https://github.com/puppeteer/puppeteer/issues/9207)) ([29f47e2](https://github.com/puppeteer/puppeteer/commit/29f47e2e150ff7bfd89e38a4ce4ca34eac7f2fdf)) + +## [19.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.2.0...puppeteer-core-v19.2.1) (2022-10-28) + + +### Bug Fixes + +* resolve navigation requests when request fails ([#9178](https://github.com/puppeteer/puppeteer/issues/9178)) ([c11297b](https://github.com/puppeteer/puppeteer/commit/c11297baa5124eb89f7686c3eb446d2ba1b7123a)), closes [#9175](https://github.com/puppeteer/puppeteer/issues/9175) + +## [19.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.1.1...puppeteer-core-v19.2.0) (2022-10-26) + + +### Features + +* **chromium:** roll to Chromium 108.0.5351.0 (r1056772) ([#9153](https://github.com/puppeteer/puppeteer/issues/9153)) ([e78a4e8](https://github.com/puppeteer/puppeteer/commit/e78a4e89c22bb1180e72d180c16b39673ff9125e)) + +## [19.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.1.0...puppeteer-core-v19.1.1) (2022-10-24) + + +### Bug Fixes + +* update documentation on configuring puppeteer ([#9150](https://github.com/puppeteer/puppeteer/issues/9150)) ([f07ad2c](https://github.com/puppeteer/puppeteer/commit/f07ad2c6616ecd2a959b0c1a65b167ba77611d61)) + +## [19.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.0.0...puppeteer-core-v19.1.0) (2022-10-21) + + +### Features + +* expose browser context id ([#9134](https://github.com/puppeteer/puppeteer/issues/9134)) ([122778a](https://github.com/puppeteer/puppeteer/commit/122778a1f8b60e0dcc6f0ffcb2097e95ae98f4a3)), closes [#9132](https://github.com/puppeteer/puppeteer/issues/9132) +* use configuration files ([#9140](https://github.com/puppeteer/puppeteer/issues/9140)) ([ec20174](https://github.com/puppeteer/puppeteer/commit/ec201744f077987b288e3dff52c0906fe700f6fb)), closes [#9128](https://github.com/puppeteer/puppeteer/issues/9128) + + +### Bug Fixes + +* update `BrowserFetcher` deprecation message ([#9141](https://github.com/puppeteer/puppeteer/issues/9141)) ([efcbc97](https://github.com/puppeteer/puppeteer/commit/efcbc97c60e4cfd49a9ed25a900f6133d06b290b)) + +## [19.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v18.2.1...puppeteer-core-v19.0.0) (2022-10-14) + + +### ⚠ BREAKING CHANGES + +* use `~/.cache/puppeteer` for browser downloads (#9095) +* deprecate `createBrowserFetcher` in favor of `BrowserFetcher` (#9079) +* refactor custom query handler API (#9078) +* remove `puppeteer.devices` in favor of `KnownDevices` (#9075) +* deprecate indirect network condition imports (#9074) +* deprecate indirect error imports (#9072) + +### Features + +* add ability to collect JS code coverage at the function level ([#9027](https://github.com/puppeteer/puppeteer/issues/9027)) ([a032583](https://github.com/puppeteer/puppeteer/commit/a032583b6c9b469bda699bca200b180206d61247)) +* deprecate `createBrowserFetcher` in favor of `BrowserFetcher` ([#9079](https://github.com/puppeteer/puppeteer/issues/9079)) ([7294dfe](https://github.com/puppeteer/puppeteer/commit/7294dfe9c6c3b224f95ba6d59b5ef33d379fd09a)), closes [#8999](https://github.com/puppeteer/puppeteer/issues/8999) +* use `~/.cache/puppeteer` for browser downloads ([#9095](https://github.com/puppeteer/puppeteer/issues/9095)) ([3df375b](https://github.com/puppeteer/puppeteer/commit/3df375baedad64b8773bb1e1e6f81b604ed18989)) + + +### Bug Fixes + +* deprecate indirect error imports ([#9072](https://github.com/puppeteer/puppeteer/issues/9072)) ([9f4f43a](https://github.com/puppeteer/puppeteer/commit/9f4f43a28b06787a1cf97efe904ccfe7237dffdd)) +* deprecate indirect network condition imports ([#9074](https://github.com/puppeteer/puppeteer/issues/9074)) ([41d0122](https://github.com/puppeteer/puppeteer/commit/41d0122b94f41b308536c48ced345dec8c272a49)) +* refactor custom query handler API ([#9078](https://github.com/puppeteer/puppeteer/issues/9078)) ([1847704](https://github.com/puppeteer/puppeteer/commit/1847704789e2888c755de8c739d567364b8ad645)) +* remove `puppeteer.devices` in favor of `KnownDevices` ([#9075](https://github.com/puppeteer/puppeteer/issues/9075)) ([87c08fd](https://github.com/puppeteer/puppeteer/commit/87c08fd86a79b63308ad8d46c5f7acd1927505f8)) +* remove viewport conditions in `waitForSelector` ([#9087](https://github.com/puppeteer/puppeteer/issues/9087)) ([acbc599](https://github.com/puppeteer/puppeteer/commit/acbc59999bf800eeac75c4045b75a32b4357c79e)) + +## [18.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v18.2.0...puppeteer-core-v18.2.1) (2022-10-06) + + +### Bug Fixes + +* add README to package during prepack ([#9057](https://github.com/puppeteer/puppeteer/issues/9057)) ([9374e23](https://github.com/puppeteer/puppeteer/commit/9374e23d3da5e40378461ed08db24649730a445a)) +* waitForRequest works with async predicate ([#9058](https://github.com/puppeteer/puppeteer/issues/9058)) ([8f6b2c9](https://github.com/puppeteer/puppeteer/commit/8f6b2c9b7c219d405c954bf7af082d3d29fd48ff)) + +## [18.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v18.1.0...puppeteer-core-v18.2.0) (2022-10-05) + + +### Features + +* separate puppeteer and puppeteer-core ([#9023](https://github.com/puppeteer/puppeteer/issues/9023)) ([f42336c](https://github.com/puppeteer/puppeteer/commit/f42336cf83982332829ca7e14ee48d8676e11545)) + + +## [18.1.0](https://github.com/puppeteer/puppeteer/compare/v18.0.5...v18.1.0) (2022-10-05) + +### Features + +* **chromium:** roll to Chromium 107.0.5296.0 (r1045629) ([#9039](https://github.com/puppeteer/puppeteer/issues/9039)) ([022fbde](https://github.com/puppeteer/puppeteer/commit/022fbde85e067e8c419cf42dd571f9a1187c343c)) + +## [18.0.5](https://github.com/puppeteer/puppeteer/compare/v18.0.4...v18.0.5) (2022-09-22) + + +### Bug Fixes + +* add missing npm config environment variable ([#8996](https://github.com/puppeteer/puppeteer/issues/8996)) ([7c1be20](https://github.com/puppeteer/puppeteer/commit/7c1be20aef46aaf5029732a580ec65aa8008aa9c)) + +## [18.0.4](https://github.com/puppeteer/puppeteer/compare/v18.0.3...v18.0.4) (2022-09-21) + + +### Bug Fixes + +* hardcode binding names ([#8993](https://github.com/puppeteer/puppeteer/issues/8993)) ([7e20554](https://github.com/puppeteer/puppeteer/commit/7e2055433e79ef20f6dcdf02f92e1d64564b7d33)) + +## [18.0.3](https://github.com/puppeteer/puppeteer/compare/v18.0.2...v18.0.3) (2022-09-20) + + +### Bug Fixes + +* change injected.ts imports ([#8987](https://github.com/puppeteer/puppeteer/issues/8987)) ([10a114d](https://github.com/puppeteer/puppeteer/commit/10a114d36f2add90860950f61b3f8b93258edb5c)) + +## [18.0.2](https://github.com/puppeteer/puppeteer/compare/v18.0.1...v18.0.2) (2022-09-19) + + +### Bug Fixes + +* mark internal objects ([#8984](https://github.com/puppeteer/puppeteer/issues/8984)) ([181a148](https://github.com/puppeteer/puppeteer/commit/181a148269fce1575f5e37056929ecdec0517586)) + +## [18.0.1](https://github.com/puppeteer/puppeteer/compare/v18.0.0...v18.0.1) (2022-09-19) + + +### Bug Fixes + +* internal lazy params ([#8982](https://github.com/puppeteer/puppeteer/issues/8982)) ([d504597](https://github.com/puppeteer/puppeteer/commit/d5045976a6dd321bbd265b84c2474ff1ad5d0b77)) + +## [18.0.0](https://github.com/puppeteer/puppeteer/compare/v17.1.3...v18.0.0) (2022-09-19) + + +### ⚠ BREAKING CHANGES + +* fix bounding box visibility conditions (#8954) + +### Features + +* add text query handler ([#8956](https://github.com/puppeteer/puppeteer/issues/8956)) ([633e7cf](https://github.com/puppeteer/puppeteer/commit/633e7cfdf99d42f420d0af381394bd1f6ac7bcd1)) + + +### Bug Fixes + +* fix bounding box visibility conditions ([#8954](https://github.com/puppeteer/puppeteer/issues/8954)) ([ac9929d](https://github.com/puppeteer/puppeteer/commit/ac9929d80f6f7d4905a39183ae235500e29b4f53)) +* suppress init errors if the target is closed ([#8947](https://github.com/puppeteer/puppeteer/issues/8947)) ([cfaaa5e](https://github.com/puppeteer/puppeteer/commit/cfaaa5e2c07e5f98baeb7de99e303aa840a351e8)) +* use win64 version of chromium when on arm64 windows ([#8927](https://github.com/puppeteer/puppeteer/issues/8927)) ([64843b8](https://github.com/puppeteer/puppeteer/commit/64843b88853210314677ab1b434729513ce615a7)) + +## [17.1.3](https://github.com/puppeteer/puppeteer/compare/v17.1.2...v17.1.3) (2022-09-08) + + +### Bug Fixes + +* FirefoxLauncher should not use BrowserFetcher in puppeteer-core ([#8920](https://github.com/puppeteer/puppeteer/issues/8920)) ([f2e8de7](https://github.com/puppeteer/puppeteer/commit/f2e8de777fc5d547778fdc6cac658add84ed4082)), closes [#8919](https://github.com/puppeteer/puppeteer/issues/8919) +* linux arm64 check on windows arm ([#8917](https://github.com/puppeteer/puppeteer/issues/8917)) ([f02b926](https://github.com/puppeteer/puppeteer/commit/f02b926245e28b5671087c051dbdbb3165696f08)), closes [#8915](https://github.com/puppeteer/puppeteer/issues/8915) + +## [17.1.2](https://github.com/puppeteer/puppeteer/compare/v17.1.1...v17.1.2) (2022-09-07) + + +### Bug Fixes + +* add missing code coverage ranges that span only a single character ([#8911](https://github.com/puppeteer/puppeteer/issues/8911)) ([0c577b9](https://github.com/puppeteer/puppeteer/commit/0c577b9bf8855dc0ccb6098cd43a25c528f6d7f5)) +* add Page.getDefaultTimeout getter ([#8903](https://github.com/puppeteer/puppeteer/issues/8903)) ([3240095](https://github.com/puppeteer/puppeteer/commit/32400954c50cbddc48468ad118c3f8a47653b9d3)), closes [#8901](https://github.com/puppeteer/puppeteer/issues/8901) +* don't detect project root for puppeteer-core ([#8907](https://github.com/puppeteer/puppeteer/issues/8907)) ([b4f5ea1](https://github.com/puppeteer/puppeteer/commit/b4f5ea1167a60c870194c70d22f5372ada5b7c4c)), closes [#8896](https://github.com/puppeteer/puppeteer/issues/8896) +* support scale for screenshot clips ([#8908](https://github.com/puppeteer/puppeteer/issues/8908)) ([260e428](https://github.com/puppeteer/puppeteer/commit/260e4282275ab1d05c86e5643e2a02c01f269a9c)), closes [#5329](https://github.com/puppeteer/puppeteer/issues/5329) +* work around a race in waitForFileChooser ([#8905](https://github.com/puppeteer/puppeteer/issues/8905)) ([053d960](https://github.com/puppeteer/puppeteer/commit/053d960fb593e514e7914d7da9af436afc39a12f)), closes [#6040](https://github.com/puppeteer/puppeteer/issues/6040) + +## [17.1.1](https://github.com/puppeteer/puppeteer/compare/v17.1.0...v17.1.1) (2022-09-05) + + +### Bug Fixes + +* restore deferred promise debugging ([#8895](https://github.com/puppeteer/puppeteer/issues/8895)) ([7b42250](https://github.com/puppeteer/puppeteer/commit/7b42250c7bb91ac873307acda493726ffc4c54a8)) + +## [17.1.0](https://github.com/puppeteer/puppeteer/compare/v17.0.0...v17.1.0) (2022-09-02) + + +### Features + +* **chromium:** roll to Chromium 106.0.5249.0 (r1036745) ([#8869](https://github.com/puppeteer/puppeteer/issues/8869)) ([6e9a47a](https://github.com/puppeteer/puppeteer/commit/6e9a47a6faa06d241dec0bcf7bcdf49370517008)) + + +### Bug Fixes + +* allow getting a frame from an elementhandle ([#8875](https://github.com/puppeteer/puppeteer/issues/8875)) ([3732757](https://github.com/puppeteer/puppeteer/commit/3732757450b4363041ccbacc3b236289a156abb0)) +* typos in documentation ([#8858](https://github.com/puppeteer/puppeteer/issues/8858)) ([8d95a9b](https://github.com/puppeteer/puppeteer/commit/8d95a9bc920b98820aa655ad4eb2d8fd9b2b893a)) +* use the timeout setting in waitForFileChooser ([#8856](https://github.com/puppeteer/puppeteer/issues/8856)) ([f477b46](https://github.com/puppeteer/puppeteer/commit/f477b46f212da9206102da695697760eea539f05)) + +## [17.0.0](https://github.com/puppeteer/puppeteer/compare/v16.2.0...v17.0.0) (2022-08-26) + + +### ⚠ BREAKING CHANGES + +* remove `root` from `WaitForSelectorOptions` (#8848) +* internalize execution context (#8844) + +### Bug Fixes + +* allow multiple navigations to happen in LifecycleWatcher ([#8826](https://github.com/puppeteer/puppeteer/issues/8826)) ([341b669](https://github.com/puppeteer/puppeteer/commit/341b669a5e45ecbb9ffb0f28c45b520660f27ad2)), closes [#8811](https://github.com/puppeteer/puppeteer/issues/8811) +* internalize execution context ([#8844](https://github.com/puppeteer/puppeteer/issues/8844)) ([2f33237](https://github.com/puppeteer/puppeteer/commit/2f33237d0443de77d58dca4454b0c9a1d2b57d03)) +* remove `root` from `WaitForSelectorOptions` ([#8848](https://github.com/puppeteer/puppeteer/issues/8848)) ([1155c8e](https://github.com/puppeteer/puppeteer/commit/1155c8eac85b176c3334cc3d98adfe7d943dfbe6)) +* remove deferred promise timeouts ([#8835](https://github.com/puppeteer/puppeteer/issues/8835)) ([202ffce](https://github.com/puppeteer/puppeteer/commit/202ffce0aa4f34dba35fbb8e7d740af16efee35f)), closes [#8832](https://github.com/puppeteer/puppeteer/issues/8832) + +## [16.2.0](https://github.com/puppeteer/puppeteer/compare/v16.1.1...v16.2.0) (2022-08-18) + + +### Features + +* add Khmer (Cambodian) language support ([#8809](https://github.com/puppeteer/puppeteer/issues/8809)) ([34f8737](https://github.com/puppeteer/puppeteer/commit/34f873721804d57a5faf3eab8ef50340c69ed180)) + + +### Bug Fixes + +* handle service workers in extensions ([#8807](https://github.com/puppeteer/puppeteer/issues/8807)) ([2a0eefb](https://github.com/puppeteer/puppeteer/commit/2a0eefb99f0ae00dacc9e768a253308c0d18a4c3)), closes [#8800](https://github.com/puppeteer/puppeteer/issues/8800) + +## [16.1.1](https://github.com/puppeteer/puppeteer/compare/v16.1.0...v16.1.1) (2022-08-16) + + +### Bug Fixes + +* custom sessions should not emit targetcreated events ([#8788](https://github.com/puppeteer/puppeteer/issues/8788)) ([3fad05d](https://github.com/puppeteer/puppeteer/commit/3fad05d333b79f41a7b58582c4ca493200bb5a79)), closes [#8787](https://github.com/puppeteer/puppeteer/issues/8787) +* deprecate `ExecutionContext` ([#8792](https://github.com/puppeteer/puppeteer/issues/8792)) ([b5da718](https://github.com/puppeteer/puppeteer/commit/b5da718e2e4a2004a36cf23cad555e1fc3b50333)) +* deprecate `root` in `WaitForSelectorOptions` ([#8795](https://github.com/puppeteer/puppeteer/issues/8795)) ([65a5ce8](https://github.com/puppeteer/puppeteer/commit/65a5ce8464c56fcc55e5ac3ed490f31311bbe32a)) +* deprecate `waitForTimeout` ([#8793](https://github.com/puppeteer/puppeteer/issues/8793)) ([8f612d5](https://github.com/puppeteer/puppeteer/commit/8f612d5ff855d48ae4b38bdaacf2a8fbda8e9ce8)) +* make sure there is a check for targets when timeout=0 ([#8765](https://github.com/puppeteer/puppeteer/issues/8765)) ([c23cdb7](https://github.com/puppeteer/puppeteer/commit/c23cdb73a7b113c1dd29f7e4a7a61326422c4080)), closes [#8763](https://github.com/puppeteer/puppeteer/issues/8763) +* resolve navigation flakiness ([#8768](https://github.com/puppeteer/puppeteer/issues/8768)) ([2580347](https://github.com/puppeteer/puppeteer/commit/2580347b50091d172b2a5591138a2e41ede072fe)), closes [#8644](https://github.com/puppeteer/puppeteer/issues/8644) +* specify Puppeteer version for Chromium 105.0.5173.0 ([#8766](https://github.com/puppeteer/puppeteer/issues/8766)) ([b5064b7](https://github.com/puppeteer/puppeteer/commit/b5064b7b8bd3bd9eb481b6807c65d9d06d23b9dd)) +* use targetFilter in puppeteer.launch ([#8774](https://github.com/puppeteer/puppeteer/issues/8774)) ([ee2540b](https://github.com/puppeteer/puppeteer/commit/ee2540baefeced44f6b336f2b979af5c3a4cb040)), closes [#8772](https://github.com/puppeteer/puppeteer/issues/8772) + +## [16.1.0](https://github.com/puppeteer/puppeteer/compare/v16.0.0...v16.1.0) (2022-08-06) + + +### Features + +* use an `xpath` query handler ([#8730](https://github.com/puppeteer/puppeteer/issues/8730)) ([5cf9b4d](https://github.com/puppeteer/puppeteer/commit/5cf9b4de8d50bd056db82bcaa23279b72c9313c5)) + + +### Bug Fixes + +* resolve target manager init if no existing targets detected ([#8748](https://github.com/puppeteer/puppeteer/issues/8748)) ([8cb5043](https://github.com/puppeteer/puppeteer/commit/8cb5043868f69cdff7f34f1cfe0c003ff09e281b)), closes [#8747](https://github.com/puppeteer/puppeteer/issues/8747) +* specify the target filter in setDiscoverTargets ([#8742](https://github.com/puppeteer/puppeteer/issues/8742)) ([49193cb](https://github.com/puppeteer/puppeteer/commit/49193cbf1c17f16f0ca59a9fd2ebf306f812f52b)) + +## [16.0.0](https://github.com/puppeteer/puppeteer/compare/v15.5.0...v16.0.0) (2022-08-02) + + +### ⚠ BREAKING CHANGES + +* With Chromium, Puppeteer will now attach to page/iframe targets immediately to allow reliable configuration of targets. + +### Features + +* add Dockerfile ([#8315](https://github.com/puppeteer/puppeteer/issues/8315)) ([936ed86](https://github.com/puppeteer/puppeteer/commit/936ed8607ec0c3798d2b22b590d0be0ad361a888)) +* detect Firefox in connect() automatically ([#8718](https://github.com/puppeteer/puppeteer/issues/8718)) ([2abd772](https://github.com/puppeteer/puppeteer/commit/2abd772c9c3d2b86deb71541eaac41aceef94356)) +* use CDP's auto-attach mechanism ([#8520](https://github.com/puppeteer/puppeteer/issues/8520)) ([2cbfdeb](https://github.com/puppeteer/puppeteer/commit/2cbfdeb0ca388a45cedfae865266230e1291bd29)) + + +### Bug Fixes + +* address flakiness in frame handling ([#8688](https://github.com/puppeteer/puppeteer/issues/8688)) ([6f81b23](https://github.com/puppeteer/puppeteer/commit/6f81b23728a511f7b89eaa2b8f850b22d6c4ab24)) +* disable AcceptCHFrame ([#8706](https://github.com/puppeteer/puppeteer/issues/8706)) ([96d9608](https://github.com/puppeteer/puppeteer/commit/96d9608d1de17877414a649a0737661894dd96c8)), closes [#8479](https://github.com/puppeteer/puppeteer/issues/8479) +* use loaderId to reduce test flakiness ([#8717](https://github.com/puppeteer/puppeteer/issues/8717)) ([d2f6db2](https://github.com/puppeteer/puppeteer/commit/d2f6db20735342bb3f419e85adbd51ed10470044)) + +## [15.5.0](https://github.com/puppeteer/puppeteer/compare/v15.4.2...v15.5.0) (2022-07-21) + + +### Features + +* **chromium:** roll to Chromium 105.0.5173.0 (r1022525) ([#8682](https://github.com/puppeteer/puppeteer/issues/8682)) ([f1b8ad3](https://github.com/puppeteer/puppeteer/commit/f1b8ad3269286800d31818ea4b6b3ee23f7437c3)) + +## [15.4.2](https://github.com/puppeteer/puppeteer/compare/v15.4.1...v15.4.2) (2022-07-21) + + +### Bug Fixes + +* taking a screenshot with null viewport should be possible ([#8680](https://github.com/puppeteer/puppeteer/issues/8680)) ([2abb9f0](https://github.com/puppeteer/puppeteer/commit/2abb9f0c144779d555ecbf337a759440d0282cba)), closes [#8673](https://github.com/puppeteer/puppeteer/issues/8673) + +## [15.4.1](https://github.com/puppeteer/puppeteer/compare/v15.4.0...v15.4.1) (2022-07-21) + + +### Bug Fixes + +* import URL ([#8670](https://github.com/puppeteer/puppeteer/issues/8670)) ([34ab5ca](https://github.com/puppeteer/puppeteer/commit/34ab5ca50353ffb6a6345a8984b724a6f42fb726)) + +## [15.4.0](https://github.com/puppeteer/puppeteer/compare/v15.3.2...v15.4.0) (2022-07-13) + + +### Features + +* expose the page getter on Frame ([#8657](https://github.com/puppeteer/puppeteer/issues/8657)) ([af08c5c](https://github.com/puppeteer/puppeteer/commit/af08c5c90380c853e8257a51298bfed4b0635779)) + + +### Bug Fixes + +* ignore *.tsbuildinfo ([#8662](https://github.com/puppeteer/puppeteer/issues/8662)) ([edcdf21](https://github.com/puppeteer/puppeteer/commit/edcdf217cefbf31aee5a2f571abac429dd81f3a0)) + +## [15.3.2](https://github.com/puppeteer/puppeteer/compare/v15.3.1...v15.3.2) (2022-07-08) + + +### Bug Fixes + +* cache dynamic imports ([#8652](https://github.com/puppeteer/puppeteer/issues/8652)) ([1de0383](https://github.com/puppeteer/puppeteer/commit/1de0383abf6be31cf06faede3e59b087a2958227)) +* expose a RemoteObject getter ([#8642](https://github.com/puppeteer/puppeteer/issues/8642)) ([d0c4291](https://github.com/puppeteer/puppeteer/commit/d0c42919956bd36ad7993a0fc1de86e886e39f62)), closes [#8639](https://github.com/puppeteer/puppeteer/issues/8639) +* **page:** fix page.#scrollIntoViewIfNeeded method ([#8631](https://github.com/puppeteer/puppeteer/issues/8631)) ([b47f066](https://github.com/puppeteer/puppeteer/commit/b47f066c2c068825e3b65cfe17b6923c77ad30b9)) + +## [15.3.1](https://github.com/puppeteer/puppeteer/compare/v15.3.0...v15.3.1) (2022-07-06) + + +### Bug Fixes + +* extends `ElementHandle` to `Node`s ([#8552](https://github.com/puppeteer/puppeteer/issues/8552)) ([5ff205d](https://github.com/puppeteer/puppeteer/commit/5ff205dc8b659eb8864b4b1862105d21dd334c8f)) + +## [15.3.0](https://github.com/puppeteer/puppeteer/compare/v15.2.0...v15.3.0) (2022-07-01) + + +### Features + +* add documentation ([#8593](https://github.com/puppeteer/puppeteer/issues/8593)) ([066f440](https://github.com/puppeteer/puppeteer/commit/066f440ba7bdc9aca9423d7205adf36f2858bd78)) + + +### Bug Fixes + +* remove unused imports ([#8613](https://github.com/puppeteer/puppeteer/issues/8613)) ([0cf4832](https://github.com/puppeteer/puppeteer/commit/0cf4832878731ffcfc84570315f326eb851d7629)) + +## [15.2.0](https://github.com/puppeteer/puppeteer/compare/v15.1.1...v15.2.0) (2022-06-29) + + +### Features + +* add fromSurface option to page.screenshot ([#8496](https://github.com/puppeteer/puppeteer/issues/8496)) ([79e1198](https://github.com/puppeteer/puppeteer/commit/79e11985ba44b72b1ad6b8cd861fe316f1945e64)) +* export public types only ([#8584](https://github.com/puppeteer/puppeteer/issues/8584)) ([7001322](https://github.com/puppeteer/puppeteer/commit/7001322cd1cf9f77ee2c370d50a6707e7aaad72d)) + + +### Bug Fixes + +* clean up tmp profile dirs when browser is closed ([#8580](https://github.com/puppeteer/puppeteer/issues/8580)) ([9787a1d](https://github.com/puppeteer/puppeteer/commit/9787a1d8df7768017b36d42327faab402695c4bb)) + +## [15.1.1](https://github.com/puppeteer/puppeteer/compare/v15.1.0...v15.1.1) (2022-06-25) + + +### Bug Fixes + +* export `ElementHandle` ([e0198a7](https://github.com/puppeteer/puppeteer/commit/e0198a79e06c8bb72dde554db0246a3db5fec4c2)) + +## [15.1.0](https://github.com/puppeteer/puppeteer/compare/v15.0.2...v15.1.0) (2022-06-24) + + +### Features + +* **chromium:** roll to Chromium 104.0.5109.0 (r1011831) ([#8569](https://github.com/puppeteer/puppeteer/issues/8569)) ([fb7d31e](https://github.com/puppeteer/puppeteer/commit/fb7d31e3698428560e1f654d33782d241192f48f)) + +## [15.0.2](https://github.com/puppeteer/puppeteer/compare/v15.0.1...v15.0.2) (2022-06-24) + + +### Bug Fixes + +* CSS coverage should work with empty stylesheets ([#8570](https://github.com/puppeteer/puppeteer/issues/8570)) ([383e855](https://github.com/puppeteer/puppeteer/commit/383e8558477fae7708734ab2160ef50f385e2983)), closes [#8535](https://github.com/puppeteer/puppeteer/issues/8535) + +## [15.0.1](https://github.com/puppeteer/puppeteer/compare/v15.0.0...v15.0.1) (2022-06-24) + + +### Bug Fixes + +* infer unioned handles ([#8562](https://github.com/puppeteer/puppeteer/issues/8562)) ([8100cbb](https://github.com/puppeteer/puppeteer/commit/8100cbb29569541541f61001983efb9a80d89890)) + +## [15.0.0](https://github.com/puppeteer/puppeteer/compare/v14.4.1...v15.0.0) (2022-06-23) + + +### ⚠ BREAKING CHANGES + +* type inference for evaluation types (#8547) + +### Features + +* add experimental `client` to `HTTPRequest` ([#8556](https://github.com/puppeteer/puppeteer/issues/8556)) ([ec79f3a](https://github.com/puppeteer/puppeteer/commit/ec79f3a58a44c9ea60a82f9cd2df4c8f19e82ab8)) +* type inference for evaluation types ([#8547](https://github.com/puppeteer/puppeteer/issues/8547)) ([26c3acb](https://github.com/puppeteer/puppeteer/commit/26c3acbb0795eb66f29479f442e156832f794f01)) + +## [14.4.1](https://github.com/puppeteer/puppeteer/compare/v14.4.0...v14.4.1) (2022-06-17) + + +### Bug Fixes + +* avoid `instanceof Object` check in `isErrorLike` ([#8527](https://github.com/puppeteer/puppeteer/issues/8527)) ([6cd5cd0](https://github.com/puppeteer/puppeteer/commit/6cd5cd043997699edca6e3458f90adc1118cf4a5)) +* export `devices`, `errors`, and more ([cba58a1](https://github.com/puppeteer/puppeteer/commit/cba58a12c4e2043f6a5acf7d4754e4a7b7f6e198)) + +## [14.4.0](https://github.com/puppeteer/puppeteer/compare/v14.3.0...v14.4.0) (2022-06-13) + + +### Features + +* export puppeteer methods ([#8493](https://github.com/puppeteer/puppeteer/issues/8493)) ([465a7c4](https://github.com/puppeteer/puppeteer/commit/465a7c405f01fcef99380ffa69d86042a1f5618f)) +* support node-like environments ([#8490](https://github.com/puppeteer/puppeteer/issues/8490)) ([f64ec20](https://github.com/puppeteer/puppeteer/commit/f64ec2051b9b2d12225abba6ffe9551da9751bf7)) + + +### Bug Fixes + +* parse empty options in \<select\> ([#8489](https://github.com/puppeteer/puppeteer/issues/8489)) ([b30f3f4](https://github.com/puppeteer/puppeteer/commit/b30f3f44cdabd9545c4661cd755b9d49e5c144cd)) +* use error-like ([#8504](https://github.com/puppeteer/puppeteer/issues/8504)) ([4d35990](https://github.com/puppeteer/puppeteer/commit/4d359906a44e4ddd5ec54a523cfd9076048d3433)) +* use OS-independent abs. path check ([#8505](https://github.com/puppeteer/puppeteer/issues/8505)) ([bfd4e68](https://github.com/puppeteer/puppeteer/commit/bfd4e68f25bec6e00fd5cbf261813f8297d362ee)) + +## [14.3.0](https://github.com/puppeteer/puppeteer/compare/v14.2.1...v14.3.0) (2022-06-07) + + +### Features + +* use absolute URL for EVALUATION_SCRIPT_URL ([#8481](https://github.com/puppeteer/puppeteer/issues/8481)) ([e142560](https://github.com/puppeteer/puppeteer/commit/e14256010d2d84d613cd3c6e7999b0705115d4bf)), closes [#8424](https://github.com/puppeteer/puppeteer/issues/8424) + + +### Bug Fixes + +* don't throw on bad access ([#8472](https://github.com/puppeteer/puppeteer/issues/8472)) ([e837866](https://github.com/puppeteer/puppeteer/commit/e8378666c671e5703aec4f52912de2aac94e1828)) +* Kill browser process when killing process group fails ([#8477](https://github.com/puppeteer/puppeteer/issues/8477)) ([7dc8e37](https://github.com/puppeteer/puppeteer/commit/7dc8e37a23d025bb2c31efb9c060c7f6e00179b4)) +* only lookup `localhost` for DNS lookups ([1b025b4](https://github.com/puppeteer/puppeteer/commit/1b025b4c8466fe64da0fa2050eaa02b7764770b1)) +* robustly check for launch executable ([#8468](https://github.com/puppeteer/puppeteer/issues/8468)) ([b54dc55](https://github.com/puppeteer/puppeteer/commit/b54dc55f7622ee2b75afd3bd9fe118dd2f144f40)) + +## [14.2.1](https://github.com/puppeteer/puppeteer/compare/v14.2.0...v14.2.1) (2022-06-02) + + +### Bug Fixes + +* use isPageTargetCallback in Browser::pages() ([#8460](https://github.com/puppeteer/puppeteer/issues/8460)) ([5c9050a](https://github.com/puppeteer/puppeteer/commit/5c9050aea0fe8d57114130fe38bd33ed2b4955d6)) + +## [14.2.0](https://github.com/puppeteer/puppeteer/compare/v14.1.2...v14.2.0) (2022-06-01) + + +### Features + +* **chromium:** roll to Chromium 103.0.5059.0 (r1002410) ([#8410](https://github.com/puppeteer/puppeteer/issues/8410)) ([54efc2c](https://github.com/puppeteer/puppeteer/commit/54efc2c949be1d6ef22f4d2630620e33d14d2597)) +* support node 18 ([#8447](https://github.com/puppeteer/puppeteer/issues/8447)) ([f2d8276](https://github.com/puppeteer/puppeteer/commit/f2d8276d6e745a7547b8ce54c3f50934bb70de0b)) +* use strict typescript ([#8401](https://github.com/puppeteer/puppeteer/issues/8401)) ([b4e751f](https://github.com/puppeteer/puppeteer/commit/b4e751f29cb6fd4c3cc41fe702de83721f0eb6dc)) + + +### Bug Fixes + +* multiple same request event listener ([#8404](https://github.com/puppeteer/puppeteer/issues/8404)) ([9211015](https://github.com/puppeteer/puppeteer/commit/92110151d9a33f26abc07bc805f4f2f3943697a0)) +* NodeNext incompatibility in package.json ([#8445](https://github.com/puppeteer/puppeteer/issues/8445)) ([c4898a7](https://github.com/puppeteer/puppeteer/commit/c4898a7a2e69681baac55366848da6688f0d8790)) +* process documentation during publishing ([#8433](https://github.com/puppeteer/puppeteer/issues/8433)) ([d111d19](https://github.com/puppeteer/puppeteer/commit/d111d19f788d88d984dcf4ad7542f59acd2f4c1e)) + +## [14.1.2](https://github.com/puppeteer/puppeteer/compare/v14.1.1...v14.1.2) (2022-05-30) + + +### Bug Fixes + +* do not use loaderId for lifecycle events ([#8395](https://github.com/puppeteer/puppeteer/issues/8395)) ([c96c915](https://github.com/puppeteer/puppeteer/commit/c96c915b535dcf414038677bd3d3ed6b980a4901)) +* fix release-please bot ([#8400](https://github.com/puppeteer/puppeteer/issues/8400)) ([5c235c7](https://github.com/puppeteer/puppeteer/commit/5c235c701fc55380f09d09ac2cf63f2c94b60e3d)) +* use strict TS in Input.ts ([#8392](https://github.com/puppeteer/puppeteer/issues/8392)) ([af92a24](https://github.com/puppeteer/puppeteer/commit/af92a24ba9fc8efea1ba41f96d87515cf760da65)) + +### [14.1.1](https://github.com/puppeteer/puppeteer/compare/v14.1.0...v14.1.1) (2022-05-19) + + +### Bug Fixes + +* kill browser process when 'taskkill' fails on Windows ([#8352](https://github.com/puppeteer/puppeteer/issues/8352)) ([dccfadb](https://github.com/puppeteer/puppeteer/commit/dccfadb90e8947cae3f33d7a209b6f5752f97b46)) +* only check loading iframe in lifecycling ([#8348](https://github.com/puppeteer/puppeteer/issues/8348)) ([7438030](https://github.com/puppeteer/puppeteer/commit/74380303ac6cc6e2d84948a10920d56e665ccebe)) +* recompile before funit and unit commands ([#8363](https://github.com/puppeteer/puppeteer/issues/8363)) ([8735b78](https://github.com/puppeteer/puppeteer/commit/8735b784ba7838c1002b521a7f9f23bb27263d03)), closes [#8362](https://github.com/puppeteer/puppeteer/issues/8362) + +## [14.1.0](https://github.com/puppeteer/puppeteer/compare/v14.0.0...v14.1.0) (2022-05-13) + + +### Features + +* add waitForXPath to ElementHandle ([#8329](https://github.com/puppeteer/puppeteer/issues/8329)) ([7eaadaf](https://github.com/puppeteer/puppeteer/commit/7eaadafe197279a7d1753e7274d2e24dfc11abdf)) +* allow handling other targets as pages internally ([#8336](https://github.com/puppeteer/puppeteer/issues/8336)) ([3b66a2c](https://github.com/puppeteer/puppeteer/commit/3b66a2c47ee36785a6a72c9afedd768fab3d040a)) + + +### Bug Fixes + +* disable AvoidUnnecessaryBeforeUnloadCheckSync to fix navigations ([#8330](https://github.com/puppeteer/puppeteer/issues/8330)) ([4854ad5](https://github.com/puppeteer/puppeteer/commit/4854ad5b15c9bdf93c06dcb758393e7cbacd7469)) +* If currentNode and root are the same, do not include them in the result ([#8332](https://github.com/puppeteer/puppeteer/issues/8332)) ([a61144d](https://github.com/puppeteer/puppeteer/commit/a61144d43780b5c32197427d7682b9b6c433f2bb)) + +## [14.0.0](https://github.com/puppeteer/puppeteer/compare/v13.7.0...v14.0.0) (2022-05-09) + + +### ⚠ BREAKING CHANGES + +* strict mode fixes for HTTPRequest/Response classes (#8297) +* Node 12 is no longer supported. + +### Features + +* add support for Apple Silicon chromium builds ([#7546](https://github.com/puppeteer/puppeteer/issues/7546)) ([baa017d](https://github.com/puppeteer/puppeteer/commit/baa017db92b1fecf2e3584d5b3161371ae60f55b)), closes [#6622](https://github.com/puppeteer/puppeteer/issues/6622) +* **chromium:** roll to Chromium 102.0.5002.0 (r991974) ([#8319](https://github.com/puppeteer/puppeteer/issues/8319)) ([be4c930](https://github.com/puppeteer/puppeteer/commit/be4c930c60164f681a966d0f8cb745f6c263fe2b)) +* support ES modules ([#8306](https://github.com/puppeteer/puppeteer/issues/8306)) ([6841bd6](https://github.com/puppeteer/puppeteer/commit/6841bd68d85e3b3952c5e7ce454ac4d23f84262d)) + + +### Bug Fixes + +* apparent typo SUPPORTER_PLATFORMS ([#8294](https://github.com/puppeteer/puppeteer/issues/8294)) ([e09287f](https://github.com/puppeteer/puppeteer/commit/e09287f4e9a1ff3c637dd165d65f221394970e2c)) +* make sure inner OOPIFs can be attached to ([#8304](https://github.com/puppeteer/puppeteer/issues/8304)) ([5539598](https://github.com/puppeteer/puppeteer/commit/553959884f4edb4deab760fa8ca38fc1c85c05c5)) +* strict mode fixes for HTTPRequest/Response classes ([#8297](https://github.com/puppeteer/puppeteer/issues/8297)) ([2804ae8](https://github.com/puppeteer/puppeteer/commit/2804ae8cdbc4c90bf942510bce656275a2d409e1)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) +* tests failing in headful ([#8273](https://github.com/puppeteer/puppeteer/issues/8273)) ([e841d7f](https://github.com/puppeteer/puppeteer/commit/e841d7f9f3f407c02dbc48e107b545b91db104e6)) + + +* drop Node 12 support ([#8299](https://github.com/puppeteer/puppeteer/issues/8299)) ([274bd6b](https://github.com/puppeteer/puppeteer/commit/274bd6b3b98c305ed014909d8053e4c54187971b)) + +## [13.7.0](https://github.com/puppeteer/puppeteer/compare/v13.6.0...v13.7.0) (2022-04-28) + + +### Features + +* add `back` and `forward` mouse buttons ([#8284](https://github.com/puppeteer/puppeteer/issues/8284)) ([7a51bff](https://github.com/puppeteer/puppeteer/commit/7a51bff47f6436fc29d0df7eb74f12f69102ca5b)) +* support chrome headless mode ([#8260](https://github.com/puppeteer/puppeteer/issues/8260)) ([1308d9a](https://github.com/puppeteer/puppeteer/commit/1308d9aa6a5920b20da02dca8db03c63e43c8b84)) + + +### Bug Fixes + +* doc typo ([#8263](https://github.com/puppeteer/puppeteer/issues/8263)) ([952a2ae](https://github.com/puppeteer/puppeteer/commit/952a2ae0bc4f059f8e8b4d1de809d0a486a74551)) +* use different test names for browser specific tests in launcher.spec.ts ([#8250](https://github.com/puppeteer/puppeteer/issues/8250)) ([c6cf1a9](https://github.com/puppeteer/puppeteer/commit/c6cf1a9f27621c8a619cfbdc9d0821541768ac94)) + +## [13.6.0](https://github.com/puppeteer/puppeteer/compare/v13.5.2...v13.6.0) (2022-04-19) + + +### Features + +* **chromium:** roll to Chromium 101.0.4950.0 (r982053) ([#8213](https://github.com/puppeteer/puppeteer/issues/8213)) ([ec74bd8](https://github.com/puppeteer/puppeteer/commit/ec74bd811d9b7fbaf600068e86f13a63d7b0bc6f)) +* respond multiple headers with same key ([#8183](https://github.com/puppeteer/puppeteer/issues/8183)) ([c1dcd85](https://github.com/puppeteer/puppeteer/commit/c1dcd857e3bc17769f02474a41bbedee01f471dc)) + + +### Bug Fixes + +* also kill Firefox when temporary profile is used ([#8233](https://github.com/puppeteer/puppeteer/issues/8233)) ([b6504d7](https://github.com/puppeteer/puppeteer/commit/b6504d7186336a2fc0b41c3878c843b7409ba5fb)) +* consider existing frames when waiting for a frame ([#8200](https://github.com/puppeteer/puppeteer/issues/8200)) ([0955225](https://github.com/puppeteer/puppeteer/commit/0955225b51421663288523a3dfb63103b51775b4)) +* disable bfcache in the launcher ([#8196](https://github.com/puppeteer/puppeteer/issues/8196)) ([9ac7318](https://github.com/puppeteer/puppeteer/commit/9ac7318506ac858b3465e9b4ede8ad75fbbcee11)), closes [#8182](https://github.com/puppeteer/puppeteer/issues/8182) +* enable page.spec event handler test for firefox ([#8214](https://github.com/puppeteer/puppeteer/issues/8214)) ([2b45027](https://github.com/puppeteer/puppeteer/commit/2b45027d256f85f21a0c824183696b237e00ad33)) +* forget queuedEventGroup when emitting response in responseReceivedExtraInfo ([#8234](https://github.com/puppeteer/puppeteer/issues/8234)) ([#8239](https://github.com/puppeteer/puppeteer/issues/8239)) ([91a8e73](https://github.com/puppeteer/puppeteer/commit/91a8e73b1196e4128b1e7c25e08080f2faaf3cf7)) +* forget request will be sent from the _requestWillBeSentMap list. ([#8226](https://github.com/puppeteer/puppeteer/issues/8226)) ([4b786c9](https://github.com/puppeteer/puppeteer/commit/4b786c904cbfe3f059322292f3b788b8a5ebd9bf)) +* ignore favicon requests in page.spec event handler tests ([#8208](https://github.com/puppeteer/puppeteer/issues/8208)) ([04e5c88](https://github.com/puppeteer/puppeteer/commit/04e5c889973432c6163a8539cdec23c0e8726bff)) +* **network.spec.ts:** typo in the word should ([#8223](https://github.com/puppeteer/puppeteer/issues/8223)) ([e93faad](https://github.com/puppeteer/puppeteer/commit/e93faadc21b7fcb1e03b69c451c28b769f9cde51)) + +### [13.5.2](https://github.com/puppeteer/puppeteer/compare/v13.5.1...v13.5.2) (2022-03-31) + + +### Bug Fixes + +* chromium downloading hung at 99% ([#8169](https://github.com/puppeteer/puppeteer/issues/8169)) ([8f13470](https://github.com/puppeteer/puppeteer/commit/8f13470af06045857f32496f03e77b14f3ecff98)) +* get extra headers from Fetch.requestPaused event ([#8162](https://github.com/puppeteer/puppeteer/issues/8162)) ([37ede68](https://github.com/puppeteer/puppeteer/commit/37ede6877017a8dc6c946a3dff4ec6d79c3ebc59)) + +### [13.5.1](https://github.com/puppeteer/puppeteer/compare/v13.5.0...v13.5.1) (2022-03-09) + + +### Bug Fixes + +* waitForNavigation in OOPIFs ([#8117](https://github.com/puppeteer/puppeteer/issues/8117)) ([34775e5](https://github.com/puppeteer/puppeteer/commit/34775e58316be49d8bc5a13209a1f570bc66b448)) + +## [13.5.0](https://github.com/puppeteer/puppeteer/compare/v13.4.1...v13.5.0) (2022-03-07) + + +### Features + +* **chromium:** roll to Chromium 100.0.4889.0 (r970485) ([#8108](https://github.com/puppeteer/puppeteer/issues/8108)) ([d12f427](https://github.com/puppeteer/puppeteer/commit/d12f42754f7013b5ec0a2198cf2d9cf945d3cb38)) + + +### Bug Fixes + +* Inherit browser-level proxy settings from incognito context ([#7770](https://github.com/puppeteer/puppeteer/issues/7770)) ([3feca32](https://github.com/puppeteer/puppeteer/commit/3feca325a9472ee36f7e866ebe375c7f083e0e36)) +* **page:** page.createIsolatedWorld error catching has been added ([#7848](https://github.com/puppeteer/puppeteer/issues/7848)) ([309e8b8](https://github.com/puppeteer/puppeteer/commit/309e8b80da0519327bc37b44a3ebb6f2e2d357a7)) +* **tests:** ensure all tests honour BINARY envvar ([#8092](https://github.com/puppeteer/puppeteer/issues/8092)) ([3b8b9ad](https://github.com/puppeteer/puppeteer/commit/3b8b9adde5d18892af96329b6f9303979f9c04f5)) + +### [13.4.1](https://github.com/puppeteer/puppeteer/compare/v13.4.0...v13.4.1) (2022-03-01) + + +### Bug Fixes + +* regression in --user-data-dir handling ([#8060](https://github.com/puppeteer/puppeteer/issues/8060)) ([85decdc](https://github.com/puppeteer/puppeteer/commit/85decdc28d7d2128e6d2946a72f4d99dd5dbb48a)) + +## [13.4.0](https://github.com/puppeteer/puppeteer/compare/v13.3.2...v13.4.0) (2022-02-22) + + +### Features + +* add support for async waitForTarget ([#7885](https://github.com/puppeteer/puppeteer/issues/7885)) ([dbf0639](https://github.com/puppeteer/puppeteer/commit/dbf0639822d0b2736993de52c0bfe1dbf4e58f25)) +* export `Frame._client` through getter ([#8041](https://github.com/puppeteer/puppeteer/issues/8041)) ([e9278fc](https://github.com/puppeteer/puppeteer/commit/e9278fcfcffe2558de63ce7542483445bcb6e74f)) +* **HTTPResponse:** expose timing information ([#8025](https://github.com/puppeteer/puppeteer/issues/8025)) ([30b3d49](https://github.com/puppeteer/puppeteer/commit/30b3d49b0de46d812b7485e708174a07c73dbdd0)) + + +### Bug Fixes + +* change kill to signal the whole process group to terminate ([#6859](https://github.com/puppeteer/puppeteer/issues/6859)) ([0eb9c78](https://github.com/puppeteer/puppeteer/commit/0eb9c7861717ebba7012c03e76b7a46063e4e5dd)) +* element screenshot issue in headful mode ([#8018](https://github.com/puppeteer/puppeteer/issues/8018)) ([5346e70](https://github.com/puppeteer/puppeteer/commit/5346e70ffc15b33c1949657cf1b465f1acc5d84d)), closes [#7999](https://github.com/puppeteer/puppeteer/issues/7999) +* ensure dom binding is not called after detach ([#8024](https://github.com/puppeteer/puppeteer/issues/8024)) ([5c308b0](https://github.com/puppeteer/puppeteer/commit/5c308b0704123736ddb085f97596c201ea18cf4a)), closes [#7814](https://github.com/puppeteer/puppeteer/issues/7814) +* use both __dirname and require.resolve to support different bundlers ([#8046](https://github.com/puppeteer/puppeteer/issues/8046)) ([e6a6295](https://github.com/puppeteer/puppeteer/commit/e6a6295d9a7480bb59ee58a2cc7785171fa0fa2c)), closes [#8044](https://github.com/puppeteer/puppeteer/issues/8044) + +### [13.3.2](https://github.com/puppeteer/puppeteer/compare/v13.3.1...v13.3.2) (2022-02-14) + + +### Bug Fixes + +* always use ENV executable path when present ([#7985](https://github.com/puppeteer/puppeteer/issues/7985)) ([6d6ea9b](https://github.com/puppeteer/puppeteer/commit/6d6ea9bf59daa3fb851b3da8baa27887e0aa2c28)) +* use require.resolve instead of __dirname ([#8003](https://github.com/puppeteer/puppeteer/issues/8003)) ([bbb186d](https://github.com/puppeteer/puppeteer/commit/bbb186d88cb99e4914299c983c822fa41a80f356)) + +### [13.3.1](https://github.com/puppeteer/puppeteer/compare/v13.3.0...v13.3.1) (2022-02-10) + + +### Bug Fixes + +* **puppeteer:** revert: esm modules ([#7986](https://github.com/puppeteer/puppeteer/issues/7986)) ([179eded](https://github.com/puppeteer/puppeteer/commit/179ededa1400c35c1f2edc015548e0f2a1bcee14)) + +## [13.3.0](https://github.com/puppeteer/puppeteer/compare/v13.2.0...v13.3.0) (2022-02-09) + + +### Features + +* **puppeteer:** export esm modules in package.json ([#7964](https://github.com/puppeteer/puppeteer/issues/7964)) ([523b487](https://github.com/puppeteer/puppeteer/commit/523b487e8802824cecff86d256b4f7dbc4c47c8a)) + +## [13.2.0](https://github.com/puppeteer/puppeteer/compare/v13.1.3...v13.2.0) (2022-02-07) + + +### Features + +* add more models to DeviceDescriptors ([#7904](https://github.com/puppeteer/puppeteer/issues/7904)) ([6a655cb](https://github.com/puppeteer/puppeteer/commit/6a655cb647e12eaf1055be0b298908d83bebac25)) +* **chromium:** roll to Chromium 99.0.4844.16 (r961656) ([#7960](https://github.com/puppeteer/puppeteer/issues/7960)) ([96c3f94](https://github.com/puppeteer/puppeteer/commit/96c3f943b2f6e26bd871ecfcce71b6a33e214ebf)) + + +### Bug Fixes + +* make projectRoot optional in Puppeteer and launchers ([#7967](https://github.com/puppeteer/puppeteer/issues/7967)) ([9afdc63](https://github.com/puppeteer/puppeteer/commit/9afdc6300b80f01091dc4cb42d4ebe952c7d60f0)) +* migrate more files to strict-mode TypeScript ([#7950](https://github.com/puppeteer/puppeteer/issues/7950)) ([aaac8d9](https://github.com/puppeteer/puppeteer/commit/aaac8d9c44327a2c503ffd6c97b7f21e8010c3e4)) +* typos in documentation ([#7968](https://github.com/puppeteer/puppeteer/issues/7968)) ([41ab4e9](https://github.com/puppeteer/puppeteer/commit/41ab4e9127df64baa6c43ecde2f7ddd702ba7b0c)) + +### [13.1.3](https://github.com/puppeteer/puppeteer/compare/v13.1.2...v13.1.3) (2022-01-31) + + +### Bug Fixes + +* issue with reading versions.js in doclint ([#7940](https://github.com/puppeteer/puppeteer/issues/7940)) ([06ba963](https://github.com/puppeteer/puppeteer/commit/06ba9632a4c63859244068d32c312817d90daf63)) +* make more files work in strict-mode TypeScript ([#7936](https://github.com/puppeteer/puppeteer/issues/7936)) ([0636513](https://github.com/puppeteer/puppeteer/commit/0636513e34046f4d40b5e88beb2b18b16dab80aa)) +* page.pdf producing an invalid pdf ([#7868](https://github.com/puppeteer/puppeteer/issues/7868)) ([afea509](https://github.com/puppeteer/puppeteer/commit/afea509544fb99bfffe5b0bebe6f3575c53802f0)), closes [#7757](https://github.com/puppeteer/puppeteer/issues/7757) + +### [13.1.2](https://github.com/puppeteer/puppeteer/compare/v13.1.1...v13.1.2) (2022-01-25) + + +### Bug Fixes + +* **package.json:** update node-fetch package ([#7924](https://github.com/puppeteer/puppeteer/issues/7924)) ([e4c48d3](https://github.com/puppeteer/puppeteer/commit/e4c48d3b8c2a812752094ed8163e4f2f32c4b6cb)) +* types in Browser.ts to be compatible with strict mode Typescript ([#7918](https://github.com/puppeteer/puppeteer/issues/7918)) ([a8ec0aa](https://github.com/puppeteer/puppeteer/commit/a8ec0aadc9c90d224d568d9e418d14261e6e85b1)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) +* types in Connection.ts to be compatible with strict mode Typescript ([#7919](https://github.com/puppeteer/puppeteer/issues/7919)) ([d80d602](https://github.com/puppeteer/puppeteer/commit/d80d6027ea8e1b7fcdaf045398629cf8e6512658)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) + +### [13.1.1](https://github.com/puppeteer/puppeteer/compare/v13.1.0...v13.1.1) (2022-01-18) + + +### Bug Fixes + +* use content box for OOPIF offset calculations ([#7911](https://github.com/puppeteer/puppeteer/issues/7911)) ([344feb5](https://github.com/puppeteer/puppeteer/commit/344feb53c28ce018a4c600d408468f6d9d741eee)) + +## [13.1.0](https://github.com/puppeteer/puppeteer/compare/v13.0.1...v13.1.0) (2022-01-17) + + +### Features + +* **chromium:** roll to Chromium 98.0.4758.0 (r950341) ([#7907](https://github.com/puppeteer/puppeteer/issues/7907)) ([a55c86f](https://github.com/puppeteer/puppeteer/commit/a55c86fac504b5e89ba23735fb3a1b1d54a4e1e5)) + + +### Bug Fixes + +* apply OOPIF offsets to bounding box and box model calls ([#7906](https://github.com/puppeteer/puppeteer/issues/7906)) ([a566263](https://github.com/puppeteer/puppeteer/commit/a566263ba28e58ff648bffbdb628606f75d5876f)) +* correctly compute clickable points for elements inside OOPIFs ([#7900](https://github.com/puppeteer/puppeteer/issues/7900)) ([486bbe0](https://github.com/puppeteer/puppeteer/commit/486bbe010d5ee5c446d9e8daf61a080232379c3f)), closes [#7849](https://github.com/puppeteer/puppeteer/issues/7849) +* error for pre-existing OOPIFs ([#7899](https://github.com/puppeteer/puppeteer/issues/7899)) ([d7937b8](https://github.com/puppeteer/puppeteer/commit/d7937b806d331bf16c2016aaf16e932b1334eac8)), closes [#7844](https://github.com/puppeteer/puppeteer/issues/7844) [#7896](https://github.com/puppeteer/puppeteer/issues/7896) + +### [13.0.1](https://github.com/puppeteer/puppeteer/compare/v13.0.0...v13.0.1) (2021-12-22) + + +### Bug Fixes + +* disable a test failing on Firefox ([#7846](https://github.com/puppeteer/puppeteer/issues/7846)) ([36207c5](https://github.com/puppeteer/puppeteer/commit/36207c5efe8ca21f4b3fc5b00212700326a701d2)) +* make sure ElementHandle.waitForSelector is evaluated in the right context ([#7843](https://github.com/puppeteer/puppeteer/issues/7843)) ([8d8e874](https://github.com/puppeteer/puppeteer/commit/8d8e874b072b17fc763f33d08e51c046b7435244)) +* predicate arguments for waitForFunction ([#7845](https://github.com/puppeteer/puppeteer/issues/7845)) ([1c44551](https://github.com/puppeteer/puppeteer/commit/1c44551f1b5bb19455b4a1eb7061715717ec880e)), closes [#7836](https://github.com/puppeteer/puppeteer/issues/7836) + +## [13.0.0](https://github.com/puppeteer/puppeteer/compare/v12.0.1...v13.0.0) (2021-12-10) + + +### ⚠ BREAKING CHANGES + +* typo in 'already-handled' constant of the request interception API (#7813) + +### Features + +* expose HTTPRequest intercept resolution state and clarify docs ([#7796](https://github.com/puppeteer/puppeteer/issues/7796)) ([dc23b75](https://github.com/puppeteer/puppeteer/commit/dc23b7535cb958c00d1eecfe85b4ee26e52e2e39)) +* implement Element.waitForSelector ([#7825](https://github.com/puppeteer/puppeteer/issues/7825)) ([c034294](https://github.com/puppeteer/puppeteer/commit/c03429444d05b39549489ad3da67d93b2be59f51)) + + +### Bug Fixes + +* handle multiple/duplicate Fetch.requestPaused events ([#7802](https://github.com/puppeteer/puppeteer/issues/7802)) ([636b086](https://github.com/puppeteer/puppeteer/commit/636b0863a169da132e333eb53b17eb2601daabe6)), closes [#7475](https://github.com/puppeteer/puppeteer/issues/7475) [#6696](https://github.com/puppeteer/puppeteer/issues/6696) [#7225](https://github.com/puppeteer/puppeteer/issues/7225) +* revert "feat(typescript): allow using puppeteer without dom lib" ([02c9af6](https://github.com/puppeteer/puppeteer/commit/02c9af62d64060a83f53368640f343ae2e30e38a)), closes [#6998](https://github.com/puppeteer/puppeteer/issues/6998) +* typo in 'already-handled' constant of the request interception API ([#7813](https://github.com/puppeteer/puppeteer/issues/7813)) ([8242422](https://github.com/puppeteer/puppeteer/commit/824242246de9e158aacb85f71350a79cb386ed92)), closes [#7745](https://github.com/puppeteer/puppeteer/issues/7745) [#7747](https://github.com/puppeteer/puppeteer/issues/7747) [#7780](https://github.com/puppeteer/puppeteer/issues/7780) + +### [12.0.1](https://github.com/puppeteer/puppeteer/compare/v12.0.0...v12.0.1) (2021-11-29) + + +### Bug Fixes + +* handle extraInfo events even if event.hasExtraInfo === false ([#7808](https://github.com/puppeteer/puppeteer/issues/7808)) ([6ee2feb](https://github.com/puppeteer/puppeteer/commit/6ee2feb1eafdd399f0af50cdc4517f21bcb55121)), closes [#7805](https://github.com/puppeteer/puppeteer/issues/7805) + +## [12.0.0](https://github.com/puppeteer/puppeteer/compare/v11.0.0...v12.0.0) (2021-11-26) + + +### ⚠ BREAKING CHANGES + +* **chromium:** roll to Chromium 97.0.4692.0 (r938248) + +### Features + +* **chromium:** roll to Chromium 97.0.4692.0 (r938248) ([ac162c5](https://github.com/puppeteer/puppeteer/commit/ac162c561ee43dd69eff38e1b354a41bb42c9eba)), closes [#7458](https://github.com/puppeteer/puppeteer/issues/7458) +* support for custom user data (profile) directory for Firefox ([#7684](https://github.com/puppeteer/puppeteer/issues/7684)) ([790c7a0](https://github.com/puppeteer/puppeteer/commit/790c7a0eb92291efebaa37e80c72f5cb5f46bbdb)) + + +### Bug Fixes + +* **ariaqueryhandler:** allow single quotes in aria attribute selector ([#7750](https://github.com/puppeteer/puppeteer/issues/7750)) ([b0319ec](https://github.com/puppeteer/puppeteer/commit/b0319ecc89f8ea3d31ab9aee5e1cd33d2a4e62be)), closes [#7721](https://github.com/puppeteer/puppeteer/issues/7721) +* clearer jsdoc for behavior of `headless` when `devtools` is true ([#7748](https://github.com/puppeteer/puppeteer/issues/7748)) ([9f9b4ed](https://github.com/puppeteer/puppeteer/commit/9f9b4ed72ab0bb43d002a0024122d6f5eab231aa)) +* null check for frame in FrameManager ([#7773](https://github.com/puppeteer/puppeteer/issues/7773)) ([23ee295](https://github.com/puppeteer/puppeteer/commit/23ee295f348d114617f2a86d0bb792936f413ac5)), closes [#7749](https://github.com/puppeteer/puppeteer/issues/7749) +* only kill the process when there is no browser instance available ([#7762](https://github.com/puppeteer/puppeteer/issues/7762)) ([51e6169](https://github.com/puppeteer/puppeteer/commit/51e61696c1c20cc09bd4fc068ae1dfa259c41745)), closes [#7668](https://github.com/puppeteer/puppeteer/issues/7668) +* parse statusText from the extraInfo event ([#7798](https://github.com/puppeteer/puppeteer/issues/7798)) ([a26b12b](https://github.com/puppeteer/puppeteer/commit/a26b12b7c775c36271cd4c98e39bbd59f4356320)), closes [#7458](https://github.com/puppeteer/puppeteer/issues/7458) +* try to remove the temporary user data directory after the process has been killed ([#7761](https://github.com/puppeteer/puppeteer/issues/7761)) ([fc94a28](https://github.com/puppeteer/puppeteer/commit/fc94a28778cfdb3cb8bcd882af3ebcdacf85c94e)) + +## [11.0.0](https://github.com/puppeteer/puppeteer/compare/v10.4.0...v11.0.0) (2021-11-02) + + +### ⚠ BREAKING CHANGES + +* **oop iframes:** integrate OOP iframes with the frame manager (#7556) + +### Features + +* improve error message for response.buffer() ([#7669](https://github.com/puppeteer/puppeteer/issues/7669)) ([03c9ecc](https://github.com/puppeteer/puppeteer/commit/03c9ecca400a02684cd60229550dbad1190a5b6e)) +* **oop iframes:** integrate OOP iframes with the frame manager ([#7556](https://github.com/puppeteer/puppeteer/issues/7556)) ([4d9dc8c](https://github.com/puppeteer/puppeteer/commit/4d9dc8c0e613f22d4cdf237e8bd0b0da3c588edb)), closes [#2548](https://github.com/puppeteer/puppeteer/issues/2548) +* add custom debugging port option ([#4993](https://github.com/puppeteer/puppeteer/issues/4993)) ([26145e9](https://github.com/puppeteer/puppeteer/commit/26145e9a24af7caed6ece61031f2cafa6abd505f)) +* add initiator to HTTPRequest ([#7614](https://github.com/puppeteer/puppeteer/issues/7614)) ([a271145](https://github.com/puppeteer/puppeteer/commit/a271145b0663ef9de1903dd0eb9fd5366465bed7)) +* allow to customize tmpdir ([#7243](https://github.com/puppeteer/puppeteer/issues/7243)) ([b1f6e86](https://github.com/puppeteer/puppeteer/commit/b1f6e8692b0bc7e8551b2a78169c830cd80a7acb)) +* handle unhandled promise rejections in tests ([#7722](https://github.com/puppeteer/puppeteer/issues/7722)) ([07febca](https://github.com/puppeteer/puppeteer/commit/07febca04b391893cfc872250e4391da142d4fe2)) + + +### Bug Fixes + +* add support for relative install paths to BrowserFetcher ([#7613](https://github.com/puppeteer/puppeteer/issues/7613)) ([eebf452](https://github.com/puppeteer/puppeteer/commit/eebf452d38b79bb2ea1a1ba84c3d2ea6f2f9f899)), closes [#7592](https://github.com/puppeteer/puppeteer/issues/7592) +* add webp to screenshot quality option allow list ([#7631](https://github.com/puppeteer/puppeteer/issues/7631)) ([b20c2bf](https://github.com/puppeteer/puppeteer/commit/b20c2bfa24cbdd4a1b9cefca2e0a9407e442baf5)) +* prevent Target closed errors on streams ([#7728](https://github.com/puppeteer/puppeteer/issues/7728)) ([5b792de](https://github.com/puppeteer/puppeteer/commit/5b792de7a97611441777d1ac99cb95516301d7dc)) +* request an animation frame to fix flaky clickablePoint test ([#7587](https://github.com/puppeteer/puppeteer/issues/7587)) ([7341d9f](https://github.com/puppeteer/puppeteer/commit/7341d9fadd1466a5b2f2bde8631f3b02cf9a7d8a)) +* setup husky properly ([#7727](https://github.com/puppeteer/puppeteer/issues/7727)) ([8b712e7](https://github.com/puppeteer/puppeteer/commit/8b712e7b642b58193437f26d4e104a9e412f388d)), closes [#7726](https://github.com/puppeteer/puppeteer/issues/7726) +* updated troubleshooting.md to meet latest dependencies changes ([#7656](https://github.com/puppeteer/puppeteer/issues/7656)) ([edb0197](https://github.com/puppeteer/puppeteer/commit/edb01972b9606d8b05b979a588eda0d622315981)) +* **launcher:** launcher.launch() should pass 'timeout' option [#5180](https://github.com/puppeteer/puppeteer/issues/5180) ([#7596](https://github.com/puppeteer/puppeteer/issues/7596)) ([113489d](https://github.com/puppeteer/puppeteer/commit/113489d3b58e2907374a4e6e5133bf46630695d1)) +* **page:** fallback to default in exposeFunction when using imported module ([#6365](https://github.com/puppeteer/puppeteer/issues/6365)) ([44c9ec6](https://github.com/puppeteer/puppeteer/commit/44c9ec67c57dccf3e186c86f14f3a8da9a8eb971)) +* **page:** fix page.off method for request event ([#7624](https://github.com/puppeteer/puppeteer/issues/7624)) ([d0cb943](https://github.com/puppeteer/puppeteer/commit/d0cb9436a302418086f6763e0e58ae3732a20b62)), closes [#7572](https://github.com/puppeteer/puppeteer/issues/7572) + +## [10.4.0](https://github.com/puppeteer/puppeteer/compare/v10.2.0...v10.4.0) (2021-09-21) + + +### Features + +* add webp to screenshot options ([#7565](https://github.com/puppeteer/puppeteer/issues/7565)) ([43a9268](https://github.com/puppeteer/puppeteer/commit/43a926832505a57922016907a264165676424557)) +* **page:** expose page.client() ([#7582](https://github.com/puppeteer/puppeteer/issues/7582)) ([99ca842](https://github.com/puppeteer/puppeteer/commit/99ca842124a1edef5e66426621885141a9feaca5)) +* **page:** mark page.client() as internal ([#7585](https://github.com/puppeteer/puppeteer/issues/7585)) ([8451951](https://github.com/puppeteer/puppeteer/commit/84519514831f304f9076ca235fe474f797616b2c)) +* add ability to specify offsets for JSHandle.click ([#7573](https://github.com/puppeteer/puppeteer/issues/7573)) ([2b5c001](https://github.com/puppeteer/puppeteer/commit/2b5c0019dc3744196c5858edeaa901dff9973ef5)) +* add durableStorage to allowed permissions ([#5295](https://github.com/puppeteer/puppeteer/issues/5295)) ([eda5171](https://github.com/puppeteer/puppeteer/commit/eda51712790b9260626dc53cfb58a72805c45582)) +* add id option to addScriptTag ([#5477](https://github.com/puppeteer/puppeteer/issues/5477)) ([300be5d](https://github.com/puppeteer/puppeteer/commit/300be5d167b6e7e532e725fdb86966081a5d0093)) +* add more Android models to DeviceDescriptors ([#7210](https://github.com/puppeteer/puppeteer/issues/7210)) ([b5020dc](https://github.com/puppeteer/puppeteer/commit/b5020dc04121b265c77662237dfb177d6de06053)), closes [/github.com/aerokube/moon-deploy/blob/master/moon-local.yaml#L199](https://github.com/puppeteer//github.com/aerokube/moon-deploy/blob/master/moon-local.yaml/issues/L199) +* add proxy and bypass list parameters to createIncognitoBrowserContext ([#7516](https://github.com/puppeteer/puppeteer/issues/7516)) ([8e45a1c](https://github.com/puppeteer/puppeteer/commit/8e45a1c882207cc36e87be2a917b661eb841c4bf)), closes [#678](https://github.com/puppeteer/puppeteer/issues/678) +* add threshold to Page.isIntersectingViewport ([#6497](https://github.com/puppeteer/puppeteer/issues/6497)) ([54c4318](https://github.com/puppeteer/puppeteer/commit/54c43180161c3c512e4698e7f2e85ce3c6f0ab50)) +* add unit test support for bisect ([#7553](https://github.com/puppeteer/puppeteer/issues/7553)) ([a0b1f6b](https://github.com/puppeteer/puppeteer/commit/a0b1f6b401abae2fbc5a8987061644adfaa7b482)) +* add User-Agent with Puppeteer version to WebSocket request ([#5614](https://github.com/puppeteer/puppeteer/issues/5614)) ([6a2bf0a](https://github.com/puppeteer/puppeteer/commit/6a2bf0aabaa4df72c7838f5a6cd742e8f9c72be6)) +* extend husky checks ([#7574](https://github.com/puppeteer/puppeteer/issues/7574)) ([7316086](https://github.com/puppeteer/puppeteer/commit/73160869417275200be19bd37372b6218dbc5f63)) +* **api:** implement `Page.waitForNetworkIdle()` ([#5140](https://github.com/puppeteer/puppeteer/issues/5140)) ([3c6029c](https://github.com/puppeteer/puppeteer/commit/3c6029c702291ca7ef637b66e78d72e03156fe58)) +* **coverage:** option for raw V8 script coverage ([#6454](https://github.com/puppeteer/puppeteer/issues/6454)) ([cb4470a](https://github.com/puppeteer/puppeteer/commit/cb4470a6d9b0a7f73836458bb3d5779eb85ac5f2)) +* support timeout for page.pdf() call ([#7508](https://github.com/puppeteer/puppeteer/issues/7508)) ([f90af66](https://github.com/puppeteer/puppeteer/commit/f90af6639d801e764bdb479b9543b7f8f2b926df)) +* **typescript:** allow using puppeteer without dom lib ([#6998](https://github.com/puppeteer/puppeteer/issues/6998)) ([723052d](https://github.com/puppeteer/puppeteer/commit/723052d5bb3c3d1d3908508467512bea4d8fdc80)), closes [#6989](https://github.com/puppeteer/puppeteer/issues/6989) + + +### Bug Fixes + +* **docs:** deploy includes website documentation ([#7469](https://github.com/puppeteer/puppeteer/issues/7469)) ([6fde41c](https://github.com/puppeteer/puppeteer/commit/6fde41c6b6657986df1bbce3f2e0f7aa499f2be4)) +* **docs:** names in version 9.1.1 ([#7517](https://github.com/puppeteer/puppeteer/issues/7517)) ([44b22bb](https://github.com/puppeteer/puppeteer/commit/44b22bbc2629e3c75c1494b299a66790b371fb0a)) +* **frame:** fix Frame.waitFor's XPath pattern detection ([#5184](https://github.com/puppeteer/puppeteer/issues/5184)) ([caa2b73](https://github.com/puppeteer/puppeteer/commit/caa2b732fe58f32ec03f2a9fa8568f20188203c5)) +* **install:** respect environment proxy config when downloading Firef… ([#6577](https://github.com/puppeteer/puppeteer/issues/6577)) ([9399c97](https://github.com/puppeteer/puppeteer/commit/9399c9786fba4e45e1c5485ddbb197d2d4f1735f)), closes [#6573](https://github.com/puppeteer/puppeteer/issues/6573) +* added names in V9.1.1 ([#7547](https://github.com/puppeteer/puppeteer/issues/7547)) ([d132b8b](https://github.com/puppeteer/puppeteer/commit/d132b8b041696e6d5b9a99d0be1acf1cf943efef)) +* **test:** tweak waitForNetworkIdle delay in test between downloads ([#7564](https://github.com/puppeteer/puppeteer/issues/7564)) ([a21b737](https://github.com/puppeteer/puppeteer/commit/a21b7376e7feaf23066d67948d52480516f42496)) +* **types:** allow evaluate functions to take a readonly array as an argument ([#7072](https://github.com/puppeteer/puppeteer/issues/7072)) ([491614c](https://github.com/puppeteer/puppeteer/commit/491614c7f8cfa50b902d0275064e611c2a48c3b2)) +* update firefox prefs documentation link ([#7539](https://github.com/puppeteer/puppeteer/issues/7539)) ([2aec355](https://github.com/puppeteer/puppeteer/commit/2aec35553bc6e0305f40837bb3665ddbd02aa889)) +* use non-deprecated tracing categories api ([#7413](https://github.com/puppeteer/puppeteer/issues/7413)) ([040a0e5](https://github.com/puppeteer/puppeteer/commit/040a0e561b4f623f7929130b90be129f94ebb642)) + +## [10.2.0](https://github.com/puppeteer/puppeteer/compare/v10.1.0...v10.2.0) (2021-08-04) + + +### Features + +* **api:** make `page.isDragInterceptionEnabled` a method ([#7419](https://github.com/puppeteer/puppeteer/issues/7419)) ([dd470c7](https://github.com/puppeteer/puppeteer/commit/dd470c7a226a8422a938a7b0fffa58ffc6b78512)), closes [#7150](https://github.com/puppeteer/puppeteer/issues/7150) +* **chromium:** roll to Chromium 93.0.4577.0 (r901912) ([#7387](https://github.com/puppeteer/puppeteer/issues/7387)) ([e10faad](https://github.com/puppeteer/puppeteer/commit/e10faad4f239b1120491bb54fcba0216acd3a646)) +* add channel parameter for puppeteer.launch ([#7389](https://github.com/puppeteer/puppeteer/issues/7389)) ([d70f60e](https://github.com/puppeteer/puppeteer/commit/d70f60e0619b8659d191fa492e3db4bc221ae982)) +* add cooperative request intercepts ([#6735](https://github.com/puppeteer/puppeteer/issues/6735)) ([b5e6474](https://github.com/puppeteer/puppeteer/commit/b5e6474374ae6a88fc73cdb1a9906764c2ac5d70)) +* add support for useragentdata ([#7378](https://github.com/puppeteer/puppeteer/issues/7378)) ([7200b1a](https://github.com/puppeteer/puppeteer/commit/7200b1a6fb9dfdfb65d50f0000339333e71b1b2a)) + + +### Bug Fixes + +* **browser-runner:** reject promise on error ([#7338](https://github.com/puppeteer/puppeteer/issues/7338)) ([5eb20e2](https://github.com/puppeteer/puppeteer/commit/5eb20e29a21ea0e0368fa8937ef38f7c7693ab34)) +* add script to remove html comments from docs markdown ([#7394](https://github.com/puppeteer/puppeteer/issues/7394)) ([ea3df80](https://github.com/puppeteer/puppeteer/commit/ea3df80ed136a03d7698d2319106af5df8d48b58)) + +## [10.1.0](https://github.com/puppeteer/puppeteer/compare/v10.0.0...v10.1.0) (2021-06-29) + + +### Features + +* add a streaming version for page.pdf ([e3699e2](https://github.com/puppeteer/puppeteer/commit/e3699e248bc9c1f7a6ead9a07d68ae8b65905443)) +* add drag-and-drop support ([#7150](https://github.com/puppeteer/puppeteer/issues/7150)) ([a91b8ac](https://github.com/puppeteer/puppeteer/commit/a91b8aca3728b2c2e310e9446897d729bf983377)) +* add page.emulateCPUThrottling ([#7343](https://github.com/puppeteer/puppeteer/issues/7343)) ([4ce4110](https://github.com/puppeteer/puppeteer/commit/4ce41106288938b9d366c550e7a424812920683d)) + + +### Bug Fixes + +* remove redundant await while fetching target ([#7351](https://github.com/puppeteer/puppeteer/issues/7351)) ([083b297](https://github.com/puppeteer/puppeteer/commit/083b297a6741c6b1dd23867f441130655fac8f7d)) + +## [10.0.0](https://github.com/puppeteer/puppeteer/compare/v9.1.1...v10.0.0) (2021-05-31) + + +### ⚠ BREAKING CHANGES + +* Node.js 10 is no longer supported. + +### Features + +* **chromium:** roll to Chromium 92.0.4512.0 (r884014) ([#7288](https://github.com/puppeteer/puppeteer/issues/7288)) ([f863f4b](https://github.com/puppeteer/puppeteer/commit/f863f4bfe015e57ea1f9fbb322f1cedee468b857)) +* **requestinterception:** remove cacheSafe flag ([#7217](https://github.com/puppeteer/puppeteer/issues/7217)) ([d01aa6c](https://github.com/puppeteer/puppeteer/commit/d01aa6c84a1e41f15ffed3a8d36ad26a404a7187)) +* expose other sessions from connection ([#6863](https://github.com/puppeteer/puppeteer/issues/6863)) ([cb285a2](https://github.com/puppeteer/puppeteer/commit/cb285a237921259eac99ade1d8b5550e068a55eb)) +* **launcher:** add new launcher option `waitForInitialPage` ([#7105](https://github.com/puppeteer/puppeteer/issues/7105)) ([2605309](https://github.com/puppeteer/puppeteer/commit/2605309f74b43da160cda4d214016e4422bf7676)), closes [#3630](https://github.com/puppeteer/puppeteer/issues/3630) + + +### Bug Fixes + +* added comments for browsercontext, startCSSCoverage, and startJSCoverage. ([#7264](https://github.com/puppeteer/puppeteer/issues/7264)) ([b750397](https://github.com/puppeteer/puppeteer/commit/b75039746ac6bddf1411538242b5e70b0f2e6e8a)) +* modified comment for method product, platform and newPage ([#7262](https://github.com/puppeteer/puppeteer/issues/7262)) ([159d283](https://github.com/puppeteer/puppeteer/commit/159d2835450697dabea6f9adf6e67d158b5b8ae3)) +* **requestinterception:** fix font loading issue ([#7060](https://github.com/puppeteer/puppeteer/issues/7060)) ([c9978d2](https://github.com/puppeteer/puppeteer/commit/c9978d20d5584c9fd2dc902e4b4ac86ed8ea5d6e)), closes [/github.com/puppeteer/puppeteer/pull/6996#issuecomment-811546501](https://github.com/puppeteer//github.com/puppeteer/puppeteer/pull/6996/issues/issuecomment-811546501) [/github.com/puppeteer/puppeteer/pull/6996#issuecomment-813797393](https://github.com/puppeteer//github.com/puppeteer/puppeteer/pull/6996/issues/issuecomment-813797393) [#7038](https://github.com/puppeteer/puppeteer/issues/7038) + + +* drop support for Node.js 10 ([#7200](https://github.com/puppeteer/puppeteer/issues/7200)) ([97c9fe2](https://github.com/puppeteer/puppeteer/commit/97c9fe2520723d45a5a86da06b888ae888d400be)), closes [#6753](https://github.com/puppeteer/puppeteer/issues/6753) + +### [9.1.1](https://github.com/puppeteer/puppeteer/compare/v9.1.0...v9.1.1) (2021-05-05) + + +### Bug Fixes + +* make targetFilter synchronous ([#7203](https://github.com/puppeteer/puppeteer/issues/7203)) ([bcc85a0](https://github.com/puppeteer/puppeteer/commit/bcc85a0969077d122e5d8d2fb5c1061999a8ae48)) + +## [9.1.0](https://github.com/puppeteer/puppeteer/compare/v9.0.0...v9.1.0) (2021-05-03) + + +### Features + +* add option to filter targets ([#7192](https://github.com/puppeteer/puppeteer/issues/7192)) ([ec3fc2e](https://github.com/puppeteer/puppeteer/commit/ec3fc2e035bb5ca14a576180fff612e1ecf6bad7)) + + +### Bug Fixes + +* change rm -rf to rimraf ([#7168](https://github.com/puppeteer/puppeteer/issues/7168)) ([ad6b736](https://github.com/puppeteer/puppeteer/commit/ad6b736039436fcc5c0a262e5b575aa041427be3)) + +## [9.0.0](https://github.com/puppeteer/puppeteer/compare/v8.0.0...v9.0.0) (2021-04-21) + + +### ⚠ BREAKING CHANGES + +* **filechooser:** FileChooser.cancel() is now synchronous. + +### Features + +* **chromium:** roll to Chromium 91.0.4469.0 (r869685) ([#7110](https://github.com/puppeteer/puppeteer/issues/7110)) ([715e7a8](https://github.com/puppeteer/puppeteer/commit/715e7a8d62901d1c7ec602425c2fce8d8148b742)) +* **launcher:** fix installation error on Apple M1 chips ([#7099](https://github.com/puppeteer/puppeteer/issues/7099)) ([c239d9e](https://github.com/puppeteer/puppeteer/commit/c239d9edc72d85697b4875c98fff3ec592848082)), closes [#6622](https://github.com/puppeteer/puppeteer/issues/6622) +* **network:** request interception and caching compatibility ([#6996](https://github.com/puppeteer/puppeteer/issues/6996)) ([8695759](https://github.com/puppeteer/puppeteer/commit/8695759a223bc1bd31baecb00dc28721216e4c6f)) +* **page:** emit the event after removing the Worker ([#7080](https://github.com/puppeteer/puppeteer/issues/7080)) ([e34a6d5](https://github.com/puppeteer/puppeteer/commit/e34a6d53183c3e1f63a375ba6a26bee0dcfcf542)) +* **types:** improve type of predicate function ([#6997](https://github.com/puppeteer/puppeteer/issues/6997)) ([943477c](https://github.com/puppeteer/puppeteer/commit/943477cc1eb4b129870142873b3554737d5ef252)), closes [/github.com/DefinitelyTyped/DefinitelyTyped/blob/c43191a8f7a7d2a47bbff0bc3a7d95ecc64d2269/types/puppeteer/index.d.ts#L1883-L1885](https://github.com/puppeteer//github.com/DefinitelyTyped/DefinitelyTyped/blob/c43191a8f7a7d2a47bbff0bc3a7d95ecc64d2269/types/puppeteer/index.d.ts/issues/L1883-L1885) +* accept captureBeyondViewport as optional screenshot param ([#7063](https://github.com/puppeteer/puppeteer/issues/7063)) ([0e092d2](https://github.com/puppeteer/puppeteer/commit/0e092d2ea0ec18ad7f07ad3507deb80f96086e7a)) +* **page:** add omitBackground option for page.pdf method ([#6981](https://github.com/puppeteer/puppeteer/issues/6981)) ([dc8ab6d](https://github.com/puppeteer/puppeteer/commit/dc8ab6d8ca1661f8e56d329e6d9c49c891e8b975)) + + +### Bug Fixes + +* **aria:** fix parsing of ARIA selectors ([#7037](https://github.com/puppeteer/puppeteer/issues/7037)) ([4426135](https://github.com/puppeteer/puppeteer/commit/4426135692ae3ee7ed2841569dd9375e7ca8286c)) +* **page:** fix mouse.click method ([#7097](https://github.com/puppeteer/puppeteer/issues/7097)) ([ba7c367](https://github.com/puppeteer/puppeteer/commit/ba7c367de33ace7753fd9d8b8cc894b2c14ab6c2)), closes [#6462](https://github.com/puppeteer/puppeteer/issues/6462) [#3347](https://github.com/puppeteer/puppeteer/issues/3347) +* make `$` and `$$` selectors generic ([#6883](https://github.com/puppeteer/puppeteer/issues/6883)) ([b349c91](https://github.com/puppeteer/puppeteer/commit/b349c91e7df76630b7411d6645e649945c4609bd)) +* type page event listeners correctly ([#6891](https://github.com/puppeteer/puppeteer/issues/6891)) ([866d34e](https://github.com/puppeteer/puppeteer/commit/866d34ee1122e89eab00743246676845bb065968)) +* **typescript:** allow defaultViewport to be 'null' ([#6942](https://github.com/puppeteer/puppeteer/issues/6942)) ([e31e68d](https://github.com/puppeteer/puppeteer/commit/e31e68dfa12dd50482b700472bc98876b9031829)), closes [#6885](https://github.com/puppeteer/puppeteer/issues/6885) +* make screenshots work in puppeteer-web ([#6936](https://github.com/puppeteer/puppeteer/issues/6936)) ([5f24f60](https://github.com/puppeteer/puppeteer/commit/5f24f608194fd4252da7b288461427cabc9dabb3)) +* **filechooser:** cancel is sync ([#6937](https://github.com/puppeteer/puppeteer/issues/6937)) ([2ba61e0](https://github.com/puppeteer/puppeteer/commit/2ba61e04e923edaac09c92315212552f2d4ce676)) +* **network:** don't disable cache for auth challenge ([#6962](https://github.com/puppeteer/puppeteer/issues/6962)) ([1c2479a](https://github.com/puppeteer/puppeteer/commit/1c2479a6cd4bd09a577175ffd31c40ca6f4279b8)) + +## [8.0.0](https://github.com/puppeteer/puppeteer/compare/v7.1.0...v8.0.0) (2021-02-26) + + +### ⚠ BREAKING CHANGES + +* renamed type `ChromeArgOptions` to `BrowserLaunchArgumentOptions` +* renamed type `BrowserOptions` to `BrowserConnectOptions` + +### Features + +* **chromium:** roll Chromium to r856583 ([#6927](https://github.com/puppeteer/puppeteer/issues/6927)) ([0c688bd](https://github.com/puppeteer/puppeteer/commit/0c688bd75ef1d1fc3afd14cbe8966757ecda68fb)) + + +### Bug Fixes + +* explicit HTTPRequest.resourceType type defs ([#6882](https://github.com/puppeteer/puppeteer/issues/6882)) ([ff26c62](https://github.com/puppeteer/puppeteer/commit/ff26c62647b60cd0d8d7ea66ee998adaadc3fcc2)), closes [#6854](https://github.com/puppeteer/puppeteer/issues/6854) +* expose `Viewport` type ([#6881](https://github.com/puppeteer/puppeteer/issues/6881)) ([be7c229](https://github.com/puppeteer/puppeteer/commit/be7c22933c1dcf5eee797d61463171bd0ef44582)) +* improve TS types for launching browsers ([#6888](https://github.com/puppeteer/puppeteer/issues/6888)) ([98c8145](https://github.com/puppeteer/puppeteer/commit/98c81458c27f378eb66c38e1620e79e2ffde418e)) +* move CI npm config out of .npmrc ([#6901](https://github.com/puppeteer/puppeteer/issues/6901)) ([f7de60b](https://github.com/puppeteer/puppeteer/commit/f7de60be22d9bc6433ada7bfefeaa7f6f6f62047)) + +## [7.1.0](https://github.com/puppeteer/puppeteer/compare/v7.0.4...v7.1.0) (2021-02-12) + + +### Features + +* **page:** add color-gamut support to Page.emulateMediaFeatures ([#6857](https://github.com/puppeteer/puppeteer/issues/6857)) ([ad59357](https://github.com/puppeteer/puppeteer/commit/ad5935738d869cfce386a0d28b4bc6131457f962)), closes [#6761](https://github.com/puppeteer/puppeteer/issues/6761) + + +### Bug Fixes + +* add favicon test asset ([#6868](https://github.com/puppeteer/puppeteer/issues/6868)) ([a63f53c](https://github.com/puppeteer/puppeteer/commit/a63f53c9380545550503f5539494c72c607e19ac)) +* expose `ScreenshotOptions` type in type defs ([#6869](https://github.com/puppeteer/puppeteer/issues/6869)) ([63d48b2](https://github.com/puppeteer/puppeteer/commit/63d48b2ecba317b6c0a3acad87a7a3671c769dbc)), closes [#6866](https://github.com/puppeteer/puppeteer/issues/6866) +* expose puppeteer.Permission type ([#6856](https://github.com/puppeteer/puppeteer/issues/6856)) ([a5e174f](https://github.com/puppeteer/puppeteer/commit/a5e174f696eb192c541db64a603ea5cdf385a643)) +* jsonValue() type is generic ([#6865](https://github.com/puppeteer/puppeteer/issues/6865)) ([bdaba78](https://github.com/puppeteer/puppeteer/commit/bdaba7829da366aabbc81885d84bb2401ab3eaff)) +* wider compat TS types and CI checks to ensure correct type defs ([#6855](https://github.com/puppeteer/puppeteer/issues/6855)) ([6a0eb78](https://github.com/puppeteer/puppeteer/commit/6a0eb7841fd82493903b0b9fa153d2de181350eb)) + +### [7.0.4](https://github.com/puppeteer/puppeteer/compare/v7.0.3...v7.0.4) (2021-02-09) + + +### Bug Fixes + +* make publish bot run full build, not just tsc ([#6848](https://github.com/puppeteer/puppeteer/issues/6848)) ([f718b14](https://github.com/puppeteer/puppeteer/commit/f718b14b64df8be492d344ddd35e40961ff750c5)) + +### [7.0.3](https://github.com/puppeteer/puppeteer/compare/v7.0.2...v7.0.3) (2021-02-09) + + +### Bug Fixes + +* include lib/types.d.ts in files list ([#6844](https://github.com/puppeteer/puppeteer/issues/6844)) ([e34f317](https://github.com/puppeteer/puppeteer/commit/e34f317b37533256a063c1238609b488d263b998)) + +### [7.0.2](https://github.com/puppeteer/puppeteer/compare/v7.0.1...v7.0.2) (2021-02-09) + + +### Bug Fixes + +* much better TypeScript definitions ([#6837](https://github.com/puppeteer/puppeteer/issues/6837)) ([f1b46ab](https://github.com/puppeteer/puppeteer/commit/f1b46ab5faa262f893c17923579d0cf52268a764)) +* **domworld:** reset bindings when context changes ([#6766](https://github.com/puppeteer/puppeteer/issues/6766)) ([#6836](https://github.com/puppeteer/puppeteer/issues/6836)) ([4e8d074](https://github.com/puppeteer/puppeteer/commit/4e8d074c2f8384a2f283f5edf9ef69c40bd8464f)) +* **launcher:** output correct error message for browser ([#6815](https://github.com/puppeteer/puppeteer/issues/6815)) ([6c61874](https://github.com/puppeteer/puppeteer/commit/6c618747979c3a08f2727e9e22fe45cade8c926a)) + +### [7.0.1](https://github.com/puppeteer/puppeteer/compare/v7.0.0...v7.0.1) (2021-02-04) + + +### Bug Fixes + +* **typescript:** ship .d.ts file in npm package ([#6811](https://github.com/puppeteer/puppeteer/issues/6811)) ([a7e3c2e](https://github.com/puppeteer/puppeteer/commit/a7e3c2e09e9163eee2f15221aafa4400e6a75f91)) + +## [7.0.0](https://github.com/puppeteer/puppeteer/compare/v6.0.0...v7.0.0) (2021-02-03) + + +### ⚠ BREAKING CHANGES + +* - `page.screenshot` makes a screenshot with the clip dimensions, not cutting it by the ViewPort size. +* **chromium:** - `page.screenshot` cuts screenshot content by the ViewPort size, not ViewPort position. + +### Features + +* use `captureBeyondViewport` in `Page.captureScreenshot` ([#6805](https://github.com/puppeteer/puppeteer/issues/6805)) ([401d84e](https://github.com/puppeteer/puppeteer/commit/401d84e4a3508f9ca5c24dbfcad2a71571b1b8eb)) +* **chromium:** roll Chromium to r848005 ([#6801](https://github.com/puppeteer/puppeteer/issues/6801)) ([890d5c2](https://github.com/puppeteer/puppeteer/commit/890d5c2e57cdee7d73915a878bda86b72e26b608)) + +## [6.0.0](https://github.com/puppeteer/puppeteer/compare/v5.5.0...v6.0.0) (2021-02-02) + + +### ⚠ BREAKING CHANGES + +* **chromium:** The built-in `aria/` selector query handler doesn’t return ignored elements anymore. + +### Features + +* **chromium:** roll Chromium to r843427 ([#6797](https://github.com/puppeteer/puppeteer/issues/6797)) ([8f9fbdb](https://github.com/puppeteer/puppeteer/commit/8f9fbdbae68254600a9c73ab05f36146c975dba6)), closes [#6758](https://github.com/puppeteer/puppeteer/issues/6758) +* add page.emulateNetworkConditions ([#6759](https://github.com/puppeteer/puppeteer/issues/6759)) ([5ea76e9](https://github.com/puppeteer/puppeteer/commit/5ea76e9333c42ab5a751ca01aa5676a662f6c063)) +* **types:** expose typedefs to consumers ([#6745](https://github.com/puppeteer/puppeteer/issues/6745)) ([ebd087a](https://github.com/puppeteer/puppeteer/commit/ebd087a31661a1b701650d0be3e123cc5a813bd8)) +* add iPhone 11 models to DeviceDescriptors ([#6467](https://github.com/puppeteer/puppeteer/issues/6467)) ([50b810d](https://github.com/puppeteer/puppeteer/commit/50b810dab7fae5950ba086295462788f91ff1e6f)) +* support fetching and launching on Apple M1 ([9a8479a](https://github.com/puppeteer/puppeteer/commit/9a8479a52a7d8b51690b0732b2a10816cd1b8aef)), closes [#6495](https://github.com/puppeteer/puppeteer/issues/6495) [#6634](https://github.com/puppeteer/puppeteer/issues/6634) [#6641](https://github.com/puppeteer/puppeteer/issues/6641) [#6614](https://github.com/puppeteer/puppeteer/issues/6614) +* support promise as return value for page.waitForResponse predicate ([#6624](https://github.com/puppeteer/puppeteer/issues/6624)) ([b57f3fc](https://github.com/puppeteer/puppeteer/commit/b57f3fcd5393c68f51d82e670b004f5b116dcbc3)) + + +### Bug Fixes + +* **domworld:** fix waitfor bindings ([#6766](https://github.com/puppeteer/puppeteer/issues/6766)) ([#6775](https://github.com/puppeteer/puppeteer/issues/6775)) ([cac540b](https://github.com/puppeteer/puppeteer/commit/cac540be3ab8799a1d77b0951b16bc22ea1c2adb)) +* **launcher:** rename TranslateUI to Translate to match Chrome ([#6692](https://github.com/puppeteer/puppeteer/issues/6692)) ([d901696](https://github.com/puppeteer/puppeteer/commit/d901696e0d8901bcb23cf676a5e5ac562f821a0d)) +* do not use old utility world ([#6528](https://github.com/puppeteer/puppeteer/issues/6528)) ([fb85911](https://github.com/puppeteer/puppeteer/commit/fb859115c0e2829bae1d1b32edbf642988e2ef76)), closes [#6527](https://github.com/puppeteer/puppeteer/issues/6527) +* update to https-proxy-agent@^5.0.0 to fix `ERR_INVALID_PROTOCOL` ([#6555](https://github.com/puppeteer/puppeteer/issues/6555)) ([3bf5a55](https://github.com/puppeteer/puppeteer/commit/3bf5a552890ee80cc4326b1e430424b0fdad4363)) + +## [5.5.0](https://github.com/puppeteer/puppeteer/compare/v5.4.1...v5.5.0) (2020-11-16) + + +### Features + +* **chromium:** roll Chromium to r818858 ([#6526](https://github.com/puppeteer/puppeteer/issues/6526)) ([b549256](https://github.com/puppeteer/puppeteer/commit/b54925695200cad32f470f8eb407259606447a85)) + + +### Bug Fixes + +* **common:** fix generic type of `_isClosedPromise` ([#6579](https://github.com/puppeteer/puppeteer/issues/6579)) ([122f074](https://github.com/puppeteer/puppeteer/commit/122f074f92f47a7b9aa08091851e51a07632d23b)) +* **domworld:** fix missing binding for waittasks ([#6562](https://github.com/puppeteer/puppeteer/issues/6562)) ([67da1cf](https://github.com/puppeteer/puppeteer/commit/67da1cf866703f5f581c9cce4923697ac38129ef)) diff --git a/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs b/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs new file mode 100644 index 0000000000..723fa2868a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs @@ -0,0 +1,112 @@ +import {mkdir, readFile, readdir, writeFile} from 'fs/promises'; +import {join} from 'path/posix'; + +import esbuild from 'esbuild'; +import {execa} from 'execa'; +import {task} from 'hereby'; + +export const generateVersionTask = task({ + name: 'generate:version', + run: async () => { + const {version} = JSON.parse(await readFile('package.json', 'utf8')); + await mkdir('src/generated', {recursive: true}); + await writeFile( + 'src/generated/version.ts', + (await readFile('src/templates/version.ts.tmpl', 'utf8')).replace( + 'PACKAGE_VERSION', + version + ) + ); + if (process.env['PUBLISH']) { + await writeFile( + '../../versions.js', + ( + await readFile('../../versions.js', { + encoding: 'utf-8', + }) + ).replace("'NEXT'", `'v${version}'`) + ); + } + }, +}); + +export const generateInjectedTask = task({ + name: 'generate:injected', + run: async () => { + const { + outputFiles: [{text}], + } = await esbuild.build({ + entryPoints: ['src/injected/injected.ts'], + bundle: true, + format: 'cjs', + target: ['chrome117', 'firefox118'], + minify: true, + write: false, + }); + const template = await readFile('src/templates/injected.ts.tmpl', 'utf8'); + await mkdir('src/generated', {recursive: true}); + await writeFile( + 'src/generated/injected.ts', + template.replace('SOURCE_CODE', JSON.stringify(text)) + ); + }, +}); + +export const generatePackageJsonTask = task({ + name: 'generate:package-json', + run: async () => { + await mkdir('lib/esm', {recursive: true}); + await writeFile('lib/esm/package.json', JSON.stringify({type: 'module'})); + }, +}); + +export const generateTask = task({ + name: 'generate', + dependencies: [ + generateVersionTask, + generateInjectedTask, + generatePackageJsonTask, + ], +}); + +export const buildTscTask = task({ + name: 'build:tsc', + dependencies: [generateTask], + run: async () => { + await execa('tsc', ['-b']); + }, +}); + +export const buildTask = task({ + name: 'build', + dependencies: [buildTscTask], + run: async () => { + const formats = ['esm', 'cjs']; + const packages = (await readdir('third_party', {withFileTypes: true})) + .filter(dirent => { + return dirent.isDirectory(); + }) + .map(({name}) => { + return name; + }); + const builders = []; + for (const format of formats) { + const folder = join('lib', format, 'third_party'); + for (const name of packages) { + const path = join(folder, name, `${name}.js`); + builders.push( + await esbuild.build({ + entryPoints: [path], + outfile: path, + bundle: true, + allowOverwrite: true, + format, + target: 'node16', + minify: true, + }) + ); + } + } + await Promise.all(builders); + }, +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/api-extractor.docs.json b/remote/test/puppeteer/packages/puppeteer-core/api-extractor.docs.json new file mode 100644 index 0000000000..b0bcacbb34 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/api-extractor.docs.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/puppeteer/puppeteer-core.d.ts", + + "extends": "./api-extractor.json", + + "dtsRollup": { + "enabled": false + }, + + "docModel": { + "enabled": true, + "apiJsonFilePath": "<projectFolder>/../../docs/<unscopedPackageName>.api.json" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/api-extractor.json b/remote/test/puppeteer/packages/puppeteer-core/api-extractor.json new file mode 100644 index 0000000000..7b9032de29 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/api-extractor.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/puppeteer/puppeteer-core.d.ts", + "bundledPackages": [], + + "apiReport": { + "enabled": false + }, + + "docModel": { + "enabled": false + }, + + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "", + "alphaTrimmedFilePath": "lib/types.d.ts" + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + + "extractorMessageReporting": { + "ae-internal-missing-underscore": { + "logLevel": "none" + }, + "default": { + "logLevel": "warning" + } + }, + + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/package.json b/remote/test/puppeteer/packages/puppeteer-core/package.json new file mode 100644 index 0000000000..2f1943bd2f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/package.json @@ -0,0 +1,136 @@ +{ + "name": "puppeteer-core", + "version": "21.10.0", + "description": "A high-level API to control headless Chrome over the DevTools Protocol", + "keywords": [ + "puppeteer", + "chrome", + "headless", + "automation" + ], + "type": "commonjs", + "main": "./lib/cjs/puppeteer/puppeteer-core.js", + "types": "./lib/types.d.ts", + "exports": { + ".": { + "types": "./lib/types.d.ts", + "import": "./lib/esm/puppeteer/puppeteer-core.js", + "require": "./lib/cjs/puppeteer/puppeteer-core.js" + }, + "./internal/*": { + "import": "./lib/esm/puppeteer/*", + "require": "./lib/cjs/puppeteer/*" + }, + "./*": { + "import": "./*", + "require": "./*" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/puppeteer-core" + }, + "engines": { + "node": ">=16.13.2" + }, + "scripts": { + "build:docs": "wireit", + "build": "wireit", + "check": "tsx tools/ensure-correct-devtools-protocol-package", + "clean": "../../tools/clean.js", + "prepack": "wireit", + "unit": "wireit" + }, + "wireit": { + "prepack": { + "command": "tsx ../../tools/cp.ts ../../README.md README.md", + "files": [ + "../../README.md" + ], + "output": [ + "README.md" + ] + }, + "build": { + "dependencies": [ + "build:tsc", + "build:types" + ] + }, + "build:docs": { + "command": "api-extractor run --local --config \"./api-extractor.docs.json\"", + "files": [ + "api-extractor.docs.json", + "lib/esm/puppeteer/puppeteer-core.d.ts", + "tsconfig.json" + ], + "dependencies": [ + "build:tsc" + ] + }, + "build:tsc": { + "command": "hereby build", + "clean": "if-file-deleted", + "dependencies": [ + "../browsers:build" + ], + "files": [ + "{src,third_party}/**", + "../../versions.js", + "!src/generated" + ], + "output": [ + "lib/{cjs,esm}/**" + ] + }, + "build:types": { + "command": "api-extractor run --local && eslint --cache-location .eslintcache --cache --ext=ts --no-ignore --no-eslintrc -c=../../.eslintrc.types.cjs --fix lib/types.d.ts", + "files": [ + "../../.eslintrc.types.cjs", + "api-extractor.json", + "lib/esm/puppeteer/types.d.ts", + "tsconfig.json" + ], + "output": [ + "lib/types.d.ts" + ], + "dependencies": [ + "build:tsc" + ] + }, + "unit": { + "command": "node --test --test-reporter spec lib/cjs", + "dependencies": [ + "build" + ] + } + }, + "files": [ + "lib", + "src", + "!*.test.ts", + "!*.test.js", + "!*.test.d.ts", + "!*.test.js.map", + "!*.test.d.ts.map", + "!*.tsbuildinfo" + ], + "author": "The Chromium Authors", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "1.9.1", + "chromium-bidi": "0.5.6", + "cross-fetch": "4.0.0", + "debug": "4.3.4", + "devtools-protocol": "0.0.1232444", + "ws": "8.16.0" + }, + "devDependencies": { + "@types/debug": "4.1.12", + "@types/node": "18.17.15", + "@types/ws": "8.5.10", + "mitt": "3.0.1", + "parsel-js": "1.1.2", + "rxjs": "7.8.1" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts new file mode 100644 index 0000000000..e3b465c80e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts @@ -0,0 +1,454 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcess} from 'child_process'; + +import type {Protocol} from 'devtools-protocol'; + +import { + filterAsync, + firstValueFrom, + from, + merge, + raceWith, +} from '../../third_party/rxjs/rxjs.js'; +import type {ProtocolType} from '../common/ConnectOptions.js'; +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {debugError, fromEmitterEvent, timeout} from '../common/util.js'; +import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js'; + +import type {BrowserContext} from './BrowserContext.js'; +import type {Page} from './Page.js'; +import type {Target} from './Target.js'; +/** + * @public + */ +export interface BrowserContextOptions { + /** + * Proxy server with optional port to use for all requests. + * Username and password can be set in `Page.authenticate`. + */ + proxyServer?: string; + /** + * Bypass the proxy for the given list of hosts. + */ + proxyBypassList?: string[]; +} + +/** + * @internal + */ +export type BrowserCloseCallback = () => Promise<void> | void; + +/** + * @public + */ +export type TargetFilterCallback = (target: Target) => boolean; + +/** + * @internal + */ +export type IsPageTargetCallback = (target: Target) => boolean; + +/** + * @internal + */ +export const WEB_PERMISSION_TO_PROTOCOL_PERMISSION = new Map< + Permission, + Protocol.Browser.PermissionType +>([ + ['geolocation', 'geolocation'], + ['midi', 'midi'], + ['notifications', 'notifications'], + // TODO: push isn't a valid type? + // ['push', 'push'], + ['camera', 'videoCapture'], + ['microphone', 'audioCapture'], + ['background-sync', 'backgroundSync'], + ['ambient-light-sensor', 'sensors'], + ['accelerometer', 'sensors'], + ['gyroscope', 'sensors'], + ['magnetometer', 'sensors'], + ['accessibility-events', 'accessibilityEvents'], + ['clipboard-read', 'clipboardReadWrite'], + ['clipboard-write', 'clipboardReadWrite'], + ['clipboard-sanitized-write', 'clipboardSanitizedWrite'], + ['payment-handler', 'paymentHandler'], + ['persistent-storage', 'durableStorage'], + ['idle-detection', 'idleDetection'], + // chrome-specific permissions we have. + ['midi-sysex', 'midiSysex'], +]); + +/** + * @public + */ +export type Permission = + | 'geolocation' + | 'midi' + | 'notifications' + | 'camera' + | 'microphone' + | 'background-sync' + | 'ambient-light-sensor' + | 'accelerometer' + | 'gyroscope' + | 'magnetometer' + | 'accessibility-events' + | 'clipboard-read' + | 'clipboard-write' + | 'clipboard-sanitized-write' + | 'payment-handler' + | 'persistent-storage' + | 'idle-detection' + | 'midi-sysex'; + +/** + * @public + */ +export interface WaitForTargetOptions { + /** + * Maximum wait time in milliseconds. Pass `0` to disable the timeout. + * + * @defaultValue `30_000` + */ + timeout?: number; +} + +/** + * All the events a {@link Browser | browser instance} may emit. + * + * @public + */ +export const enum BrowserEvent { + /** + * Emitted when Puppeteer gets disconnected from the browser instance. This + * might happen because either: + * + * - The browser closes/crashes or + * - {@link Browser.disconnect} was called. + */ + Disconnected = 'disconnected', + /** + * Emitted when the URL of a target changes. Contains a {@link Target} + * instance. + * + * @remarks Note that this includes target changes in incognito browser + * contexts. + */ + TargetChanged = 'targetchanged', + /** + * Emitted when a target is created, for example when a new page is opened by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open} + * or by {@link Browser.newPage | browser.newPage} + * + * Contains a {@link Target} instance. + * + * @remarks Note that this includes target creations in incognito browser + * contexts. + */ + TargetCreated = 'targetcreated', + /** + * Emitted when a target is destroyed, for example when a page is closed. + * Contains a {@link Target} instance. + * + * @remarks Note that this includes target destructions in incognito browser + * contexts. + */ + TargetDestroyed = 'targetdestroyed', + /** + * @internal + */ + TargetDiscovered = 'targetdiscovered', +} + +export { + /** + * @deprecated Use {@link BrowserEvent}. + */ + BrowserEvent as BrowserEmittedEvents, +}; + +/** + * @public + */ +export interface BrowserEvents extends Record<EventType, unknown> { + [BrowserEvent.Disconnected]: undefined; + [BrowserEvent.TargetCreated]: Target; + [BrowserEvent.TargetDestroyed]: Target; + [BrowserEvent.TargetChanged]: Target; + /** + * @internal + */ + [BrowserEvent.TargetDiscovered]: Protocol.Target.TargetInfo; +} + +/** + * @public + * @experimental + */ +export interface DebugInfo { + pendingProtocolErrors: Error[]; +} + +/** + * {@link Browser} represents a browser instance that is either: + * + * - connected to via {@link Puppeteer.connect} or + * - launched by {@link PuppeteerNode.launch}. + * + * {@link Browser} {@link EventEmitter | emits} various events which are + * documented in the {@link BrowserEvent} enum. + * + * @example Using a {@link Browser} to create a {@link Page}: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await browser.close(); + * ``` + * + * @example Disconnecting from and reconnecting to a {@link Browser}: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * const browser = await puppeteer.launch(); + * // Store the endpoint to be able to reconnect to the browser. + * const browserWSEndpoint = browser.wsEndpoint(); + * // Disconnect puppeteer from the browser. + * await browser.disconnect(); + * + * // Use the endpoint to reestablish a connection + * const browser2 = await puppeteer.connect({browserWSEndpoint}); + * // Close the browser. + * await browser2.close(); + * ``` + * + * @public + */ +export abstract class Browser extends EventEmitter<BrowserEvents> { + /** + * @internal + */ + constructor() { + super(); + } + + /** + * Gets the associated + * {@link https://nodejs.org/api/child_process.html#class-childprocess | ChildProcess}. + * + * @returns `null` if this instance was connected to via + * {@link Puppeteer.connect}. + */ + abstract process(): ChildProcess | null; + + /** + * Creates a new incognito {@link BrowserContext | browser context}. + * + * This won't share cookies/cache with other {@link BrowserContext | browser contexts}. + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * const browser = await puppeteer.launch(); + * // Create a new incognito browser context. + * const context = await browser.createIncognitoBrowserContext(); + * // Create a new page in a pristine context. + * const page = await context.newPage(); + * // Do stuff + * await page.goto('https://example.com'); + * ``` + */ + abstract createIncognitoBrowserContext( + options?: BrowserContextOptions + ): Promise<BrowserContext>; + + /** + * Gets a list of open {@link BrowserContext | browser contexts}. + * + * In a newly-created {@link Browser | browser}, this will return a single + * instance of {@link BrowserContext}. + */ + abstract browserContexts(): BrowserContext[]; + + /** + * Gets the default {@link BrowserContext | browser context}. + * + * @remarks The default {@link BrowserContext | browser context} cannot be + * closed. + */ + abstract defaultBrowserContext(): BrowserContext; + + /** + * Gets the WebSocket URL to connect to this {@link Browser | browser}. + * + * This is usually used with {@link Puppeteer.connect}. + * + * You can find the debugger URL (`webSocketDebuggerUrl`) from + * `http://HOST:PORT/json/version`. + * + * See {@link + * https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target + * | browser endpoint} for more information. + * + * @remarks The format is always `ws://HOST:PORT/devtools/browser/<id>`. + */ + abstract wsEndpoint(): string; + + /** + * Creates a new {@link Page | page} in the + * {@link Browser.defaultBrowserContext | default browser context}. + */ + abstract newPage(): Promise<Page>; + + /** + * Gets all active {@link Target | targets}. + * + * In case of multiple {@link BrowserContext | browser contexts}, this returns + * all {@link Target | targets} in all + * {@link BrowserContext | browser contexts}. + */ + abstract targets(): Target[]; + + /** + * Gets the {@link Target | target} associated with the + * {@link Browser.defaultBrowserContext | default browser context}). + */ + abstract target(): Target; + + /** + * Waits until a {@link Target | target} matching the given `predicate` + * appears and returns it. + * + * This will look all open {@link BrowserContext | browser contexts}. + * + * @example Finding a target for a page opened via `window.open`: + * + * ```ts + * await page.evaluate(() => window.open('https://www.example.com/')); + * const newWindowTarget = await browser.waitForTarget( + * target => target.url() === 'https://www.example.com/' + * ); + * ``` + */ + async waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options: WaitForTargetOptions = {} + ): Promise<Target> { + const {timeout: ms = 30000} = options; + return await firstValueFrom( + merge( + fromEmitterEvent(this, BrowserEvent.TargetCreated), + fromEmitterEvent(this, BrowserEvent.TargetChanged), + from(this.targets()) + ).pipe(filterAsync(predicate), raceWith(timeout(ms))) + ); + } + + /** + * Gets a list of all open {@link Page | pages} inside this {@link Browser}. + * + * If there ar multiple {@link BrowserContext | browser contexts}, this + * returns all {@link Page | pages} in all + * {@link BrowserContext | browser contexts}. + * + * @remarks Non-visible {@link Page | pages}, such as `"background_page"`, + * will not be listed here. You can find them using {@link Target.page}. + */ + async pages(): Promise<Page[]> { + const contextPages = await Promise.all( + this.browserContexts().map(context => { + return context.pages(); + }) + ); + // Flatten array. + return contextPages.reduce((acc, x) => { + return acc.concat(x); + }, []); + } + + /** + * Gets a string representing this {@link Browser | browser's} name and + * version. + * + * For headless browser, this is similar to `"HeadlessChrome/61.0.3153.0"`. For + * non-headless or new-headless, this is similar to `"Chrome/61.0.3153.0"`. For + * Firefox, it is similar to `"Firefox/116.0a1"`. + * + * The format of {@link Browser.version} might change with future releases of + * browsers. + */ + abstract version(): Promise<string>; + + /** + * Gets this {@link Browser | browser's} original user agent. + * + * {@link Page | Pages} can override the user agent with + * {@link Page.setUserAgent}. + * + */ + abstract userAgent(): Promise<string>; + + /** + * Closes this {@link Browser | browser} and all associated + * {@link Page | pages}. + */ + abstract close(): Promise<void>; + + /** + * Disconnects Puppeteer from this {@link Browser | browser}, but leaves the + * process running. + */ + abstract disconnect(): Promise<void>; + + /** + * Whether Puppeteer is connected to this {@link Browser | browser}. + * + * @deprecated Use {@link Browser | Browser.connected}. + */ + isConnected(): boolean { + return this.connected; + } + + /** + * Whether Puppeteer is connected to this {@link Browser | browser}. + */ + abstract get connected(): boolean; + + /** @internal */ + [disposeSymbol](): void { + return void this.close().catch(debugError); + } + + /** @internal */ + [asyncDisposeSymbol](): Promise<void> { + return this.close(); + } + + /** + * @internal + */ + abstract get protocol(): ProtocolType; + + /** + * Get debug information from Puppeteer. + * + * @remarks + * + * Currently, includes pending protocol calls. In the future, we might add more info. + * + * @public + * @experimental + */ + abstract get debugInfo(): DebugInfo; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts new file mode 100644 index 0000000000..79335eb9ed --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js'; + +import type {Browser, Permission, WaitForTargetOptions} from './Browser.js'; +import type {Page} from './Page.js'; +import type {Target} from './Target.js'; + +/** + * @public + */ +export const enum BrowserContextEvent { + /** + * Emitted when the url of a target inside the browser context changes. + * Contains a {@link Target} instance. + */ + TargetChanged = 'targetchanged', + + /** + * Emitted when a target is created within the browser context, for example + * when a new page is opened by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open} + * or by {@link BrowserContext.newPage | browserContext.newPage} + * + * Contains a {@link Target} instance. + */ + TargetCreated = 'targetcreated', + /** + * Emitted when a target is destroyed within the browser context, for example + * when a page is closed. Contains a {@link Target} instance. + */ + TargetDestroyed = 'targetdestroyed', +} + +export { + /** + * @deprecated Use {@link BrowserContextEvent} + */ + BrowserContextEvent as BrowserContextEmittedEvents, +}; + +/** + * @public + */ +export interface BrowserContextEvents extends Record<EventType, unknown> { + [BrowserContextEvent.TargetChanged]: Target; + [BrowserContextEvent.TargetCreated]: Target; + [BrowserContextEvent.TargetDestroyed]: Target; +} + +/** + * {@link BrowserContext} represents individual sessions within a + * {@link Browser | browser}. + * + * When a {@link Browser | browser} is launched, it has a single + * {@link BrowserContext | browser context} by default. Others can be created + * using {@link Browser.createIncognitoBrowserContext}. + * + * {@link BrowserContext} {@link EventEmitter | emits} various events which are + * documented in the {@link BrowserContextEvent} enum. + * + * If a {@link Page | page} opens another {@link Page | page}, e.g. using + * `window.open`, the popup will belong to the parent {@link Page.browserContext + * | page's browser context}. + * + * @example Creating an incognito {@link BrowserContext | browser context}: + * + * ```ts + * // Create a new incognito browser context + * const context = await browser.createIncognitoBrowserContext(); + * // Create a new page inside context. + * const page = await context.newPage(); + * // ... do stuff with page ... + * await page.goto('https://example.com'); + * // Dispose context once it's no longer needed. + * await context.close(); + * ``` + * + * @public + */ + +export abstract class BrowserContext extends EventEmitter<BrowserContextEvents> { + /** + * @internal + */ + constructor() { + super(); + } + + /** + * Gets all active {@link Target | targets} inside this + * {@link BrowserContext | browser context}. + */ + abstract targets(): Target[]; + + /** + * Waits until a {@link Target | target} matching the given `predicate` + * appears and returns it. + * + * This will look all open {@link BrowserContext | browser contexts}. + * + * @example Finding a target for a page opened via `window.open`: + * + * ```ts + * await page.evaluate(() => window.open('https://www.example.com/')); + * const newWindowTarget = await browserContext.waitForTarget( + * target => target.url() === 'https://www.example.com/' + * ); + * ``` + */ + abstract waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options?: WaitForTargetOptions + ): Promise<Target>; + + /** + * Gets a list of all open {@link Page | pages} inside this + * {@link BrowserContext | browser context}. + * + * @remarks Non-visible {@link Page | pages}, such as `"background_page"`, + * will not be listed here. You can find them using {@link Target.page}. + */ + abstract pages(): Promise<Page[]>; + + /** + * Whether this {@link BrowserContext | browser context} is incognito. + * + * The {@link Browser.defaultBrowserContext | default browser context} is the + * only non-incognito browser context. + */ + abstract isIncognito(): boolean; + + /** + * Grants this {@link BrowserContext | browser context} the given + * `permissions` within the given `origin`. + * + * @example Overriding permissions in the + * {@link Browser.defaultBrowserContext | default browser context}: + * + * ```ts + * const context = browser.defaultBrowserContext(); + * await context.overridePermissions('https://html5demos.com', [ + * 'geolocation', + * ]); + * ``` + * + * @param origin - The origin to grant permissions to, e.g. + * "https://example.com". + * @param permissions - An array of permissions to grant. All permissions that + * are not listed here will be automatically denied. + */ + abstract overridePermissions( + origin: string, + permissions: Permission[] + ): Promise<void>; + + /** + * Clears all permission overrides for this + * {@link BrowserContext | browser context}. + * + * @example Clearing overridden permissions in the + * {@link Browser.defaultBrowserContext | default browser context}: + * + * ```ts + * const context = browser.defaultBrowserContext(); + * context.overridePermissions('https://example.com', ['clipboard-read']); + * // do stuff .. + * context.clearPermissionOverrides(); + * ``` + */ + abstract clearPermissionOverrides(): Promise<void>; + + /** + * Creates a new {@link Page | page} in this + * {@link BrowserContext | browser context}. + */ + abstract newPage(): Promise<Page>; + + /** + * Gets the {@link Browser | browser} associated with this + * {@link BrowserContext | browser context}. + */ + abstract browser(): Browser; + + /** + * Closes this {@link BrowserContext | browser context} and all associated + * {@link Page | pages}. + * + * @remarks The + * {@link Browser.defaultBrowserContext | default browser context} cannot be + * closed. + */ + abstract close(): Promise<void>; + + /** + * Whether this {@link BrowserContext | browser context} is closed. + */ + get closed(): boolean { + return !this.browser().browserContexts().includes(this); + } + + /** + * Identifier for this {@link BrowserContext | browser context}. + */ + get id(): string | undefined { + return undefined; + } + + /** @internal */ + [disposeSymbol](): void { + return void this.close().catch(debugError); + } + + /** @internal */ + [asyncDisposeSymbol](): Promise<void> { + return this.close(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts new file mode 100644 index 0000000000..8bdf96f954 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts @@ -0,0 +1,121 @@ +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import type {Connection} from '../cdp/Connection.js'; +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; + +/** + * @public + */ +export type CDPEvents = { + [Property in keyof ProtocolMapping.Events]: ProtocolMapping.Events[Property][0]; +}; + +/** + * Events that the CDPSession class emits. + * + * @public + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace CDPSessionEvent { + /** @internal */ + export const Disconnected = Symbol('CDPSession.Disconnected'); + /** @internal */ + export const Swapped = Symbol('CDPSession.Swapped'); + /** + * Emitted when the session is ready to be configured during the auto-attach + * process. Right after the event is handled, the session will be resumed. + * + * @internal + */ + export const Ready = Symbol('CDPSession.Ready'); + export const SessionAttached = 'sessionattached' as const; + export const SessionDetached = 'sessiondetached' as const; +} + +/** + * @public + */ +export interface CDPSessionEvents + extends CDPEvents, + Record<EventType, unknown> { + /** @internal */ + [CDPSessionEvent.Disconnected]: undefined; + /** @internal */ + [CDPSessionEvent.Swapped]: CDPSession; + /** @internal */ + [CDPSessionEvent.Ready]: CDPSession; + [CDPSessionEvent.SessionAttached]: CDPSession; + [CDPSessionEvent.SessionDetached]: CDPSession; +} + +/** + * @public + */ +export interface CommandOptions { + timeout: number; +} + +/** + * The `CDPSession` instances are used to talk raw Chrome Devtools Protocol. + * + * @remarks + * + * Protocol methods can be called with {@link CDPSession.send} method and protocol + * events can be subscribed to with `CDPSession.on` method. + * + * Useful links: {@link https://chromedevtools.github.io/devtools-protocol/ | DevTools Protocol Viewer} + * and {@link https://github.com/aslushnikov/getting-started-with-cdp/blob/HEAD/README.md | Getting Started with DevTools Protocol}. + * + * @example + * + * ```ts + * const client = await page.target().createCDPSession(); + * await client.send('Animation.enable'); + * client.on('Animation.animationCreated', () => + * console.log('Animation created!') + * ); + * const response = await client.send('Animation.getPlaybackRate'); + * console.log('playback rate is ' + response.playbackRate); + * await client.send('Animation.setPlaybackRate', { + * playbackRate: response.playbackRate / 2, + * }); + * ``` + * + * @public + */ +export abstract class CDPSession extends EventEmitter<CDPSessionEvents> { + /** + * @internal + */ + constructor() { + super(); + } + + abstract connection(): Connection | undefined; + + /** + * Parent session in terms of CDP's auto-attach mechanism. + * + * @internal + */ + parentSession(): CDPSession | undefined { + return undefined; + } + + abstract send<T extends keyof ProtocolMapping.Commands>( + method: T, + params?: ProtocolMapping.Commands[T]['paramsType'][0], + options?: CommandOptions + ): Promise<ProtocolMapping.Commands[T]['returnType']>; + + /** + * Detaches the cdpSession from the target. Once detached, the cdpSession object + * won't emit any events and can't be used to send messages. + */ + abstract detach(): Promise<void>; + + /** + * Returns the session's id. + */ + abstract id(): string; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts new file mode 100644 index 0000000000..352337f30f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import {assert} from '../util/assert.js'; + +/** + * Dialog instances are dispatched by the {@link Page} via the `dialog` event. + * + * @remarks + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * page.on('dialog', async dialog => { + * console.log(dialog.message()); + * await dialog.dismiss(); + * await browser.close(); + * }); + * page.evaluate(() => alert('1')); + * })(); + * ``` + * + * @public + */ +export abstract class Dialog { + #type: Protocol.Page.DialogType; + #message: string; + #defaultValue: string; + #handled = false; + + /** + * @internal + */ + constructor( + type: Protocol.Page.DialogType, + message: string, + defaultValue = '' + ) { + this.#type = type; + this.#message = message; + this.#defaultValue = defaultValue; + } + + /** + * The type of the dialog. + */ + type(): Protocol.Page.DialogType { + return this.#type; + } + + /** + * The message displayed in the dialog. + */ + message(): string { + return this.#message; + } + + /** + * The default value of the prompt, or an empty string if the dialog + * is not a `prompt`. + */ + defaultValue(): string { + return this.#defaultValue; + } + + /** + * @internal + */ + protected abstract handle(options: { + accept: boolean; + text?: string; + }): Promise<void>; + + /** + * A promise that resolves when the dialog has been accepted. + * + * @param promptText - optional text that will be entered in the dialog + * prompt. Has no effect if the dialog's type is not `prompt`. + * + */ + async accept(promptText?: string): Promise<void> { + assert(!this.#handled, 'Cannot accept dialog which is already handled!'); + this.#handled = true; + await this.handle({ + accept: true, + text: promptText, + }); + } + + /** + * A promise which will resolve once the dialog has been dismissed + */ + async dismiss(): Promise<void> { + assert(!this.#handled, 'Cannot dismiss dialog which is already handled!'); + this.#handled = true; + await this.handle({ + accept: false, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts new file mode 100644 index 0000000000..43fec58e37 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts @@ -0,0 +1,1580 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {Frame} from '../api/Frame.js'; +import {getQueryHandlerAndSelector} from '../common/GetQueryHandler.js'; +import {LazyArg} from '../common/LazyArg.js'; +import type { + ElementFor, + EvaluateFuncWith, + HandleFor, + HandleOr, + NodeFor, +} from '../common/types.js'; +import type {KeyInput} from '../common/USKeyboardLayout.js'; +import { + debugError, + isString, + withSourcePuppeteerURLIfNone, +} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; +import {throwIfDisposed} from '../util/decorators.js'; +import {AsyncDisposableStack} from '../util/disposable.js'; + +import {_isElementHandle} from './ElementHandleSymbol.js'; +import type { + KeyboardTypeOptions, + KeyPressOptions, + MouseClickOptions, +} from './Input.js'; +import {JSHandle} from './JSHandle.js'; +import type {ScreenshotOptions, WaitForSelectorOptions} from './Page.js'; + +/** + * @public + */ +export type Quad = [Point, Point, Point, Point]; + +/** + * @public + */ +export interface BoxModel { + content: Quad; + padding: Quad; + border: Quad; + margin: Quad; + width: number; + height: number; +} + +/** + * @public + */ +export interface BoundingBox extends Point { + /** + * the width of the element in pixels. + */ + width: number; + /** + * the height of the element in pixels. + */ + height: number; +} + +/** + * @public + */ +export interface Offset { + /** + * x-offset for the clickable point relative to the top-left corner of the border box. + */ + x: number; + /** + * y-offset for the clickable point relative to the top-left corner of the border box. + */ + y: number; +} + +/** + * @public + */ +export interface ClickOptions extends MouseClickOptions { + /** + * Offset for the clickable point relative to the top-left corner of the border box. + */ + offset?: Offset; +} + +/** + * @public + */ +export interface Point { + x: number; + y: number; +} + +/** + * @public + */ +export interface ElementScreenshotOptions extends ScreenshotOptions { + /** + * @defaultValue `true` + */ + scrollIntoView?: boolean; +} + +/** + * ElementHandle represents an in-page DOM element. + * + * @remarks + * ElementHandles can be created with the {@link Page.$} method. + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * const hrefElement = await page.$('a'); + * await hrefElement.click(); + * // ... + * })(); + * ``` + * + * ElementHandle prevents the DOM element from being garbage-collected unless the + * handle is {@link JSHandle.dispose | disposed}. ElementHandles are auto-disposed + * when their origin frame gets navigated. + * + * ElementHandle instances can be used as arguments in {@link Page.$eval} and + * {@link Page.evaluate} methods. + * + * If you're using TypeScript, ElementHandle takes a generic argument that + * denotes the type of element the handle is holding within. For example, if you + * have a handle to a `<select>` element, you can type it as + * `ElementHandle<HTMLSelectElement>` and you get some nicer type checks. + * + * @public + */ +export abstract class ElementHandle< + ElementType extends Node = Element, +> extends JSHandle<ElementType> { + /** + * @internal + */ + declare [_isElementHandle]: boolean; + + /** + * A given method will have it's `this` replaced with an isolated version of + * `this` when decorated with this decorator. + * + * All changes of isolated `this` are reflected on the actual `this`. + * + * @internal + */ + static bindIsolatedHandle<This extends ElementHandle<Node>>( + target: (this: This, ...args: any[]) => Promise<any>, + _: unknown + ): typeof target { + return async function (...args) { + // If the handle is already isolated, then we don't need to adopt it + // again. + if (this.realm === this.frame.isolatedRealm()) { + return await target.call(this, ...args); + } + using adoptedThis = await this.frame.isolatedRealm().adoptHandle(this); + const result = await target.call(adoptedThis, ...args); + // If the function returns `adoptedThis`, then we return `this`. + if (result === adoptedThis) { + return this; + } + // If the function returns a handle, transfer it into the current realm. + if (result instanceof JSHandle) { + return await this.realm.transferHandle(result); + } + // If the function returns an array of handlers, transfer them into the + // current realm. + if (Array.isArray(result)) { + await Promise.all( + result.map(async (item, index, result) => { + if (item instanceof JSHandle) { + result[index] = await this.realm.transferHandle(item); + } + }) + ); + } + if (result instanceof Map) { + await Promise.all( + [...result.entries()].map(async ([key, value]) => { + if (value instanceof JSHandle) { + result.set(key, await this.realm.transferHandle(value)); + } + }) + ); + } + return result; + }; + } + + /** + * @internal + */ + protected readonly handle; + + /** + * @internal + */ + constructor(handle: JSHandle<ElementType>) { + super(); + this.handle = handle; + this[_isElementHandle] = true; + } + + /** + * @internal + */ + override get id(): string | undefined { + return this.handle.id; + } + + /** + * @internal + */ + override get disposed(): boolean { + return this.handle.disposed; + } + + /** + * @internal + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async getProperty<K extends keyof ElementType>( + propertyName: HandleOr<K> + ): Promise<HandleFor<ElementType[K]>> { + return await this.handle.getProperty(propertyName); + } + + /** + * @internal + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async getProperties(): Promise<Map<string, JSHandle>> { + return await this.handle.getProperties(); + } + + /** + * @internal + */ + override async evaluate< + Params extends unknown[], + Func extends EvaluateFuncWith<ElementType, Params> = EvaluateFuncWith< + ElementType, + Params + >, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + return await this.handle.evaluate(pageFunction, ...args); + } + + /** + * @internal + */ + override async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFuncWith<ElementType, Params> = EvaluateFuncWith< + ElementType, + Params + >, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + return await this.handle.evaluateHandle(pageFunction, ...args); + } + + /** + * @internal + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async jsonValue(): Promise<ElementType> { + return await this.handle.jsonValue(); + } + + /** + * @internal + */ + override toString(): string { + return this.handle.toString(); + } + + /** + * @internal + */ + override remoteObject(): Protocol.Runtime.RemoteObject { + return this.handle.remoteObject(); + } + + /** + * @internal + */ + override dispose(): Promise<void> { + return this.handle.dispose(); + } + + /** + * @internal + */ + override asElement(): ElementHandle<ElementType> { + return this; + } + + /** + * Frame corresponding to the current handle. + */ + abstract get frame(): Frame; + + /** + * Queries the current element for an element matching the given selector. + * + * @param selector - The selector to query for. + * @returns A {@link ElementHandle | element handle} to the first element + * matching the given selector. Otherwise, `null`. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.queryOne( + this, + updatedSelector + )) as ElementHandle<NodeFor<Selector>> | null; + } + + /** + * Queries the current element for all elements matching the given selector. + * + * @param selector - The selector to query for. + * @returns An array of {@link ElementHandle | element handles} that point to + * elements matching the given selector. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return await (AsyncIterableUtil.collect( + QueryHandler.queryAll(this, updatedSelector) + ) as Promise<Array<ElementHandle<NodeFor<Selector>>>>); + } + + /** + * Runs the given function on the first element matching the given selector in + * the current element. + * + * If the given function returns a promise, then this method will wait till + * the promise resolves. + * + * @example + * + * ```ts + * const tweetHandle = await page.$('.tweet'); + * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe( + * '100' + * ); + * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe( + * '10' + * ); + * ``` + * + * @param selector - The selector to query for. + * @param pageFunction - The function to be evaluated in this element's page's + * context. The first element matching the selector will be passed in as the + * first argument. + * @param args - Additional arguments to pass to `pageFunction`. + * @returns A promise to the result of the function. + */ + async $eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith< + NodeFor<Selector>, + Params + >, + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction); + using elementHandle = await this.$(selector); + if (!elementHandle) { + throw new Error( + `Error: failed to find element matching selector "${selector}"` + ); + } + return await elementHandle.evaluate(pageFunction, ...args); + } + + /** + * Runs the given function on an array of elements matching the given selector + * in the current element. + * + * If the given function returns a promise, then this method will wait till + * the promise resolves. + * + * @example + * HTML: + * + * ```html + * <div class="feed"> + * <div class="tweet">Hello!</div> + * <div class="tweet">Hi!</div> + * </div> + * ``` + * + * JavaScript: + * + * ```ts + * const feedHandle = await page.$('.feed'); + * expect( + * await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText)) + * ).toEqual(['Hello!', 'Hi!']); + * ``` + * + * @param selector - The selector to query for. + * @param pageFunction - The function to be evaluated in the element's page's + * context. An array of elements matching the given selector will be passed to + * the function as its first argument. + * @param args - Additional arguments to pass to `pageFunction`. + * @returns A promise to the result of the function. + */ + async $$eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith< + Array<NodeFor<Selector>>, + Params + > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>, + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); + const results = await this.$$(selector); + using elements = await this.evaluateHandle( + (_, ...elements) => { + return elements; + }, + ...results + ); + const [result] = await Promise.all([ + elements.evaluate(pageFunction, ...args), + ...results.map(results => { + return results.dispose(); + }), + ]); + return result; + } + + /** + * @deprecated Use {@link ElementHandle.$$} with the `xpath` prefix. + * + * Example: `await elementHandle.$$('xpath/' + xpathExpression)` + * + * The method evaluates the XPath expression relative to the elementHandle. + * If `xpath` starts with `//` instead of `.//`, the dot will be appended + * automatically. + * + * If there are no such elements, the method will resolve to an empty array. + * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate} + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async $x(expression: string): Promise<Array<ElementHandle<Node>>> { + if (expression.startsWith('//')) { + expression = `.${expression}`; + } + return await this.$$(`xpath/${expression}`); + } + + /** + * Wait for an element matching the given selector to appear in the current + * element. + * + * Unlike {@link Frame.waitForSelector}, this method does not work across + * navigations or if the element is detached from DOM. + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page + * .mainFrame() + * .waitForSelector('img') + * .then(() => console.log('First URL with image: ' + currentURL)); + * + * for (currentURL of [ + * 'https://example.com', + * 'https://google.com', + * 'https://bbc.com', + * ]) { + * await page.goto(currentURL); + * } + * await browser.close(); + * })(); + * ``` + * + * @param selector - The selector to query and wait for. + * @param options - Options for customizing waiting behavior. + * @returns An element matching the given selector. + * @throws Throws if an element matching the given selector doesn't appear. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.waitFor( + this, + updatedSelector, + options + )) as ElementHandle<NodeFor<Selector>> | null; + } + + async #checkVisibility(visibility: boolean): Promise<boolean> { + return await this.evaluate( + async (element, PuppeteerUtil, visibility) => { + return Boolean(PuppeteerUtil.checkVisibility(element, visibility)); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + visibility + ); + } + + /** + * Checks if an element is visible using the same mechanism as + * {@link ElementHandle.waitForSelector}. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async isVisible(): Promise<boolean> { + return await this.#checkVisibility(true); + } + + /** + * Checks if an element is hidden using the same mechanism as + * {@link ElementHandle.waitForSelector}. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async isHidden(): Promise<boolean> { + return await this.#checkVisibility(false); + } + + /** + * @deprecated Use {@link ElementHandle.waitForSelector} with the `xpath` + * prefix. + * + * Example: `await elementHandle.waitForSelector('xpath/' + xpathExpression)` + * + * The method evaluates the XPath expression relative to the elementHandle. + * + * Wait for the `xpath` within the element. If at the moment of calling the + * method the `xpath` already exists, the method will return immediately. If + * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the + * function will throw. + * + * If `xpath` starts with `//` instead of `.//`, the dot will be appended + * automatically. + * + * @example + * This method works across navigation. + * + * ```ts + * import puppeteer from 'puppeteer'; + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page + * .waitForXPath('//img') + * .then(() => console.log('First URL with image: ' + currentURL)); + * for (currentURL of [ + * 'https://example.com', + * 'https://google.com', + * 'https://bbc.com', + * ]) { + * await page.goto(currentURL); + * } + * await browser.close(); + * })(); + * ``` + * + * @param xpath - A + * {@link https://developer.mozilla.org/en-US/docs/Web/XPath | xpath} of an + * element to wait for + * @param options - Optional waiting parameters + * @returns Promise which resolves when element specified by xpath string is + * added to DOM. Resolves to `null` if waiting for `hidden: true` and xpath is + * not found in DOM, otherwise resolves to `ElementHandle`. + * @remarks + * The optional Argument `options` have properties: + * + * - `visible`: A boolean to wait for element to be present in DOM and to be + * visible, i.e. to not have `display: none` or `visibility: hidden` CSS + * properties. Defaults to `false`. + * + * - `hidden`: A boolean wait for element to not be found in the DOM or to be + * hidden, i.e. have `display: none` or `visibility: hidden` CSS properties. + * Defaults to `false`. + * + * - `timeout`: A number which is maximum time to wait for in milliseconds. + * Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The + * default value can be changed by using the {@link Page.setDefaultTimeout} + * method. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async waitForXPath( + xpath: string, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } = {} + ): Promise<ElementHandle<Node> | null> { + if (xpath.startsWith('//')) { + xpath = `.${xpath}`; + } + return await this.waitForSelector(`xpath/${xpath}`, options); + } + + /** + * Converts the current handle to the given element type. + * + * @example + * + * ```ts + * const element: ElementHandle<Element> = await page.$( + * '.class-name-of-anchor' + * ); + * // DO NOT DISPOSE `element`, this will be always be the same handle. + * const anchor: ElementHandle<HTMLAnchorElement> = + * await element.toElement('a'); + * ``` + * + * @param tagName - The tag name of the desired element type. + * @throws An error if the handle does not match. **The handle will not be + * automatically disposed.** + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async toElement< + K extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap, + >(tagName: K): Promise<HandleFor<ElementFor<K>>> { + const isMatchingTagName = await this.evaluate((node, tagName) => { + return node.nodeName === tagName.toUpperCase(); + }, tagName); + if (!isMatchingTagName) { + throw new Error(`Element is not a(n) \`${tagName}\` element`); + } + return this as unknown as HandleFor<ElementFor<K>>; + } + + /** + * Resolves the frame associated with the element, if any. Always exists for + * HTMLIFrameElements. + */ + abstract contentFrame(this: ElementHandle<HTMLIFrameElement>): Promise<Frame>; + abstract contentFrame(): Promise<Frame | null>; + + /** + * Returns the middle point within an element unless a specific offset is provided. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async clickablePoint(offset?: Offset): Promise<Point> { + const box = await this.#clickableBox(); + if (!box) { + throw new Error('Node is either not clickable or not an Element'); + } + if (offset !== undefined) { + return { + x: box.x + offset.x, + y: box.y + offset.y, + }; + } + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }; + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page} to hover over the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async hover(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.frame.page().mouse.move(x, y); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page | Page.mouse} to click in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async click( + this: ElementHandle<Element>, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(options.offset); + await this.frame.page().mouse.click(x, y, options); + } + + /** + * Drags an element over the given element or point. + * + * @returns DEPRECATED. When drag interception is enabled, the drag payload is + * returned. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async drag( + this: ElementHandle<Element>, + target: Point | ElementHandle<Element> + ): Promise<Protocol.Input.DragData | void> { + await this.scrollIntoViewIfNeeded(); + const page = this.frame.page(); + if (page.isDragInterceptionEnabled()) { + const source = await this.clickablePoint(); + if (target instanceof ElementHandle) { + target = await target.clickablePoint(); + } + return await page.mouse.drag(source, target); + } + try { + if (!page._isDragging) { + page._isDragging = true; + await this.hover(); + await page.mouse.down(); + } + if (target instanceof ElementHandle) { + await target.hover(); + } else { + await page.mouse.move(target.x, target.y); + } + } catch (error) { + page._isDragging = false; + throw error; + } + } + + /** + * @deprecated Do not use. `dragenter` will automatically be performed during dragging. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async dragEnter( + this: ElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + const page = this.frame.page(); + await this.scrollIntoViewIfNeeded(); + const target = await this.clickablePoint(); + await page.mouse.dragEnter(target, data); + } + + /** + * @deprecated Do not use. `dragover` will automatically be performed during dragging. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async dragOver( + this: ElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + const page = this.frame.page(); + await this.scrollIntoViewIfNeeded(); + const target = await this.clickablePoint(); + await page.mouse.dragOver(target, data); + } + + /** + * Drops the given element onto the current one. + */ + async drop( + this: ElementHandle<Element>, + element: ElementHandle<Element> + ): Promise<void>; + + /** + * @deprecated No longer supported. + */ + async drop( + this: ElementHandle<Element>, + data?: Protocol.Input.DragData + ): Promise<void>; + + /** + * @internal + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async drop( + this: ElementHandle<Element>, + dataOrElement: ElementHandle<Element> | Protocol.Input.DragData = { + items: [], + dragOperationsMask: 1, + } + ): Promise<void> { + const page = this.frame.page(); + if ('items' in dataOrElement) { + await this.scrollIntoViewIfNeeded(); + const destination = await this.clickablePoint(); + await page.mouse.drop(destination, dataOrElement); + } else { + // Note if the rest errors, we still want dragging off because the errors + // is most likely something implying the mouse is no longer dragging. + await dataOrElement.drag(this); + page._isDragging = false; + await page.mouse.up(); + } + } + + /** + * @deprecated Use `ElementHandle.drop` instead. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async dragAndDrop( + this: ElementHandle<Element>, + target: ElementHandle<Node>, + options?: {delay: number} + ): Promise<void> { + const page = this.frame.page(); + assert( + page.isDragInterceptionEnabled(), + 'Drag Interception is not enabled!' + ); + await this.scrollIntoViewIfNeeded(); + const startPoint = await this.clickablePoint(); + const targetPoint = await target.clickablePoint(); + await page.mouse.dragAndDrop(startPoint, targetPoint, options); + } + + /** + * Triggers a `change` and `input` event once all the provided options have been + * selected. If there's no `<select>` element matching `selector`, the method + * throws an error. + * + * @example + * + * ```ts + * handle.select('blue'); // single selection + * handle.select('red', 'green', 'blue'); // multiple selections + * ``` + * + * @param values - Values of options to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first + * one is taken into account. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async select(...values: string[]): Promise<string[]> { + for (const value of values) { + assert( + isString(value), + 'Values must be strings. Found value "' + + value + + '" of type "' + + typeof value + + '"' + ); + } + + return await this.evaluate((element, vals): string[] => { + const values = new Set(vals); + if (!(element instanceof HTMLSelectElement)) { + throw new Error('Element is not a <select> element.'); + } + + const selectedValues = new Set<string>(); + if (!element.multiple) { + for (const option of element.options) { + option.selected = false; + } + for (const option of element.options) { + if (values.has(option.value)) { + option.selected = true; + selectedValues.add(option.value); + break; + } + } + } else { + for (const option of element.options) { + option.selected = values.has(option.value); + if (option.selected) { + selectedValues.add(option.value); + } + } + } + element.dispatchEvent(new Event('input', {bubbles: true})); + element.dispatchEvent(new Event('change', {bubbles: true})); + return [...selectedValues.values()]; + }, values); + } + + /** + * Sets the value of an + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input | input element} + * to the given file paths. + * + * @remarks This will not validate whether the file paths exists. Also, if a + * path is relative, then it is resolved against the + * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}. + * For locals script connecting to remote chrome environments, paths must be + * absolute. + */ + abstract uploadFile( + this: ElementHandle<HTMLInputElement>, + ...paths: string[] + ): Promise<void>; + + /** + * This method scrolls element into view if needed, and then uses + * {@link Touchscreen.tap} to tap in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async tap(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.frame.page().touchscreen.tap(x, y); + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async touchStart(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.frame.page().touchscreen.touchStart(x, y); + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async touchMove(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.frame.page().touchscreen.touchMove(x, y); + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async touchEnd(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + await this.frame.page().touchscreen.touchEnd(); + } + + /** + * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async focus(): Promise<void> { + await this.evaluate(element => { + if (!(element instanceof HTMLElement)) { + throw new Error('Cannot focus non-HTMLElement'); + } + return element.focus(); + }); + } + + /** + * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and + * `keyup` event for each character in the text. + * + * To press a special key, like `Control` or `ArrowDown`, + * use {@link ElementHandle.press}. + * + * @example + * + * ```ts + * await elementHandle.type('Hello'); // Types instantly + * await elementHandle.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @example + * An example of typing into a text field and then submitting the form: + * + * ```ts + * const elementHandle = await page.$('input'); + * await elementHandle.type('some text'); + * await elementHandle.press('Enter'); + * ``` + * + * @param options - Delay in milliseconds. Defaults to 0. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async type( + text: string, + options?: Readonly<KeyboardTypeOptions> + ): Promise<void> { + await this.focus(); + await this.frame.page().keyboard.type(text, options); + } + + /** + * Focuses the element, and then uses {@link Keyboard.down} and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also be generated. + * The `text` option can be specified to force an input event to be generated. + * + * **NOTE** Modifier keys DO affect `elementHandle.press`. Holding down `Shift` + * will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async press( + key: KeyInput, + options?: Readonly<KeyPressOptions> + ): Promise<void> { + await this.focus(); + await this.frame.page().keyboard.press(key, options); + } + + async #clickableBox(): Promise<BoundingBox | null> { + const boxes = await this.evaluate(element => { + if (!(element instanceof Element)) { + return null; + } + return [...element.getClientRects()].map(rect => { + return {x: rect.x, y: rect.y, width: rect.width, height: rect.height}; + }); + }); + if (!boxes?.length) { + return null; + } + await this.#intersectBoundingBoxesWithFrame(boxes); + let frame = this.frame; + let parentFrame: Frame | null | undefined; + while ((parentFrame = frame?.parentFrame())) { + using handle = await frame.frameElement(); + if (!handle) { + throw new Error('Unsupported frame type'); + } + const parentBox = await handle.evaluate(element => { + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + left: + rect.left + + parseInt(style.paddingLeft, 10) + + parseInt(style.borderLeftWidth, 10), + top: + rect.top + + parseInt(style.paddingTop, 10) + + parseInt(style.borderTopWidth, 10), + }; + }); + if (!parentBox) { + return null; + } + for (const box of boxes) { + box.x += parentBox.left; + box.y += parentBox.top; + } + await handle.#intersectBoundingBoxesWithFrame(boxes); + frame = parentFrame; + } + const box = boxes.find(box => { + return box.width >= 1 && box.height >= 1; + }); + if (!box) { + return null; + } + return { + x: box.x, + y: box.y, + height: box.height, + width: box.width, + }; + } + + async #intersectBoundingBoxesWithFrame(boxes: BoundingBox[]) { + const {documentWidth, documentHeight} = await this.frame + .isolatedRealm() + .evaluate(() => { + return { + documentWidth: document.documentElement.clientWidth, + documentHeight: document.documentElement.clientHeight, + }; + }); + for (const box of boxes) { + intersectBoundingBox(box, documentWidth, documentHeight); + } + } + + /** + * This method returns the bounding box of the element (relative to the main frame), + * or `null` if the element is {@link https://drafts.csswg.org/css-display-4/#box-generation | not part of the layout} + * (example: `display: none`). + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async boundingBox(): Promise<BoundingBox | null> { + const box = await this.evaluate(element => { + if (!(element instanceof Element)) { + return null; + } + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + return {x: rect.x, y: rect.y, width: rect.width, height: rect.height}; + }); + if (!box) { + return null; + } + const offset = await this.#getTopLeftCornerOfFrame(); + if (!offset) { + return null; + } + return { + x: box.x + offset.x, + y: box.y + offset.y, + height: box.height, + width: box.width, + }; + } + + /** + * This method returns boxes of the element, + * or `null` if the element is {@link https://drafts.csswg.org/css-display-4/#box-generation | not part of the layout} + * (example: `display: none`). + * + * @remarks + * + * Boxes are represented as an array of points; + * Each Point is an object `{x, y}`. Box points are sorted clock-wise. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async boxModel(): Promise<BoxModel | null> { + const model = await this.evaluate(element => { + if (!(element instanceof Element)) { + return null; + } + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const offsets = { + padding: { + left: parseInt(style.paddingLeft, 10), + top: parseInt(style.paddingTop, 10), + right: parseInt(style.paddingRight, 10), + bottom: parseInt(style.paddingBottom, 10), + }, + margin: { + left: -parseInt(style.marginLeft, 10), + top: -parseInt(style.marginTop, 10), + right: -parseInt(style.marginRight, 10), + bottom: -parseInt(style.marginBottom, 10), + }, + border: { + left: parseInt(style.borderLeft, 10), + top: parseInt(style.borderTop, 10), + right: parseInt(style.borderRight, 10), + bottom: parseInt(style.borderBottom, 10), + }, + }; + const border: Quad = [ + {x: rect.left, y: rect.top}, + {x: rect.left + rect.width, y: rect.top}, + {x: rect.left + rect.width, y: rect.top + rect.bottom}, + {x: rect.left, y: rect.top + rect.bottom}, + ]; + const padding = transformQuadWithOffsets(border, offsets.border); + const content = transformQuadWithOffsets(padding, offsets.padding); + const margin = transformQuadWithOffsets(border, offsets.margin); + return { + content, + padding, + border, + margin, + width: rect.width, + height: rect.height, + }; + + function transformQuadWithOffsets( + quad: Quad, + offsets: {top: number; left: number; right: number; bottom: number} + ): Quad { + return [ + { + x: quad[0].x + offsets.left, + y: quad[0].y + offsets.top, + }, + { + x: quad[1].x - offsets.right, + y: quad[1].y + offsets.top, + }, + { + x: quad[2].x - offsets.right, + y: quad[2].y - offsets.bottom, + }, + { + x: quad[3].x + offsets.left, + y: quad[3].y - offsets.bottom, + }, + ]; + } + }); + if (!model) { + return null; + } + const offset = await this.#getTopLeftCornerOfFrame(); + if (!offset) { + return null; + } + for (const attribute of [ + 'content', + 'padding', + 'border', + 'margin', + ] as const) { + for (const point of model[attribute]) { + point.x += offset.x; + point.y += offset.y; + } + } + return model; + } + + async #getTopLeftCornerOfFrame() { + const point = {x: 0, y: 0}; + let frame = this.frame; + let parentFrame: Frame | null | undefined; + while ((parentFrame = frame?.parentFrame())) { + using handle = await frame.frameElement(); + if (!handle) { + throw new Error('Unsupported frame type'); + } + const parentBox = await handle.evaluate(element => { + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + left: + rect.left + + parseInt(style.paddingLeft, 10) + + parseInt(style.borderLeftWidth, 10), + top: + rect.top + + parseInt(style.paddingTop, 10) + + parseInt(style.borderTopWidth, 10), + }; + }); + if (!parentBox) { + return null; + } + point.x += parentBox.left; + point.y += parentBox.top; + frame = parentFrame; + } + return point; + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Page.(screenshot:2) } to take a screenshot of the element. + * If the element is detached from DOM, the method throws an error. + */ + async screenshot( + options: Readonly<ScreenshotOptions> & {encoding: 'base64'} + ): Promise<string>; + async screenshot(options?: Readonly<ScreenshotOptions>): Promise<Buffer>; + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async screenshot( + this: ElementHandle<Element>, + options: Readonly<ElementScreenshotOptions> = {} + ): Promise<string | Buffer> { + const {scrollIntoView = true} = options; + + let clip = await this.#nonEmptyVisibleBoundingBox(); + + const page = this.frame.page(); + + // If the element is larger than the viewport, `captureBeyondViewport` will + // _not_ affect element rendering, so we need to adjust the viewport to + // properly render the element. + const viewport = page.viewport() ?? { + width: clip.width, + height: clip.height, + }; + await using stack = new AsyncDisposableStack(); + if (clip.width > viewport.width || clip.height > viewport.height) { + await this.frame.page().setViewport({ + ...viewport, + width: Math.max(viewport.width, Math.ceil(clip.width)), + height: Math.max(viewport.height, Math.ceil(clip.height)), + }); + + stack.defer(async () => { + try { + await this.frame.page().setViewport(viewport); + } catch (error) { + debugError(error); + } + }); + } + + // Only scroll the element into view if the user wants it. + if (scrollIntoView) { + await this.scrollIntoViewIfNeeded(); + + // We measure again just in case. + clip = await this.#nonEmptyVisibleBoundingBox(); + } + + const [pageLeft, pageTop] = await this.evaluate(() => { + if (!window.visualViewport) { + throw new Error('window.visualViewport is not supported.'); + } + return [ + window.visualViewport.pageLeft, + window.visualViewport.pageTop, + ] as const; + }); + clip.x += pageLeft; + clip.y += pageTop; + + return await page.screenshot({...options, clip}); + } + + async #nonEmptyVisibleBoundingBox() { + const box = await this.boundingBox(); + assert(box, 'Node is either not visible or not an HTMLElement'); + assert(box.width !== 0, 'Node has 0 width.'); + assert(box.height !== 0, 'Node has 0 height.'); + return box; + } + + /** + * @internal + */ + protected async assertConnectedElement(): Promise<void> { + const error = await this.evaluate(async element => { + if (!element.isConnected) { + return 'Node is detached from document'; + } + if (element.nodeType !== Node.ELEMENT_NODE) { + return 'Node is not of type HTMLElement'; + } + return; + }); + + if (error) { + throw new Error(error); + } + } + + /** + * @internal + */ + protected async scrollIntoViewIfNeeded( + this: ElementHandle<Element> + ): Promise<void> { + if ( + await this.isIntersectingViewport({ + threshold: 1, + }) + ) { + return; + } + await this.scrollIntoView(); + } + + /** + * Resolves to true if the element is visible in the current viewport. If an + * element is an SVG, we check if the svg owner element is in the viewport + * instead. See https://crbug.com/963246. + * + * @param options - Threshold for the intersection between 0 (no intersection) and 1 + * (full intersection). Defaults to 1. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async isIntersectingViewport( + this: ElementHandle<Element>, + options: { + threshold?: number; + } = {} + ): Promise<boolean> { + await this.assertConnectedElement(); + // eslint-disable-next-line rulesdir/use-using -- Returns `this`. + const handle = await this.#asSVGElementHandle(); + using target = handle && (await handle.#getOwnerSVGElement()); + return await ((target ?? this) as ElementHandle<Element>).evaluate( + async (element, threshold) => { + const visibleRatio = await new Promise<number>(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0]!.intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + }); + return threshold === 1 ? visibleRatio === 1 : visibleRatio > threshold; + }, + options.threshold ?? 0 + ); + } + + /** + * Scrolls the element into view using either the automation protocol client + * or by calling element.scrollIntoView. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async scrollIntoView(this: ElementHandle<Element>): Promise<void> { + await this.assertConnectedElement(); + await this.evaluate(async (element): Promise<void> => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'instant', + }); + }); + } + + /** + * Returns true if an element is an SVGElement (included svg, path, rect + * etc.). + */ + async #asSVGElementHandle( + this: ElementHandle<Element> + ): Promise<ElementHandle<SVGElement> | null> { + if ( + await this.evaluate(element => { + return element instanceof SVGElement; + }) + ) { + return this as ElementHandle<SVGElement>; + } else { + return null; + } + } + + async #getOwnerSVGElement( + this: ElementHandle<SVGElement> + ): Promise<ElementHandle<SVGSVGElement>> { + // SVGSVGElement.ownerSVGElement === null. + return await this.evaluateHandle(element => { + if (element instanceof SVGSVGElement) { + return element; + } + return element.ownerSVGElement!; + }); + } + + /** + * If the element is a form input, you can use {@link ElementHandle.autofill} + * to test if the form is compatible with the browser's autofill + * implementation. Throws an error if the form cannot be autofilled. + * + * @remarks + * + * Currently, Puppeteer supports auto-filling credit card information only and + * in Chrome in the new headless and headful modes only. + * + * ```ts + * // Select an input on the credit card form. + * const name = await page.waitForSelector('form #name'); + * // Trigger autofill with the desired data. + * await name.autofill({ + * creditCard: { + * number: '4444444444444444', + * name: 'John Smith', + * expiryMonth: '01', + * expiryYear: '2030', + * cvc: '123', + * }, + * }); + * ``` + */ + abstract autofill(data: AutofillData): Promise<void>; +} + +/** + * @public + */ +export interface AutofillData { + creditCard: { + // See https://chromedevtools.github.io/devtools-protocol/tot/Autofill/#type-CreditCard. + number: string; + name: string; + expiryMonth: string; + expiryYear: string; + cvc: string; + }; +} + +function intersectBoundingBox( + box: BoundingBox, + width: number, + height: number +): void { + box.width = Math.max( + box.x >= 0 + ? Math.min(width - box.x, box.width) + : Math.min(width, box.width + box.x), + 0 + ); + box.height = Math.max( + box.y >= 0 + ? Math.min(height - box.y, box.height) + : Math.min(height, box.height + box.y), + 0 + ); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts new file mode 100644 index 0000000000..6e5087b773 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const _isElementHandle = Symbol('_isElementHandle'); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts new file mode 100644 index 0000000000..c5a8d73d00 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CDPSession} from './CDPSession.js'; +import type {Realm} from './Realm.js'; + +/** + * @internal + */ +export interface Environment { + get client(): CDPSession; + mainRealm(): Realm; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts new file mode 100644 index 0000000000..757ec872c6 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts @@ -0,0 +1,1218 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import type {ClickOptions, ElementHandle} from '../api/ElementHandle.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type { + Page, + WaitForSelectorOptions, + WaitTimeoutOptions, +} from '../api/Page.js'; +import type {DeviceRequestPrompt} from '../cdp/DeviceRequestPrompt.js'; +import type {IsolatedWorldChart} from '../cdp/IsolatedWorld.js'; +import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js'; +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {getQueryHandlerAndSelector} from '../common/GetQueryHandler.js'; +import {transposeIterableHandle} from '../common/HandleIterator.js'; +import {LazyArg} from '../common/LazyArg.js'; +import type { + Awaitable, + EvaluateFunc, + EvaluateFuncWith, + HandleFor, + NodeFor, +} from '../common/types.js'; +import { + importFSPromises, + withSourcePuppeteerURLIfNone, +} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {throwIfDisposed} from '../util/decorators.js'; + +import type {CDPSession} from './CDPSession.js'; +import type {KeyboardTypeOptions} from './Input.js'; +import { + FunctionLocator, + type Locator, + NodeLocator, +} from './locators/locators.js'; +import type {Realm} from './Realm.js'; + +/** + * @public + */ +export interface WaitForOptions { + /** + * Maximum wait time in milliseconds. Pass 0 to disable the timeout. + * + * The default value can be changed by using the + * {@link Page.setDefaultTimeout} or {@link Page.setDefaultNavigationTimeout} + * methods. + * + * @defaultValue `30000` + */ + timeout?: number; + /** + * When to consider waiting succeeds. Given an array of event strings, waiting + * is considered to be successful after all events have been fired. + * + * @defaultValue `'load'` + */ + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; +} + +/** + * @public + */ +export interface GoToOptions extends WaitForOptions { + /** + * If provided, it will take preference over the referer header value set by + * {@link Page.setExtraHTTPHeaders | page.setExtraHTTPHeaders()}. + */ + referer?: string; + /** + * If provided, it will take preference over the referer-policy header value + * set by {@link Page.setExtraHTTPHeaders | page.setExtraHTTPHeaders()}. + */ + referrerPolicy?: string; +} + +/** + * @public + */ +export interface FrameWaitForFunctionOptions { + /** + * An interval at which the `pageFunction` is executed, defaults to `raf`. If + * `polling` is a number, then it is treated as an interval in milliseconds at + * which the function would be executed. If `polling` is a string, then it can + * be one of the following values: + * + * - `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` + * callback. This is the tightest polling mode which is suitable to observe + * styling changes. + * + * - `mutation` - to execute `pageFunction` on every DOM mutation. + */ + polling?: 'raf' | 'mutation' | number; + /** + * Maximum time to wait in milliseconds. Defaults to `30000` (30 seconds). + * Pass `0` to disable the timeout. Puppeteer's default timeout can be changed + * using {@link Page.setDefaultTimeout}. + */ + timeout?: number; + /** + * A signal object that allows you to cancel a waitForFunction call. + */ + signal?: AbortSignal; +} + +/** + * @public + */ +export interface FrameAddScriptTagOptions { + /** + * URL of the script to be added. + */ + url?: string; + /** + * Path to a JavaScript file to be injected into the frame. + * + * @remarks + * If `path` is a relative path, it is resolved relative to the current + * working directory (`process.cwd()` in Node.js). + */ + path?: string; + /** + * JavaScript to be injected into the frame. + */ + content?: string; + /** + * Sets the `type` of the script. Use `module` in order to load an ES2015 module. + */ + type?: string; + /** + * Sets the `id` of the script. + */ + id?: string; +} + +/** + * @public + */ +export interface FrameAddStyleTagOptions { + /** + * the URL of the CSS file to be added. + */ + url?: string; + /** + * The path to a CSS file to be injected into the frame. + * @remarks + * If `path` is a relative path, it is resolved relative to the current + * working directory (`process.cwd()` in Node.js). + */ + path?: string; + /** + * Raw CSS content to be injected into the frame. + */ + content?: string; +} + +/** + * @public + */ +export interface FrameEvents extends Record<EventType, unknown> { + /** @internal */ + [FrameEvent.FrameNavigated]: Protocol.Page.NavigationType; + /** @internal */ + [FrameEvent.FrameSwapped]: undefined; + /** @internal */ + [FrameEvent.LifecycleEvent]: undefined; + /** @internal */ + [FrameEvent.FrameNavigatedWithinDocument]: undefined; + /** @internal */ + [FrameEvent.FrameDetached]: Frame; + /** @internal */ + [FrameEvent.FrameSwappedByActivation]: undefined; +} + +/** + * We use symbols to prevent external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace FrameEvent { + export const FrameNavigated = Symbol('Frame.FrameNavigated'); + export const FrameSwapped = Symbol('Frame.FrameSwapped'); + export const LifecycleEvent = Symbol('Frame.LifecycleEvent'); + export const FrameNavigatedWithinDocument = Symbol( + 'Frame.FrameNavigatedWithinDocument' + ); + export const FrameDetached = Symbol('Frame.FrameDetached'); + export const FrameSwappedByActivation = Symbol( + 'Frame.FrameSwappedByActivation' + ); +} + +/** + * @internal + */ +export const throwIfDetached = throwIfDisposed<Frame>(frame => { + return `Attempted to use detached Frame '${frame._id}'.`; +}); + +/** + * Represents a DOM frame. + * + * To understand frames, you can think of frames as `<iframe>` elements. Just + * like iframes, frames can be nested, and when JavaScript is executed in a + * frame, the JavaScript does not effect frames inside the ambient frame the + * JavaScript executes in. + * + * @example + * At any point in time, {@link Page | pages} expose their current frame + * tree via the {@link Page.mainFrame} and {@link Frame.childFrames} methods. + * + * @example + * An example of dumping frame tree: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com/chrome/browser/canary.html'); + * dumpFrameTree(page.mainFrame(), ''); + * await browser.close(); + * + * function dumpFrameTree(frame, indent) { + * console.log(indent + frame.url()); + * for (const child of frame.childFrames()) { + * dumpFrameTree(child, indent + ' '); + * } + * } + * })(); + * ``` + * + * @example + * An example of getting text from an iframe element: + * + * ```ts + * const frame = page.frames().find(frame => frame.name() === 'myframe'); + * const text = await frame.$eval('.selector', element => element.textContent); + * console.log(text); + * ``` + * + * @remarks + * Frame lifecycles are controlled by three events that are all dispatched on + * the parent {@link Frame.page | page}: + * + * - {@link PageEvent.FrameAttached} + * - {@link PageEvent.FrameNavigated} + * - {@link PageEvent.FrameDetached} + * + * @public + */ +export abstract class Frame extends EventEmitter<FrameEvents> { + /** + * @internal + */ + _id!: string; + /** + * @internal + */ + _parentId?: string; + + /** + * @internal + */ + worlds!: IsolatedWorldChart; + + /** + * @internal + */ + _name?: string; + + /** + * @internal + */ + _hasStartedLoading = false; + + /** + * @internal + */ + constructor() { + super(); + } + + /** + * The page associated with the frame. + */ + abstract page(): Page; + + /** + * Is `true` if the frame is an out-of-process (OOP) frame. Otherwise, + * `false`. + */ + abstract isOOPFrame(): boolean; + + /** + * Navigates the frame to the given `url`. + * + * @remarks + * Navigation to `about:blank` or navigation to the same URL with a different + * hash will succeed and return `null`. + * + * :::warning + * + * Headless mode doesn't support navigation to a PDF document. See the {@link + * https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream + * issue}. + * + * ::: + * + * @param url - URL to navigate the frame to. The URL should include scheme, + * e.g. `https://` + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. + * @throws If: + * + * - there's an SSL error (e.g. in case of self-signed certificates). + * - target URL is invalid. + * - the timeout is exceeded during navigation. + * - the remote server does not respond or is unreachable. + * - the main resource failed to load. + * + * This method will not throw an error when any valid HTTP status code is + * returned by the remote server, including 404 "Not Found" and 500 "Internal + * Server Error". The status code for such responses can be retrieved by + * calling {@link HTTPResponse.status}. + */ + abstract goto( + url: string, + options?: { + referer?: string; + referrerPolicy?: string; + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } + ): Promise<HTTPResponse | null>; + + /** + * Waits for the frame to navigate. It is useful for when you run code which + * will indirectly cause the frame to navigate. + * + * Usage of the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API} + * to change the URL is considered a navigation. + * + * @example + * + * ```ts + * const [response] = await Promise.all([ + * // The navigation promise resolves after navigation has finished + * frame.waitForNavigation(), + * // Clicking the link will indirectly cause a navigation + * frame.click('a.my-link'), + * ]); + * ``` + * + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves to the main resource response. + */ + abstract waitForNavigation( + options?: WaitForOptions + ): Promise<HTTPResponse | null>; + + /** + * @internal + */ + abstract get client(): CDPSession; + + /** + * @internal + */ + abstract mainRealm(): Realm; + + /** + * @internal + */ + abstract isolatedRealm(): Realm; + + #_document: Promise<ElementHandle<Document>> | undefined; + + /** + * @internal + */ + #document(): Promise<ElementHandle<Document>> { + if (!this.#_document) { + this.#_document = this.isolatedRealm() + .evaluateHandle(() => { + return document; + }) + .then(handle => { + return this.mainRealm().transferHandle(handle); + }); + } + return this.#_document; + } + + /** + * Used to clear the document handle that has been destroyed. + * + * @internal + */ + clearDocumentHandle(): void { + this.#_document = undefined; + } + + /** + * @internal + */ + @throwIfDetached + async frameElement(): Promise<HandleFor<HTMLIFrameElement> | null> { + const parentFrame = this.parentFrame(); + if (!parentFrame) { + return null; + } + using list = await parentFrame.isolatedRealm().evaluateHandle(() => { + return document.querySelectorAll('iframe'); + }); + for await (using iframe of transposeIterableHandle(list)) { + const frame = await iframe.contentFrame(); + if (frame._id === this._id) { + return iframe.move(); + } + } + return null; + } + + /** + * Behaves identically to {@link Page.evaluateHandle} except it's run within + * the context of this frame. + * + * @see {@link Page.evaluateHandle} for details. + */ + @throwIfDetached + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + return await this.mainRealm().evaluateHandle(pageFunction, ...args); + } + + /** + * Behaves identically to {@link Page.evaluate} except it's run within the + * the context of this frame. + * + * @see {@link Page.evaluate} for details. + */ + @throwIfDetached + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + return await this.mainRealm().evaluate(pageFunction, ...args); + } + + /** + * Creates a locator for the provided selector. See {@link Locator} for + * details and supported actions. + * + * @remarks + * Locators API is experimental and we will not follow semver for breaking + * change in the Locators API. + */ + locator<Selector extends string>( + selector: Selector + ): Locator<NodeFor<Selector>>; + + /** + * Creates a locator for the provided function. See {@link Locator} for + * details and supported actions. + * + * @remarks + * Locators API is experimental and we will not follow semver for breaking + * change in the Locators API. + */ + locator<Ret>(func: () => Awaitable<Ret>): Locator<Ret>; + + /** + * @internal + */ + @throwIfDetached + locator<Selector extends string, Ret>( + selectorOrFunc: Selector | (() => Awaitable<Ret>) + ): Locator<NodeFor<Selector>> | Locator<Ret> { + if (typeof selectorOrFunc === 'string') { + return NodeLocator.create(this, selectorOrFunc); + } else { + return FunctionLocator.create(this, selectorOrFunc); + } + } + /** + * Queries the frame for an element matching the given selector. + * + * @param selector - The selector to query for. + * @returns A {@link ElementHandle | element handle} to the first element + * matching the given selector. Otherwise, `null`. + */ + @throwIfDetached + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$(selector); + } + + /** + * Queries the frame for all elements matching the given selector. + * + * @param selector - The selector to query for. + * @returns An array of {@link ElementHandle | element handles} that point to + * elements matching the given selector. + */ + @throwIfDetached + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$$(selector); + } + + /** + * Runs the given function on the first element matching the given selector in + * the frame. + * + * If the given function returns a promise, then this method will wait till + * the promise resolves. + * + * @example + * + * ```ts + * const searchValue = await frame.$eval('#search', el => el.value); + * ``` + * + * @param selector - The selector to query for. + * @param pageFunction - The function to be evaluated in the frame's context. + * The first element matching the selector will be passed to the function as + * its first argument. + * @param args - Additional arguments to pass to `pageFunction`. + * @returns A promise to the result of the function. + */ + @throwIfDetached + async $eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith< + NodeFor<Selector>, + Params + >, + >( + selector: Selector, + pageFunction: string | Func, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction); + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$eval(selector, pageFunction, ...args); + } + + /** + * Runs the given function on an array of elements matching the given selector + * in the frame. + * + * If the given function returns a promise, then this method will wait till + * the promise resolves. + * + * @example + * + * ```ts + * const divsCounts = await frame.$$eval('div', divs => divs.length); + * ``` + * + * @param selector - The selector to query for. + * @param pageFunction - The function to be evaluated in the frame's context. + * An array of elements matching the given selector will be passed to the + * function as its first argument. + * @param args - Additional arguments to pass to `pageFunction`. + * @returns A promise to the result of the function. + */ + @throwIfDetached + async $$eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith< + Array<NodeFor<Selector>>, + Params + > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>, + >( + selector: Selector, + pageFunction: string | Func, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$$eval(selector, pageFunction, ...args); + } + + /** + * @deprecated Use {@link Frame.$$} with the `xpath` prefix. + * + * Example: `await frame.$$('xpath/' + xpathExpression)` + * + * This method evaluates the given XPath expression and returns the results. + * If `xpath` starts with `//` instead of `.//`, the dot will be appended + * automatically. + * @param expression - the XPath expression to evaluate. + */ + @throwIfDetached + async $x(expression: string): Promise<Array<ElementHandle<Node>>> { + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$x(expression); + } + + /** + * Waits for an element matching the given selector to appear in the frame. + * + * This method works across navigations. + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page + * .mainFrame() + * .waitForSelector('img') + * .then(() => console.log('First URL with image: ' + currentURL)); + * + * for (currentURL of [ + * 'https://example.com', + * 'https://google.com', + * 'https://bbc.com', + * ]) { + * await page.goto(currentURL); + * } + * await browser.close(); + * })(); + * ``` + * + * @param selector - The selector to query and wait for. + * @param options - Options for customizing waiting behavior. + * @returns An element matching the given selector. + * @throws Throws if an element matching the given selector doesn't appear. + */ + @throwIfDetached + async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.waitFor( + this, + updatedSelector, + options + )) as ElementHandle<NodeFor<Selector>> | null; + } + + /** + * @deprecated Use {@link Frame.waitForSelector} with the `xpath` prefix. + * + * Example: `await frame.waitForSelector('xpath/' + xpathExpression)` + * + * The method evaluates the XPath expression relative to the Frame. + * If `xpath` starts with `//` instead of `.//`, the dot will be appended + * automatically. + * + * Wait for the `xpath` to appear in page. If at the moment of calling the + * method the `xpath` already exists, the method will return immediately. If + * the xpath doesn't appear after the `timeout` milliseconds of waiting, the + * function will throw. + * + * For a code example, see the example for {@link Frame.waitForSelector}. That + * function behaves identically other than taking a CSS selector rather than + * an XPath. + * + * @param xpath - the XPath expression to wait for. + * @param options - options to configure the visibility of the element and how + * long to wait before timing out. + */ + @throwIfDetached + async waitForXPath( + xpath: string, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<Node> | null> { + if (xpath.startsWith('//')) { + xpath = `.${xpath}`; + } + return await this.waitForSelector(`xpath/${xpath}`, options); + } + + /** + * @example + * The `waitForFunction` can be used to observe viewport size change: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * . const browser = await puppeteer.launch(); + * . const page = await browser.newPage(); + * . const watchDog = page.mainFrame().waitForFunction('window.innerWidth < 100'); + * . page.setViewport({width: 50, height: 50}); + * . await watchDog; + * . await browser.close(); + * })(); + * ``` + * + * To pass arguments from Node.js to the predicate of `page.waitForFunction` function: + * + * ```ts + * const selector = '.foo'; + * await frame.waitForFunction( + * selector => !!document.querySelector(selector), + * {}, // empty options object + * selector + * ); + * ``` + * + * @param pageFunction - the function to evaluate in the frame context. + * @param options - options to configure the polling method and timeout. + * @param args - arguments to pass to the `pageFunction`. + * @returns the promise which resolve when the `pageFunction` returns a truthy value. + */ + @throwIfDetached + async waitForFunction< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + options: FrameWaitForFunctionOptions = {}, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return await (this.mainRealm().waitForFunction( + pageFunction, + options, + ...args + ) as Promise<HandleFor<Awaited<ReturnType<Func>>>>); + } + /** + * The full HTML contents of the frame, including the DOCTYPE. + */ + @throwIfDetached + async content(): Promise<string> { + return await this.evaluate(() => { + let content = ''; + for (const node of document.childNodes) { + switch (node) { + case document.documentElement: + content += document.documentElement.outerHTML; + break; + default: + content += new XMLSerializer().serializeToString(node); + break; + } + } + + return content; + }); + } + + /** + * Set the content of the frame. + * + * @param html - HTML markup to assign to the page. + * @param options - Options to configure how long before timing out and at + * what point to consider the content setting successful. + */ + abstract setContent( + html: string, + options?: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } + ): Promise<void>; + + /** + * @internal + */ + async setFrameContent(content: string): Promise<void> { + return await this.evaluate(html => { + document.open(); + document.write(html); + document.close(); + }, content); + } + + /** + * The frame's `name` attribute as specified in the tag. + * + * @remarks + * If the name is empty, it returns the `id` attribute instead. + * + * @remarks + * This value is calculated once when the frame is created, and will not + * update if the attribute is changed later. + */ + name(): string { + return this._name || ''; + } + + /** + * The frame's URL. + */ + abstract url(): string; + + /** + * The parent frame, if any. Detached and main frames return `null`. + */ + abstract parentFrame(): Frame | null; + + /** + * An array of child frames. + */ + abstract childFrames(): Frame[]; + + /** + * @returns `true` if the frame has detached. `false` otherwise. + */ + abstract get detached(): boolean; + + /** + * Is`true` if the frame has been detached. Otherwise, `false`. + * + * @deprecated Use the `detached` getter. + */ + isDetached(): boolean { + return this.detached; + } + + /** + * @internal + */ + get disposed(): boolean { + return this.detached; + } + + /** + * Adds a `<script>` tag into the page with the desired url or content. + * + * @param options - Options for the script. + * @returns An {@link ElementHandle | element handle} to the injected + * `<script>` element. + */ + @throwIfDetached + async addScriptTag( + options: FrameAddScriptTagOptions + ): Promise<ElementHandle<HTMLScriptElement>> { + let {content = '', type} = options; + const {path} = options; + if (+!!options.url + +!!path + +!!content !== 1) { + throw new Error( + 'Exactly one of `url`, `path`, or `content` must be specified.' + ); + } + + if (path) { + const fs = await importFSPromises(); + content = await fs.readFile(path, 'utf8'); + content += `//# sourceURL=${path.replace(/\n/g, '')}`; + } + + type = type ?? 'text/javascript'; + + return await this.mainRealm().transferHandle( + await this.isolatedRealm().evaluateHandle( + async ({Deferred}, {url, id, type, content}) => { + const deferred = Deferred.create<void>(); + const script = document.createElement('script'); + script.type = type; + script.text = content; + if (url) { + script.src = url; + script.addEventListener( + 'load', + () => { + return deferred.resolve(); + }, + {once: true} + ); + script.addEventListener( + 'error', + event => { + deferred.reject( + new Error(event.message ?? 'Could not load script') + ); + }, + {once: true} + ); + } else { + deferred.resolve(); + } + if (id) { + script.id = id; + } + document.head.appendChild(script); + await deferred.valueOrThrow(); + return script; + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + {...options, type, content} + ) + ); + } + + /** + * Adds a `HTMLStyleElement` into the frame with the desired URL + * + * @returns An {@link ElementHandle | element handle} to the loaded `<style>` + * element. + */ + async addStyleTag( + options: Omit<FrameAddStyleTagOptions, 'url'> + ): Promise<ElementHandle<HTMLStyleElement>>; + + /** + * Adds a `HTMLLinkElement` into the frame with the desired URL + * + * @returns An {@link ElementHandle | element handle} to the loaded `<link>` + * element. + */ + async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLLinkElement>>; + + /** + * @internal + */ + @throwIfDetached + async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> { + let {content = ''} = options; + const {path} = options; + if (+!!options.url + +!!path + +!!content !== 1) { + throw new Error( + 'Exactly one of `url`, `path`, or `content` must be specified.' + ); + } + + if (path) { + const fs = await importFSPromises(); + + content = await fs.readFile(path, 'utf8'); + content += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/'; + options.content = content; + } + + return await this.mainRealm().transferHandle( + await this.isolatedRealm().evaluateHandle( + async ({Deferred}, {url, content}) => { + const deferred = Deferred.create<void>(); + let element: HTMLStyleElement | HTMLLinkElement; + if (!url) { + element = document.createElement('style'); + element.appendChild(document.createTextNode(content!)); + } else { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + element = link; + } + element.addEventListener( + 'load', + () => { + deferred.resolve(); + }, + {once: true} + ); + element.addEventListener( + 'error', + event => { + deferred.reject( + new Error( + (event as ErrorEvent).message ?? 'Could not load style' + ) + ); + }, + {once: true} + ); + document.head.appendChild(element); + await deferred.valueOrThrow(); + return element; + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + options + ) + ); + } + + /** + * Clicks the first element found that matches `selector`. + * + * @remarks + * If `click()` triggers a navigation event and there's a separate + * `page.waitForNavigation()` promise to be resolved, you may end up with a + * race condition that yields unexpected results. The correct pattern for + * click and wait for navigation is the following: + * + * ```ts + * const [response] = await Promise.all([ + * page.waitForNavigation(waitOptions), + * frame.click(selector, clickOptions), + * ]); + * ``` + * + * @param selector - The selector to query for. + */ + @throwIfDetached + async click( + selector: string, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.click(options); + await handle.dispose(); + } + + /** + * Focuses the first element that matches the `selector`. + * + * @param selector - The selector to query for. + * @throws Throws if there's no element matching `selector`. + */ + @throwIfDetached + async focus(selector: string): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.focus(); + } + + /** + * Hovers the pointer over the center of the first element that matches the + * `selector`. + * + * @param selector - The selector to query for. + * @throws Throws if there's no element matching `selector`. + */ + @throwIfDetached + async hover(selector: string): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.hover(); + } + + /** + * Selects a set of value on the first `<select>` element that matches the + * `selector`. + * + * @example + * + * ```ts + * frame.select('select#colors', 'blue'); // single selection + * frame.select('select#colors', 'red', 'green', 'blue'); // multiple selections + * ``` + * + * @param selector - The selector to query for. + * @param values - The array of values to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first + * one is taken into account. + * @returns the list of values that were successfully selected. + * @throws Throws if there's no `<select>` matching `selector`. + */ + @throwIfDetached + async select(selector: string, ...values: string[]): Promise<string[]> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + return await handle.select(...values); + } + + /** + * Taps the first element that matches the `selector`. + * + * @param selector - The selector to query for. + * @throws Throws if there's no element matching `selector`. + */ + @throwIfDetached + async tap(selector: string): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.tap(); + } + + /** + * Sends a `keydown`, `keypress`/`input`, and `keyup` event for each character + * in the text. + * + * @remarks + * To press a special key, like `Control` or `ArrowDown`, use + * {@link Keyboard.press}. + * + * @example + * + * ```ts + * await frame.type('#mytextarea', 'Hello'); // Types instantly + * await frame.type('#mytextarea', 'World', {delay: 100}); // Types slower, like a user + * ``` + * + * @param selector - the selector for the element to type into. If there are + * multiple the first will be used. + * @param text - text to type into the element + * @param options - takes one option, `delay`, which sets the time to wait + * between key presses in milliseconds. Defaults to `0`. + */ + @throwIfDetached + async type( + selector: string, + text: string, + options?: Readonly<KeyboardTypeOptions> + ): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.type(text, options); + } + + /** + * @deprecated Replace with `new Promise(r => setTimeout(r, milliseconds));`. + * + * Causes your script to wait for the given number of milliseconds. + * + * @remarks + * It's generally recommended to not wait for a number of seconds, but instead + * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or + * {@link Frame.waitForFunction} to wait for exactly the conditions you want. + * + * @example + * + * Wait for 1 second: + * + * ```ts + * await frame.waitForTimeout(1000); + * ``` + * + * @param milliseconds - the number of milliseconds to wait. + */ + async waitForTimeout(milliseconds: number): Promise<void> { + return await new Promise(resolve => { + setTimeout(resolve, milliseconds); + }); + } + + /** + * The frame's title. + */ + @throwIfDetached + async title(): Promise<string> { + return await this.isolatedRealm().evaluate(() => { + return document.title; + }); + } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * frame.waitForDevicePrompt(), + * frame.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + * + * @internal + */ + abstract waitForDevicePrompt( + options?: WaitTimeoutOptions + ): Promise<DeviceRequestPrompt>; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts new file mode 100644 index 0000000000..3c952371ee --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts @@ -0,0 +1,521 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from './CDPSession.js'; +import type {Frame} from './Frame.js'; +import type {HTTPResponse} from './HTTPResponse.js'; + +/** + * @public + */ +export interface ContinueRequestOverrides { + /** + * If set, the request URL will change. This is not a redirect. + */ + url?: string; + method?: string; + postData?: string; + headers?: Record<string, string>; +} + +/** + * @public + */ +export interface InterceptResolutionState { + action: InterceptResolutionAction; + priority?: number; +} + +/** + * Required response data to fulfill a request with. + * + * @public + */ +export interface ResponseForRequest { + status: number; + /** + * Optional response headers. All values are converted to strings. + */ + headers: Record<string, unknown>; + contentType: string; + body: string | Buffer; +} + +/** + * Resource types for HTTPRequests as perceived by the rendering engine. + * + * @public + */ +export type ResourceType = Lowercase<Protocol.Network.ResourceType>; + +/** + * The default cooperative request interception resolution priority + * + * @public + */ +export const DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0; + +/** + * Represents an HTTP request sent by a page. + * @remarks + * + * Whenever the page sends a request, such as for a network resource, the + * following events are emitted by Puppeteer's `page`: + * + * - `request`: emitted when the request is issued by the page. + * - `requestfinished` - emitted when the response body is downloaded and the + * request is complete. + * + * If request fails at some point, then instead of `requestfinished` event the + * `requestfailed` event is emitted. + * + * All of these events provide an instance of `HTTPRequest` representing the + * request that occurred: + * + * ``` + * page.on('request', request => ...) + * ``` + * + * NOTE: HTTP Error responses, such as 404 or 503, are still successful + * responses from HTTP standpoint, so request will complete with + * `requestfinished` event. + * + * If request gets a 'redirect' response, the request is successfully finished + * with the `requestfinished` event, and a new request is issued to a + * redirected url. + * + * @public + */ +export abstract class HTTPRequest { + /** + * @internal + */ + _requestId = ''; + /** + * @internal + */ + _interceptionId: string | undefined; + /** + * @internal + */ + _failureText: string | null = null; + /** + * @internal + */ + _response: HTTPResponse | null = null; + /** + * @internal + */ + _fromMemoryCache = false; + /** + * @internal + */ + _redirectChain: HTTPRequest[] = []; + + /** + * Warning! Using this client can break Puppeteer. Use with caution. + * + * @experimental + */ + abstract get client(): CDPSession; + + /** + * @internal + */ + constructor() {} + + /** + * The URL of the request + */ + abstract url(): string; + + /** + * The `ContinueRequestOverrides` that will be used + * if the interception is allowed to continue (ie, `abort()` and + * `respond()` aren't called). + */ + abstract continueRequestOverrides(): ContinueRequestOverrides; + + /** + * The `ResponseForRequest` that gets used if the + * interception is allowed to respond (ie, `abort()` is not called). + */ + abstract responseForRequest(): Partial<ResponseForRequest> | null; + + /** + * The most recent reason for aborting the request + */ + abstract abortErrorReason(): Protocol.Network.ErrorReason | null; + + /** + * An InterceptResolutionState object describing the current resolution + * action and priority. + * + * InterceptResolutionState contains: + * action: InterceptResolutionAction + * priority?: number + * + * InterceptResolutionAction is one of: `abort`, `respond`, `continue`, + * `disabled`, `none`, or `already-handled`. + */ + abstract interceptResolutionState(): InterceptResolutionState; + + /** + * Is `true` if the intercept resolution has already been handled, + * `false` otherwise. + */ + abstract isInterceptResolutionHandled(): boolean; + + /** + * Adds an async request handler to the processing queue. + * Deferred handlers are not guaranteed to execute in any particular order, + * but they are guaranteed to resolve before the request interception + * is finalized. + */ + abstract enqueueInterceptAction( + pendingHandler: () => void | PromiseLike<unknown> + ): void; + + /** + * Awaits pending interception handlers and then decides how to fulfill + * the request interception. + */ + abstract finalizeInterceptions(): Promise<void>; + + /** + * Contains the request's resource type as it was perceived by the rendering + * engine. + */ + abstract resourceType(): ResourceType; + + /** + * The method used (`GET`, `POST`, etc.) + */ + abstract method(): string; + + /** + * The request's post body, if any. + */ + abstract postData(): string | undefined; + + /** + * True when the request has POST data. Note that {@link HTTPRequest.postData} + * might still be undefined when this flag is true when the data is too long + * or not readily available in the decoded form. In that case, use + * {@link HTTPRequest.fetchPostData}. + */ + abstract hasPostData(): boolean; + + /** + * Fetches the POST data for the request from the browser. + */ + abstract fetchPostData(): Promise<string | undefined>; + + /** + * An object with HTTP headers associated with the request. All + * header names are lower-case. + */ + abstract headers(): Record<string, string>; + + /** + * A matching `HTTPResponse` object, or null if the response has not + * been received yet. + */ + abstract response(): HTTPResponse | null; + + /** + * The frame that initiated the request, or null if navigating to + * error pages. + */ + abstract frame(): Frame | null; + + /** + * True if the request is the driver of the current frame's navigation. + */ + abstract isNavigationRequest(): boolean; + + /** + * The initiator of the request. + */ + abstract initiator(): Protocol.Network.Initiator | undefined; + + /** + * A `redirectChain` is a chain of requests initiated to fetch a resource. + * @remarks + * + * `redirectChain` is shared between all the requests of the same chain. + * + * For example, if the website `http://example.com` has a single redirect to + * `https://example.com`, then the chain will contain one request: + * + * ```ts + * const response = await page.goto('http://example.com'); + * const chain = response.request().redirectChain(); + * console.log(chain.length); // 1 + * console.log(chain[0].url()); // 'http://example.com' + * ``` + * + * If the website `https://google.com` has no redirects, then the chain will be empty: + * + * ```ts + * const response = await page.goto('https://google.com'); + * const chain = response.request().redirectChain(); + * console.log(chain.length); // 0 + * ``` + * + * @returns the chain of requests - if a server responds with at least a + * single redirect, this chain will contain all requests that were redirected. + */ + abstract redirectChain(): HTTPRequest[]; + + /** + * Access information about the request's failure. + * + * @remarks + * + * @example + * + * Example of logging all failed requests: + * + * ```ts + * page.on('requestfailed', request => { + * console.log(request.url() + ' ' + request.failure().errorText); + * }); + * ``` + * + * @returns `null` unless the request failed. If the request fails this can + * return an object with `errorText` containing a human-readable error + * message, e.g. `net::ERR_FAILED`. It is not guaranteed that there will be + * failure text if the request fails. + */ + abstract failure(): {errorText: string} | null; + + /** + * Continues request with optional request overrides. + * + * @example + * + * ```ts + * await page.setRequestInterception(true); + * page.on('request', request => { + * // Override headers + * const headers = Object.assign({}, request.headers(), { + * foo: 'bar', // set "foo" header + * origin: undefined, // remove "origin" header + * }); + * request.continue({headers}); + * }); + * ``` + * + * @param overrides - optional overrides to apply to the request. + * @param priority - If provided, intercept is resolved using cooperative + * handling rules. Otherwise, intercept is resolved immediately. + * + * @remarks + * + * To use this, request interception should be enabled with + * {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + */ + abstract continue( + overrides?: ContinueRequestOverrides, + priority?: number + ): Promise<void>; + + /** + * Fulfills a request with the given response. + * + * @example + * An example of fulfilling all requests with 404 responses: + * + * ```ts + * await page.setRequestInterception(true); + * page.on('request', request => { + * request.respond({ + * status: 404, + * contentType: 'text/plain', + * body: 'Not Found!', + * }); + * }); + * ``` + * + * NOTE: Mocking responses for dataURL requests is not supported. + * Calling `request.respond` for a dataURL request is a noop. + * + * @param response - the response to fulfill the request with. + * @param priority - If provided, intercept is resolved using + * cooperative handling rules. Otherwise, intercept is resolved + * immediately. + * + * @remarks + * + * To use this, request + * interception should be enabled with {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + */ + abstract respond( + response: Partial<ResponseForRequest>, + priority?: number + ): Promise<void>; + + /** + * Aborts a request. + * + * @param errorCode - optional error code to provide. + * @param priority - If provided, intercept is resolved using + * cooperative handling rules. Otherwise, intercept is resolved + * immediately. + * + * @remarks + * + * To use this, request interception should be enabled with + * {@link Page.setRequestInterception}. If it is not enabled, this method will + * throw an exception immediately. + */ + abstract abort(errorCode?: ErrorCode, priority?: number): Promise<void>; +} + +/** + * @public + */ +export enum InterceptResolutionAction { + Abort = 'abort', + Respond = 'respond', + Continue = 'continue', + Disabled = 'disabled', + None = 'none', + AlreadyHandled = 'already-handled', +} + +/** + * @public + * + * @deprecated please use {@link InterceptResolutionAction} instead. + */ +export type InterceptResolutionStrategy = InterceptResolutionAction; + +/** + * @public + */ +export type ErrorCode = + | 'aborted' + | 'accessdenied' + | 'addressunreachable' + | 'blockedbyclient' + | 'blockedbyresponse' + | 'connectionaborted' + | 'connectionclosed' + | 'connectionfailed' + | 'connectionrefused' + | 'connectionreset' + | 'internetdisconnected' + | 'namenotresolved' + | 'timedout' + | 'failed'; + +/** + * @public + */ +export type ActionResult = 'continue' | 'abort' | 'respond'; + +/** + * @internal + */ +export function headersArray( + headers: Record<string, string | string[]> +): Array<{name: string; value: string}> { + const result = []; + for (const name in headers) { + const value = headers[name]; + + if (!Object.is(value, undefined)) { + const values = Array.isArray(value) ? value : [value]; + + result.push( + ...values.map(value => { + return {name, value: value + ''}; + }) + ); + } + } + return result; +} + +/** + * @internal + * + * @remarks + * List taken from {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml} + * with extra 306 and 418 codes. + */ +export const STATUS_TEXTS: Record<string, string> = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '103': 'Early Hints', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '226': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Switch Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': "I'm a teapot", + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '425': 'Too Early', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '510': 'Not Extended', + '511': 'Network Authentication Required', +} as const; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts new file mode 100644 index 0000000000..906479eb43 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import type {SecurityDetails} from '../common/SecurityDetails.js'; + +import type {Frame} from './Frame.js'; +import type {HTTPRequest} from './HTTPRequest.js'; + +/** + * @public + */ +export interface RemoteAddress { + ip?: string; + port?: number; +} + +/** + * The HTTPResponse class represents responses which are received by the + * {@link Page} class. + * + * @public + */ +export abstract class HTTPResponse { + /** + * @internal + */ + constructor() {} + + /** + * The IP address and port number used to connect to the remote + * server. + */ + abstract remoteAddress(): RemoteAddress; + + /** + * The URL of the response. + */ + abstract url(): string; + + /** + * True if the response was successful (status in the range 200-299). + */ + ok(): boolean { + // TODO: document === 0 case? + const status = this.status(); + return status === 0 || (status >= 200 && status <= 299); + } + + /** + * The status code of the response (e.g., 200 for a success). + */ + abstract status(): number; + + /** + * The status text of the response (e.g. usually an "OK" for a + * success). + */ + abstract statusText(): string; + + /** + * An object with HTTP headers associated with the response. All + * header names are lower-case. + */ + abstract headers(): Record<string, string>; + + /** + * {@link SecurityDetails} if the response was received over the + * secure connection, or `null` otherwise. + */ + abstract securityDetails(): SecurityDetails | null; + + /** + * Timing information related to the response. + */ + abstract timing(): Protocol.Network.ResourceTiming | null; + + /** + * Promise which resolves to a buffer with response body. + */ + abstract buffer(): Promise<Buffer>; + + /** + * Promise which resolves to a text representation of response body. + */ + async text(): Promise<string> { + const content = await this.buffer(); + return content.toString('utf8'); + } + + /** + * Promise which resolves to a JSON representation of response body. + * + * @remarks + * + * This method will throw if the response body is not parsable via + * `JSON.parse`. + */ + async json(): Promise<any> { + const content = await this.text(); + return JSON.parse(content); + } + + /** + * A matching {@link HTTPRequest} object. + */ + abstract request(): HTTPRequest; + + /** + * True if the response was served from either the browser's disk + * cache or memory cache. + */ + abstract fromCache(): boolean; + + /** + * True if the response was served by a service worker. + */ + abstract fromServiceWorker(): boolean; + + /** + * A {@link Frame} that initiated this response, or `null` if + * navigating to error pages. + */ + abstract frame(): Frame | null; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts new file mode 100644 index 0000000000..6b41ca8fe1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts @@ -0,0 +1,517 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {KeyInput} from '../common/USKeyboardLayout.js'; + +import type {Point} from './ElementHandle.js'; + +/** + * @public + */ +export interface KeyDownOptions { + /** + * @deprecated Do not use. This is automatically handled. + */ + text?: string; + /** + * @deprecated Do not use. This is automatically handled. + */ + commands?: string[]; +} + +/** + * @public + */ +export interface KeyboardTypeOptions { + delay?: number; +} + +/** + * @public + */ +export type KeyPressOptions = KeyDownOptions & KeyboardTypeOptions; + +/** + * Keyboard provides an api for managing a virtual keyboard. + * The high level api is {@link Keyboard."type"}, + * which takes raw characters and generates proper keydown, keypress/input, + * and keyup events on your page. + * + * @remarks + * For finer control, you can use {@link Keyboard.down}, + * {@link Keyboard.up}, and {@link Keyboard.sendCharacter} + * to manually fire events as if they were generated from a real keyboard. + * + * On macOS, keyboard shortcuts like `⌘ A` -\> Select All do not work. + * See {@link https://github.com/puppeteer/puppeteer/issues/1313 | #1313}. + * + * @example + * An example of holding down `Shift` in order to select and delete some text: + * + * ```ts + * await page.keyboard.type('Hello World!'); + * await page.keyboard.press('ArrowLeft'); + * + * await page.keyboard.down('Shift'); + * for (let i = 0; i < ' World'.length; i++) + * await page.keyboard.press('ArrowLeft'); + * await page.keyboard.up('Shift'); + * + * await page.keyboard.press('Backspace'); + * // Result text will end up saying 'Hello!' + * ``` + * + * @example + * An example of pressing `A` + * + * ```ts + * await page.keyboard.down('Shift'); + * await page.keyboard.press('KeyA'); + * await page.keyboard.up('Shift'); + * ``` + * + * @public + */ +export abstract class Keyboard { + /** + * @internal + */ + constructor() {} + + /** + * Dispatches a `keydown` event. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also generated. + * The `text` option can be specified to force an input event to be generated. + * If `key` is a modifier key, `Shift`, `Meta`, `Control`, or `Alt`, + * subsequent key presses will be sent with that modifier active. + * To release the modifier key, use {@link Keyboard.up}. + * + * After the key is pressed once, subsequent calls to + * {@link Keyboard.down} will have + * {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat | repeat} + * set to true. To release the key, use {@link Keyboard.up}. + * + * Modifier keys DO influence {@link Keyboard.down}. + * Holding down `Shift` will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + * + * @param options - An object of options. Accepts text which, if specified, + * generates an input event with this text. Accepts commands which, if specified, + * is the commands of keyboard shortcuts, + * see {@link https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/editing/commands/editor_command_names.h | Chromium Source Code} for valid command names. + */ + abstract down( + key: KeyInput, + options?: Readonly<KeyDownOptions> + ): Promise<void>; + + /** + * Dispatches a `keyup` event. + * + * @param key - Name of key to release, such as `ArrowLeft`. + * See {@link KeyInput | KeyInput} + * for a list of all key names. + */ + abstract up(key: KeyInput): Promise<void>; + + /** + * Dispatches a `keypress` and `input` event. + * This does not send a `keydown` or `keyup` event. + * + * @remarks + * Modifier keys DO NOT effect {@link Keyboard.sendCharacter | Keyboard.sendCharacter}. + * Holding down `Shift` will not type the text in upper case. + * + * @example + * + * ```ts + * page.keyboard.sendCharacter('嗨'); + * ``` + * + * @param char - Character to send into the page. + */ + abstract sendCharacter(char: string): Promise<void>; + + /** + * Sends a `keydown`, `keypress`/`input`, + * and `keyup` event for each character in the text. + * + * @remarks + * To press a special key, like `Control` or `ArrowDown`, + * use {@link Keyboard.press}. + * + * Modifier keys DO NOT effect `keyboard.type`. + * Holding down `Shift` will not type the text in upper case. + * + * @example + * + * ```ts + * await page.keyboard.type('Hello'); // Types instantly + * await page.keyboard.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @param text - A text to type into a focused element. + * @param options - An object of options. Accepts delay which, + * if specified, is the time to wait between `keydown` and `keyup` in milliseconds. + * Defaults to 0. + */ + abstract type( + text: string, + options?: Readonly<KeyboardTypeOptions> + ): Promise<void>; + + /** + * Shortcut for {@link Keyboard.down} + * and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also generated. + * The `text` option can be specified to force an input event to be generated. + * + * Modifier keys DO effect {@link Keyboard.press}. + * Holding down `Shift` will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + * + * @param options - An object of options. Accepts text which, if specified, + * generates an input event with this text. Accepts delay which, + * if specified, is the time to wait between `keydown` and `keyup` in milliseconds. + * Defaults to 0. Accepts commands which, if specified, + * is the commands of keyboard shortcuts, + * see {@link https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/editing/commands/editor_command_names.h | Chromium Source Code} for valid command names. + */ + abstract press( + key: KeyInput, + options?: Readonly<KeyPressOptions> + ): Promise<void>; +} + +/** + * @public + */ +export interface MouseOptions { + /** + * Determines which button will be pressed. + * + * @defaultValue `'left'` + */ + button?: MouseButton; + /** + * Determines the click count for the mouse event. This does not perform + * multiple clicks. + * + * @deprecated Use {@link MouseClickOptions.count}. + * @defaultValue `1` + */ + clickCount?: number; +} + +/** + * @public + */ +export interface MouseClickOptions extends MouseOptions { + /** + * Time (in ms) to delay the mouse release after the mouse press. + */ + delay?: number; + /** + * Number of clicks to perform. + * + * @defaultValue `1` + */ + count?: number; +} + +/** + * @public + */ +export interface MouseWheelOptions { + deltaX?: number; + deltaY?: number; +} + +/** + * @public + */ +export interface MouseMoveOptions { + /** + * Determines the number of movements to make from the current mouse position + * to the new one. + * + * @defaultValue `1` + */ + steps?: number; +} + +/** + * Enum of valid mouse buttons. + * + * @public + */ +export const MouseButton = Object.freeze({ + Left: 'left', + Right: 'right', + Middle: 'middle', + Back: 'back', + Forward: 'forward', +}) satisfies Record<string, Protocol.Input.MouseButton>; + +/** + * @public + */ +export type MouseButton = (typeof MouseButton)[keyof typeof MouseButton]; + +/** + * The Mouse class operates in main-frame CSS pixels + * relative to the top-left corner of the viewport. + * @remarks + * Every `page` object has its own Mouse, accessible with [`page.mouse`](#pagemouse). + * + * @example + * + * ```ts + * // Using ‘page.mouse’ to trace a 100x100 square. + * await page.mouse.move(0, 0); + * await page.mouse.down(); + * await page.mouse.move(0, 100); + * await page.mouse.move(100, 100); + * await page.mouse.move(100, 0); + * await page.mouse.move(0, 0); + * await page.mouse.up(); + * ``` + * + * **Note**: The mouse events trigger synthetic `MouseEvent`s. + * This means that it does not fully replicate the functionality of what a normal user + * would be able to do with their mouse. + * + * For example, dragging and selecting text is not possible using `page.mouse`. + * Instead, you can use the {@link https://developer.mozilla.org/en-US/docs/Web/API/DocumentOrShadowRoot/getSelection | `DocumentOrShadowRoot.getSelection()`} functionality implemented in the platform. + * + * @example + * For example, if you want to select all content between nodes: + * + * ```ts + * await page.evaluate( + * (from, to) => { + * const selection = from.getRootNode().getSelection(); + * const range = document.createRange(); + * range.setStartBefore(from); + * range.setEndAfter(to); + * selection.removeAllRanges(); + * selection.addRange(range); + * }, + * fromJSHandle, + * toJSHandle + * ); + * ``` + * + * If you then would want to copy-paste your selection, you can use the clipboard api: + * + * ```ts + * // The clipboard api does not allow you to copy, unless the tab is focused. + * await page.bringToFront(); + * await page.evaluate(() => { + * // Copy the selected content to the clipboard + * document.execCommand('copy'); + * // Obtain the content of the clipboard as a string + * return navigator.clipboard.readText(); + * }); + * ``` + * + * **Note**: If you want access to the clipboard API, + * you have to give it permission to do so: + * + * ```ts + * await browser + * .defaultBrowserContext() + * .overridePermissions('<your origin>', [ + * 'clipboard-read', + * 'clipboard-write', + * ]); + * ``` + * + * @public + */ +export abstract class Mouse { + /** + * @internal + */ + constructor() {} + + /** + * Resets the mouse to the default state: No buttons pressed; position at + * (0,0). + */ + abstract reset(): Promise<void>; + + /** + * Moves the mouse to the given coordinate. + * + * @param x - Horizontal position of the mouse. + * @param y - Vertical position of the mouse. + * @param options - Options to configure behavior. + */ + abstract move( + x: number, + y: number, + options?: Readonly<MouseMoveOptions> + ): Promise<void>; + + /** + * Presses the mouse. + * + * @param options - Options to configure behavior. + */ + abstract down(options?: Readonly<MouseOptions>): Promise<void>; + + /** + * Releases the mouse. + * + * @param options - Options to configure behavior. + */ + abstract up(options?: Readonly<MouseOptions>): Promise<void>; + + /** + * Shortcut for `mouse.move`, `mouse.down` and `mouse.up`. + * + * @param x - Horizontal position of the mouse. + * @param y - Vertical position of the mouse. + * @param options - Options to configure behavior. + */ + abstract click( + x: number, + y: number, + options?: Readonly<MouseClickOptions> + ): Promise<void>; + + /** + * Dispatches a `mousewheel` event. + * @param options - Optional: `MouseWheelOptions`. + * + * @example + * An example of zooming into an element: + * + * ```ts + * await page.goto( + * 'https://mdn.mozillademos.org/en-US/docs/Web/API/Element/wheel_event$samples/Scaling_an_element_via_the_wheel?revision=1587366' + * ); + * + * const elem = await page.$('div'); + * const boundingBox = await elem.boundingBox(); + * await page.mouse.move( + * boundingBox.x + boundingBox.width / 2, + * boundingBox.y + boundingBox.height / 2 + * ); + * + * await page.mouse.wheel({deltaY: -100}); + * ``` + */ + abstract wheel(options?: Readonly<MouseWheelOptions>): Promise<void>; + + /** + * Dispatches a `drag` event. + * @param start - starting point for drag + * @param target - point to drag to + */ + abstract drag(start: Point, target: Point): Promise<Protocol.Input.DragData>; + + /** + * Dispatches a `dragenter` event. + * @param target - point for emitting `dragenter` event + * @param data - drag data containing items and operations mask + */ + abstract dragEnter( + target: Point, + data: Protocol.Input.DragData + ): Promise<void>; + + /** + * Dispatches a `dragover` event. + * @param target - point for emitting `dragover` event + * @param data - drag data containing items and operations mask + */ + abstract dragOver( + target: Point, + data: Protocol.Input.DragData + ): Promise<void>; + + /** + * Performs a dragenter, dragover, and drop in sequence. + * @param target - point to drop on + * @param data - drag data containing items and operations mask + */ + abstract drop(target: Point, data: Protocol.Input.DragData): Promise<void>; + + /** + * Performs a drag, dragenter, dragover, and drop in sequence. + * @param start - point to drag from + * @param target - point to drop on + * @param options - An object of options. Accepts delay which, + * if specified, is the time to wait between `dragover` and `drop` in milliseconds. + * Defaults to 0. + */ + abstract dragAndDrop( + start: Point, + target: Point, + options?: {delay?: number} + ): Promise<void>; +} + +/** + * The Touchscreen class exposes touchscreen events. + * @public + */ +export abstract class Touchscreen { + /** + * @internal + */ + constructor() {} + + /** + * Dispatches a `touchstart` and `touchend` event. + * @param x - Horizontal position of the tap. + * @param y - Vertical position of the tap. + */ + async tap(x: number, y: number): Promise<void> { + await this.touchStart(x, y); + await this.touchEnd(); + } + + /** + * Dispatches a `touchstart` event. + * @param x - Horizontal position of the tap. + * @param y - Vertical position of the tap. + */ + abstract touchStart(x: number, y: number): Promise<void>; + + /** + * Dispatches a `touchMove` event. + * @param x - Horizontal position of the move. + * @param y - Vertical position of the move. + * + * @remarks + * + * Not every `touchMove` call results in a `touchmove` event being emitted, + * depending on the browser's optimizations. For example, Chrome + * {@link https://developer.chrome.com/blog/a-more-compatible-smoother-touch/#chromes-new-model-the-throttled-async-touchmove-model | throttles} + * touch move events. + */ + abstract touchMove(x: number, y: number): Promise<void>; + + /** + * Dispatches a `touchend` event. + */ + abstract touchEnd(): Promise<void>; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts new file mode 100644 index 0000000000..52ca7fe8f8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import type {EvaluateFuncWith, HandleFor, HandleOr} from '../common/types.js'; +import {debugError, withSourcePuppeteerURLIfNone} from '../common/util.js'; +import {moveable, throwIfDisposed} from '../util/decorators.js'; +import {disposeSymbol, asyncDisposeSymbol} from '../util/disposable.js'; + +import type {ElementHandle} from './ElementHandle.js'; +import type {Realm} from './Realm.js'; + +/** + * Represents a reference to a JavaScript object. Instances can be created using + * {@link Page.evaluateHandle}. + * + * Handles prevent the referenced JavaScript object from being garbage-collected + * unless the handle is purposely {@link JSHandle.dispose | disposed}. JSHandles + * are auto-disposed when their associated frame is navigated away or the parent + * context gets destroyed. + * + * Handles can be used as arguments for any evaluation function such as + * {@link Page.$eval}, {@link Page.evaluate}, and {@link Page.evaluateHandle}. + * They are resolved to their referenced object. + * + * @example + * + * ```ts + * const windowHandle = await page.evaluateHandle(() => window); + * ``` + * + * @public + */ +@moveable +export abstract class JSHandle<T = unknown> { + declare move: () => this; + + /** + * Used for nominally typing {@link JSHandle}. + */ + declare _?: T; + + /** + * @internal + */ + constructor() {} + + /** + * @internal + */ + abstract get realm(): Realm; + + /** + * @internal + */ + abstract get disposed(): boolean; + + /** + * Evaluates the given function with the current handle as its first argument. + */ + async evaluate< + Params extends unknown[], + Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + return await this.realm.evaluate(pageFunction, this, ...args); + } + + /** + * Evaluates the given function with the current handle as its first argument. + * + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + return await this.realm.evaluateHandle(pageFunction, this, ...args); + } + + /** + * Fetches a single property from the referenced object. + */ + getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>>; + getProperty(propertyName: string): Promise<JSHandle<unknown>>; + + /** + * @internal + */ + @throwIfDisposed() + async getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>> { + return await this.evaluateHandle((object, propertyName) => { + return object[propertyName as K]; + }, propertyName); + } + + /** + * Gets a map of handles representing the properties of the current handle. + * + * @example + * + * ```ts + * const listHandle = await page.evaluateHandle(() => document.body.children); + * const properties = await listHandle.getProperties(); + * const children = []; + * for (const property of properties.values()) { + * const element = property.asElement(); + * if (element) { + * children.push(element); + * } + * } + * children; // holds elementHandles to all children of document.body + * ``` + */ + @throwIfDisposed() + async getProperties(): Promise<Map<string, JSHandle>> { + const propertyNames = await this.evaluate(object => { + const enumerableProperties = []; + const descriptors = Object.getOwnPropertyDescriptors(object); + for (const propertyName in descriptors) { + if (descriptors[propertyName]?.enumerable) { + enumerableProperties.push(propertyName); + } + } + return enumerableProperties; + }); + const map = new Map<string, JSHandle>(); + const results = await Promise.all( + propertyNames.map(key => { + return this.getProperty(key); + }) + ); + for (const [key, value] of Object.entries(propertyNames)) { + using handle = results[key as any]; + if (handle) { + map.set(value, handle.move()); + } + } + return map; + } + + /** + * A vanilla object representing the serializable portions of the + * referenced object. + * @throws Throws if the object cannot be serialized due to circularity. + * + * @remarks + * If the object has a `toJSON` function, it **will not** be called. + */ + abstract jsonValue(): Promise<T>; + + /** + * Either `null` or the handle itself if the handle is an + * instance of {@link ElementHandle}. + */ + abstract asElement(): ElementHandle<Node> | null; + + /** + * Releases the object referenced by the handle for garbage collection. + */ + abstract dispose(): Promise<void>; + + /** + * Returns a string representation of the JSHandle. + * + * @remarks + * Useful during debugging. + */ + abstract toString(): string; + + /** + * @internal + */ + abstract get id(): string | undefined; + + /** + * Provides access to the + * {@link https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-RemoteObject | Protocol.Runtime.RemoteObject} + * backing this handle. + */ + abstract remoteObject(): Protocol.Runtime.RemoteObject; + + /** @internal */ + [disposeSymbol](): void { + return void this.dispose().catch(debugError); + } + + /** @internal */ + [asyncDisposeSymbol](): Promise<void> { + return this.dispose(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts new file mode 100644 index 0000000000..deb04628fd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts @@ -0,0 +1,3090 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Readable} from 'stream'; + +import type {Protocol} from 'devtools-protocol'; + +import { + concat, + EMPTY, + filter, + filterAsync, + first, + firstValueFrom, + from, + map, + merge, + mergeMap, + of, + race, + raceWith, + startWith, + switchMap, + takeUntil, + timer, + type Observable, +} from '../../third_party/rxjs/rxjs.js'; +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {Accessibility} from '../cdp/Accessibility.js'; +import type {Coverage} from '../cdp/Coverage.js'; +import type {DeviceRequestPrompt} from '../cdp/DeviceRequestPrompt.js'; +import type {Credentials, NetworkConditions} from '../cdp/NetworkManager.js'; +import type {Tracing} from '../cdp/Tracing.js'; +import type {ConsoleMessage} from '../common/ConsoleMessage.js'; +import type {Device} from '../common/Device.js'; +import {TargetCloseError} from '../common/Errors.js'; +import { + EventEmitter, + type EventsWithWildcard, + type EventType, + type Handler, +} from '../common/EventEmitter.js'; +import type {FileChooser} from '../common/FileChooser.js'; +import type {PDFOptions} from '../common/PDFOptions.js'; +import {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type { + Awaitable, + AwaitablePredicate, + EvaluateFunc, + EvaluateFuncWith, + HandleFor, + NodeFor, +} from '../common/types.js'; +import { + debugError, + fromEmitterEvent, + importFSPromises, + isString, + NETWORK_IDLE_TIME, + timeout, + withSourcePuppeteerURLIfNone, +} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import type {ScreenRecorder} from '../node/ScreenRecorder.js'; +import {guarded} from '../util/decorators.js'; +import { + AsyncDisposableStack, + asyncDisposeSymbol, + DisposableStack, + disposeSymbol, +} from '../util/disposable.js'; + +import type {Browser} from './Browser.js'; +import type {BrowserContext} from './BrowserContext.js'; +import type {CDPSession} from './CDPSession.js'; +import type {Dialog} from './Dialog.js'; +import type { + BoundingBox, + ClickOptions, + ElementHandle, +} from './ElementHandle.js'; +import type { + Frame, + FrameAddScriptTagOptions, + FrameAddStyleTagOptions, + FrameWaitForFunctionOptions, + GoToOptions, + WaitForOptions, +} from './Frame.js'; +import type { + Keyboard, + KeyboardTypeOptions, + Mouse, + Touchscreen, +} from './Input.js'; +import type {JSHandle} from './JSHandle.js'; +import { + FunctionLocator, + Locator, + NodeLocator, + type AwaitedLocator, +} from './locators/locators.js'; +import type {Target} from './Target.js'; +import type {WebWorker} from './WebWorker.js'; + +/** + * @public + */ +export interface Metrics { + Timestamp?: number; + Documents?: number; + Frames?: number; + JSEventListeners?: number; + Nodes?: number; + LayoutCount?: number; + RecalcStyleCount?: number; + LayoutDuration?: number; + RecalcStyleDuration?: number; + ScriptDuration?: number; + TaskDuration?: number; + JSHeapUsedSize?: number; + JSHeapTotalSize?: number; +} + +/** + * @public + */ +export interface WaitForNetworkIdleOptions extends WaitTimeoutOptions { + /** + * Time (in milliseconds) the network should be idle. + * + * @defaultValue `500` + */ + idleTime?: number; + /** + * Maximum number concurrent of network connections to be considered inactive. + * + * @defaultValue `0` + */ + concurrency?: number; +} + +/** + * @public + */ +export interface WaitTimeoutOptions { + /** + * Maximum wait time in milliseconds. Pass 0 to disable the timeout. + * + * The default value can be changed by using the + * {@link Page.setDefaultTimeout} method. + * + * @defaultValue `30000` + */ + timeout?: number; +} + +/** + * @public + */ +export interface WaitForSelectorOptions { + /** + * Wait for the selected element to be present in DOM and to be visible, i.e. + * to not have `display: none` or `visibility: hidden` CSS properties. + * + * @defaultValue `false` + */ + visible?: boolean; + /** + * Wait for the selected element to not be found in the DOM or to be hidden, + * i.e. have `display: none` or `visibility: hidden` CSS properties. + * + * @defaultValue `false` + */ + hidden?: boolean; + /** + * Maximum time to wait in milliseconds. Pass `0` to disable timeout. + * + * The default value can be changed by using {@link Page.setDefaultTimeout} + * + * @defaultValue `30_000` (30 seconds) + */ + timeout?: number; + /** + * A signal object that allows you to cancel a waitForSelector call. + */ + signal?: AbortSignal; +} + +/** + * @public + */ +export interface GeolocationOptions { + /** + * Latitude between `-90` and `90`. + */ + longitude: number; + /** + * Longitude between `-180` and `180`. + */ + latitude: number; + /** + * Optional non-negative accuracy value. + */ + accuracy?: number; +} + +/** + * @public + */ +export interface MediaFeature { + name: string; + value: string; +} + +/** + * @public + */ +export interface ScreenshotClip extends BoundingBox { + /** + * @defaultValue `1` + */ + scale?: number; +} + +/** + * @public + */ +export interface ScreenshotOptions { + /** + * @defaultValue `false` + */ + optimizeForSpeed?: boolean; + /** + * @defaultValue `'png'` + */ + type?: 'png' | 'jpeg' | 'webp'; + /** + * Quality of the image, between 0-100. Not applicable to `png` images. + */ + quality?: number; + /** + * Capture the screenshot from the surface, rather than the view. + * + * @defaultValue `true` + */ + fromSurface?: boolean; + /** + * When `true`, takes a screenshot of the full page. + * + * @defaultValue `false` + */ + fullPage?: boolean; + /** + * Hides default white background and allows capturing screenshots with transparency. + * + * @defaultValue `false` + */ + omitBackground?: boolean; + /** + * The file path to save the image to. The screenshot type will be inferred + * from file extension. If path is a relative path, then it is resolved + * relative to current working directory. If no path is provided, the image + * won't be saved to the disk. + */ + path?: string; + /** + * Specifies the region of the page to clip. + */ + clip?: ScreenshotClip; + /** + * Encoding of the image. + * + * @defaultValue `'binary'` + */ + encoding?: 'base64' | 'binary'; + /** + * Capture the screenshot beyond the viewport. + * + * @defaultValue `false` if there is no `clip`. `true` otherwise. + */ + captureBeyondViewport?: boolean; +} + +/** + * @public + * @experimental + */ +export interface ScreencastOptions { + /** + * File path to save the screencast to. + */ + path?: `${string}.webm`; + /** + * Specifies the region of the viewport to crop. + */ + crop?: BoundingBox; + /** + * Scales the output video. + * + * For example, `0.5` will shrink the width and height of the output video by + * half. `2` will double the width and height of the output video. + * + * @defaultValue `1` + */ + scale?: number; + /** + * Specifies the speed to record at. + * + * For example, `0.5` will slowdown the output video by 50%. `2` will double the + * speed of the output video. + * + * @defaultValue `1` + */ + speed?: number; + /** + * Path to the [ffmpeg](https://ffmpeg.org/). + * + * Required if `ffmpeg` is not in your PATH. + */ + ffmpegPath?: string; +} + +/** + * All the events that a page instance may emit. + * + * @public + */ +export const enum PageEvent { + /** + * Emitted when the page closes. + */ + Close = 'close', + /** + * Emitted when JavaScript within the page calls one of console API methods, + * e.g. `console.log` or `console.dir`. Also emitted if the page throws an + * error or a warning. + * + * @remarks + * A `console` event provides a {@link ConsoleMessage} representing the + * console message that was logged. + * + * @example + * An example of handling `console` event: + * + * ```ts + * page.on('console', msg => { + * for (let i = 0; i < msg.args().length; ++i) + * console.log(`${i}: ${msg.args()[i]}`); + * }); + * page.evaluate(() => console.log('hello', 5, {foo: 'bar'})); + * ``` + */ + Console = 'console', + /** + * Emitted when a JavaScript dialog appears, such as `alert`, `prompt`, + * `confirm` or `beforeunload`. Puppeteer can respond to the dialog via + * {@link Dialog.accept} or {@link Dialog.dismiss}. + */ + Dialog = 'dialog', + /** + * Emitted when the JavaScript + * {@link https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded | DOMContentLoaded } + * event is dispatched. + */ + DOMContentLoaded = 'domcontentloaded', + /** + * Emitted when the page crashes. Will contain an `Error`. + */ + Error = 'error', + /** Emitted when a frame is attached. Will contain a {@link Frame}. */ + FrameAttached = 'frameattached', + /** Emitted when a frame is detached. Will contain a {@link Frame}. */ + FrameDetached = 'framedetached', + /** + * Emitted when a frame is navigated to a new URL. Will contain a + * {@link Frame}. + */ + FrameNavigated = 'framenavigated', + /** + * Emitted when the JavaScript + * {@link https://developer.mozilla.org/en-US/docs/Web/Events/load | load} + * event is dispatched. + */ + Load = 'load', + /** + * Emitted when the JavaScript code makes a call to `console.timeStamp`. For + * the list of metrics see {@link Page.metrics | page.metrics}. + * + * @remarks + * Contains an object with two properties: + * + * - `title`: the title passed to `console.timeStamp` + * - `metrics`: object containing metrics as key/value pairs. The values will + * be `number`s. + */ + Metrics = 'metrics', + /** + * Emitted when an uncaught exception happens within the page. Contains an + * `Error`. + */ + PageError = 'pageerror', + /** + * Emitted when the page opens a new tab or window. + * + * Contains a {@link Page} corresponding to the popup window. + * + * @example + * + * ```ts + * const [popup] = await Promise.all([ + * new Promise(resolve => page.once('popup', resolve)), + * page.click('a[target=_blank]'), + * ]); + * ``` + * + * ```ts + * const [popup] = await Promise.all([ + * new Promise(resolve => page.once('popup', resolve)), + * page.evaluate(() => window.open('https://example.com')), + * ]); + * ``` + */ + Popup = 'popup', + /** + * Emitted when a page issues a request and contains a {@link HTTPRequest}. + * + * @remarks + * The object is readonly. See {@link Page.setRequestInterception} for + * intercepting and mutating requests. + */ + Request = 'request', + /** + * Emitted when a request ended up loading from cache. Contains a + * {@link HTTPRequest}. + * + * @remarks + * For certain requests, might contain undefined. + * {@link https://crbug.com/750469} + */ + RequestServedFromCache = 'requestservedfromcache', + /** + * Emitted when a request fails, for example by timing out. + * + * Contains a {@link HTTPRequest}. + * + * @remarks + * HTTP Error responses, such as 404 or 503, are still successful responses + * from HTTP standpoint, so request will complete with `requestfinished` event + * and not with `requestfailed`. + */ + RequestFailed = 'requestfailed', + /** + * Emitted when a request finishes successfully. Contains a + * {@link HTTPRequest}. + */ + RequestFinished = 'requestfinished', + /** + * Emitted when a response is received. Contains a {@link HTTPResponse}. + */ + Response = 'response', + /** + * Emitted when a dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker} + * is spawned by the page. + */ + WorkerCreated = 'workercreated', + /** + * Emitted when a dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker} + * is destroyed by the page. + */ + WorkerDestroyed = 'workerdestroyed', +} + +export { + /** + * All the events that a page instance may emit. + * + * @deprecated Use {@link PageEvent}. + */ + PageEvent as PageEmittedEvents, +}; + +/** + * Denotes the objects received by callback functions for page events. + * + * See {@link PageEvent} for more detail on the events and when they are + * emitted. + * + * @public + */ +export interface PageEvents extends Record<EventType, unknown> { + [PageEvent.Close]: undefined; + [PageEvent.Console]: ConsoleMessage; + [PageEvent.Dialog]: Dialog; + [PageEvent.DOMContentLoaded]: undefined; + [PageEvent.Error]: Error; + [PageEvent.FrameAttached]: Frame; + [PageEvent.FrameDetached]: Frame; + [PageEvent.FrameNavigated]: Frame; + [PageEvent.Load]: undefined; + [PageEvent.Metrics]: {title: string; metrics: Metrics}; + [PageEvent.PageError]: Error; + [PageEvent.Popup]: Page | null; + [PageEvent.Request]: HTTPRequest; + [PageEvent.Response]: HTTPResponse; + [PageEvent.RequestFailed]: HTTPRequest; + [PageEvent.RequestFinished]: HTTPRequest; + [PageEvent.RequestServedFromCache]: HTTPRequest; + [PageEvent.WorkerCreated]: WebWorker; + [PageEvent.WorkerDestroyed]: WebWorker; +} + +export type { + /** + * @deprecated Use {@link PageEvents}. + */ + PageEvents as PageEventObject, +}; + +/** + * @public + */ +export interface NewDocumentScriptEvaluation { + identifier: string; +} + +/** + * @internal + */ +export function setDefaultScreenshotOptions(options: ScreenshotOptions): void { + options.optimizeForSpeed ??= false; + options.type ??= 'png'; + options.fromSurface ??= true; + options.fullPage ??= false; + options.omitBackground ??= false; + options.encoding ??= 'binary'; + options.captureBeyondViewport ??= true; +} + +/** + * Page provides methods to interact with a single tab or + * {@link https://developer.chrome.com/extensions/background_pages | extension background page} + * in the browser. + * + * :::note + * + * One Browser instance might have multiple Page instances. + * + * ::: + * + * @example + * This example creates a page, navigates it to a URL, and then saves a screenshot: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await page.screenshot({path: 'screenshot.png'}); + * await browser.close(); + * })(); + * ``` + * + * The Page class extends from Puppeteer's {@link EventEmitter} class and will + * emit various events which are documented in the {@link PageEvent} enum. + * + * @example + * This example logs a message for a single page `load` event: + * + * ```ts + * page.once('load', () => console.log('Page loaded!')); + * ``` + * + * To unsubscribe from events use the {@link EventEmitter.off} method: + * + * ```ts + * function logRequest(interceptedRequest) { + * console.log('A request was made:', interceptedRequest.url()); + * } + * page.on('request', logRequest); + * // Sometime later... + * page.off('request', logRequest); + * ``` + * + * @public + */ +export abstract class Page extends EventEmitter<PageEvents> { + /** + * @internal + */ + _isDragging = false; + /** + * @internal + */ + _timeoutSettings = new TimeoutSettings(); + + #requestHandlers = new WeakMap<Handler<HTTPRequest>, Handler<HTTPRequest>>(); + + #requestsInFlight = 0; + #inflight$: Observable<number>; + + /** + * @internal + */ + constructor() { + super(); + + this.#inflight$ = fromEmitterEvent(this, PageEvent.Request).pipe( + takeUntil(fromEmitterEvent(this, PageEvent.Close)), + mergeMap(request => { + return concat( + of(1), + race( + fromEmitterEvent(this, PageEvent.Response).pipe( + filter(response => { + return response.request()._requestId === request._requestId; + }) + ), + fromEmitterEvent(this, PageEvent.RequestFailed).pipe( + filter(failure => { + return failure._requestId === request._requestId; + }) + ), + fromEmitterEvent(this, PageEvent.RequestFinished).pipe( + filter(success => { + return success._requestId === request._requestId; + }) + ) + ).pipe( + map(() => { + return -1; + }) + ) + ); + }) + ); + + this.#inflight$.subscribe(count => { + this.#requestsInFlight += count; + }); + } + + /** + * `true` if the service worker are being bypassed, `false` otherwise. + */ + abstract isServiceWorkerBypassed(): boolean; + + /** + * `true` if drag events are being intercepted, `false` otherwise. + * + * @deprecated We no longer support intercepting drag payloads. Use the new + * drag APIs found on {@link ElementHandle} to drag (or just use the + * {@link Page | Page.mouse}). + */ + abstract isDragInterceptionEnabled(): boolean; + + /** + * `true` if the page has JavaScript enabled, `false` otherwise. + */ + abstract isJavaScriptEnabled(): boolean; + + /** + * Listen to page events. + * + * @remarks + * This method exists to define event typings and handle proper wireup of + * cooperative request interception. Actual event listening and dispatching is + * delegated to {@link EventEmitter}. + * + * @internal + */ + override on<K extends keyof EventsWithWildcard<PageEvents>>( + type: K, + handler: (event: EventsWithWildcard<PageEvents>[K]) => void + ): this { + if (type !== PageEvent.Request) { + return super.on(type, handler); + } + let wrapper = this.#requestHandlers.get( + handler as (event: PageEvents[PageEvent.Request]) => void + ); + if (wrapper === undefined) { + wrapper = (event: HTTPRequest) => { + event.enqueueInterceptAction(() => { + return handler(event as EventsWithWildcard<PageEvents>[K]); + }); + }; + this.#requestHandlers.set( + handler as (event: PageEvents[PageEvent.Request]) => void, + wrapper + ); + } + return super.on( + type, + wrapper as (event: EventsWithWildcard<PageEvents>[K]) => void + ); + } + + /** + * @internal + */ + override off<K extends keyof EventsWithWildcard<PageEvents>>( + type: K, + handler: (event: EventsWithWildcard<PageEvents>[K]) => void + ): this { + if (type === PageEvent.Request) { + handler = + (this.#requestHandlers.get( + handler as ( + event: EventsWithWildcard<PageEvents>[PageEvent.Request] + ) => void + ) as (event: EventsWithWildcard<PageEvents>[K]) => void) || handler; + } + return super.off(type, handler); + } + + /** + * This method is typically coupled with an action that triggers file + * choosing. + * + * :::caution + * + * This must be called before the file chooser is launched. It will not return + * a currently active file chooser. + * + * ::: + * + * @remarks + * In the "headful" browser, this method results in the native file picker + * dialog `not showing up` for the user. + * + * @example + * The following example clicks a button that issues a file chooser + * and then responds with `/tmp/myfile.pdf` as if a user has selected this file. + * + * ```ts + * const [fileChooser] = await Promise.all([ + * page.waitForFileChooser(), + * page.click('#upload-file-button'), + * // some button that triggers file selection + * ]); + * await fileChooser.accept(['/tmp/myfile.pdf']); + * ``` + */ + abstract waitForFileChooser( + options?: WaitTimeoutOptions + ): Promise<FileChooser>; + + /** + * Sets the page's geolocation. + * + * @remarks + * Consider using {@link BrowserContext.overridePermissions} to grant + * permissions for the page to read its geolocation. + * + * @example + * + * ```ts + * await page.setGeolocation({latitude: 59.95, longitude: 30.31667}); + * ``` + */ + abstract setGeolocation(options: GeolocationOptions): Promise<void>; + + /** + * A target this page was created from. + */ + abstract target(): Target; + + /** + * Get the browser the page belongs to. + */ + abstract browser(): Browser; + + /** + * Get the browser context that the page belongs to. + */ + abstract browserContext(): BrowserContext; + + /** + * The page's main frame. + * + * @remarks + * Page is guaranteed to have a main frame which persists during navigations. + */ + abstract mainFrame(): Frame; + + /** + * Creates a Chrome Devtools Protocol session attached to the page. + */ + abstract createCDPSession(): Promise<CDPSession>; + + /** + * {@inheritDoc Keyboard} + */ + abstract get keyboard(): Keyboard; + + /** + * {@inheritDoc Touchscreen} + */ + abstract get touchscreen(): Touchscreen; + + /** + * {@inheritDoc Coverage} + */ + abstract get coverage(): Coverage; + + /** + * {@inheritDoc Tracing} + */ + abstract get tracing(): Tracing; + + /** + * {@inheritDoc Accessibility} + */ + abstract get accessibility(): Accessibility; + + /** + * An array of all frames attached to the page. + */ + abstract frames(): Frame[]; + + /** + * All of the dedicated {@link + * https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | + * WebWorkers} associated with the page. + * + * @remarks + * This does not contain ServiceWorkers + */ + abstract workers(): WebWorker[]; + + /** + * Activating request interception enables {@link HTTPRequest.abort}, + * {@link HTTPRequest.continue} and {@link HTTPRequest.respond} methods. This + * provides the capability to modify network requests that are made by a page. + * + * Once request interception is enabled, every request will stall unless it's + * continued, responded or aborted; or completed using the browser cache. + * + * See the + * {@link https://pptr.dev/next/guides/request-interception|Request interception guide} + * for more details. + * + * @example + * An example of a naïve request interceptor that aborts all image requests: + * + * ```ts + * import puppeteer from 'puppeteer'; + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.setRequestInterception(true); + * page.on('request', interceptedRequest => { + * if ( + * interceptedRequest.url().endsWith('.png') || + * interceptedRequest.url().endsWith('.jpg') + * ) + * interceptedRequest.abort(); + * else interceptedRequest.continue(); + * }); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + * + * @param value - Whether to enable request interception. + */ + abstract setRequestInterception(value: boolean): Promise<void>; + + /** + * Toggles ignoring of service worker for each request. + * + * @param bypass - Whether to bypass service worker and load from network. + */ + abstract setBypassServiceWorker(bypass: boolean): Promise<void>; + + /** + * @param enabled - Whether to enable drag interception. + * + * @deprecated We no longer support intercepting drag payloads. Use the new + * drag APIs found on {@link ElementHandle} to drag (or just use the + * {@link Page | Page.mouse}). + */ + abstract setDragInterception(enabled: boolean): Promise<void>; + + /** + * Sets the network connection to offline. + * + * It does not change the parameters used in {@link Page.emulateNetworkConditions} + * + * @param enabled - When `true`, enables offline mode for the page. + */ + abstract setOfflineMode(enabled: boolean): Promise<void>; + + /** + * This does not affect WebSockets and WebRTC PeerConnections (see + * https://crbug.com/563644). To set the page offline, you can use + * {@link Page.setOfflineMode}. + * + * A list of predefined network conditions can be used by importing + * {@link PredefinedNetworkConditions}. + * + * @example + * + * ```ts + * import {PredefinedNetworkConditions} from 'puppeteer'; + * const slow3G = PredefinedNetworkConditions['Slow 3G']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulateNetworkConditions(slow3G); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * @param networkConditions - Passing `null` disables network condition + * emulation. + */ + abstract emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void>; + + /** + * This setting will change the default maximum navigation time for the + * following methods and related shortcuts: + * + * - {@link Page.goBack | page.goBack(options)} + * + * - {@link Page.goForward | page.goForward(options)} + * + * - {@link Page.goto | page.goto(url,options)} + * + * - {@link Page.reload | page.reload(options)} + * + * - {@link Page.setContent | page.setContent(html,options)} + * + * - {@link Page.waitForNavigation | page.waitForNavigation(options)} + * @param timeout - Maximum navigation time in milliseconds. + */ + abstract setDefaultNavigationTimeout(timeout: number): void; + + /** + * @param timeout - Maximum time in milliseconds. + */ + abstract setDefaultTimeout(timeout: number): void; + + /** + * Maximum time in milliseconds. + */ + abstract getDefaultTimeout(): number; + + /** + * Creates a locator for the provided selector. See {@link Locator} for + * details and supported actions. + * + * @remarks + * Locators API is experimental and we will not follow semver for breaking + * change in the Locators API. + */ + locator<Selector extends string>( + selector: Selector + ): Locator<NodeFor<Selector>>; + + /** + * Creates a locator for the provided function. See {@link Locator} for + * details and supported actions. + * + * @remarks + * Locators API is experimental and we will not follow semver for breaking + * change in the Locators API. + */ + locator<Ret>(func: () => Awaitable<Ret>): Locator<Ret>; + locator<Selector extends string, Ret>( + selectorOrFunc: Selector | (() => Awaitable<Ret>) + ): Locator<NodeFor<Selector>> | Locator<Ret> { + if (typeof selectorOrFunc === 'string') { + return NodeLocator.create(this, selectorOrFunc); + } else { + return FunctionLocator.create(this, selectorOrFunc); + } + } + + /** + * A shortcut for {@link Locator.race} that does not require static imports. + * + * @internal + */ + locatorRace<Locators extends readonly unknown[] | []>( + locators: Locators + ): Locator<AwaitedLocator<Locators[number]>> { + return Locator.race(locators); + } + + /** + * Runs `document.querySelector` within the page. If no element matches the + * selector, the return value resolves to `null`. + * + * @param selector - A `selector` to query page for + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query page for. + */ + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + return await this.mainFrame().$(selector); + } + + /** + * The method runs `document.querySelectorAll` within the page. If no elements + * match the selector, the return value resolves to `[]`. + * + * @param selector - A `selector` to query page for + * + * @remarks + * + * Shortcut for {@link Frame.$$ | Page.mainFrame().$$(selector) }. + */ + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + return await this.mainFrame().$$(selector); + } + + /** + * @remarks + * + * The only difference between {@link Page.evaluate | page.evaluate} and + * `page.evaluateHandle` is that `evaluateHandle` will return the value + * wrapped in an in-page object. + * + * If the function passed to `page.evaluateHandle` returns a Promise, the + * function will wait for the promise to resolve and return its value. + * + * You can pass a string instead of a function (although functions are + * recommended as they are easier to debug and use with TypeScript): + * + * @example + * + * ```ts + * const aHandle = await page.evaluateHandle('document'); + * ``` + * + * @example + * {@link JSHandle} instances can be passed as arguments to the `pageFunction`: + * + * ```ts + * const aHandle = await page.evaluateHandle(() => document.body); + * const resultHandle = await page.evaluateHandle( + * body => body.innerHTML, + * aHandle + * ); + * console.log(await resultHandle.jsonValue()); + * await resultHandle.dispose(); + * ``` + * + * Most of the time this function returns a {@link JSHandle}, + * but if `pageFunction` returns a reference to an element, + * you instead get an {@link ElementHandle} back: + * + * @example + * + * ```ts + * const button = await page.evaluateHandle(() => + * document.querySelector('button') + * ); + * // can call `click` because `button` is an `ElementHandle` + * await button.click(); + * ``` + * + * The TypeScript definitions assume that `evaluateHandle` returns + * a `JSHandle`, but if you know it's going to return an + * `ElementHandle`, pass it as the generic argument: + * + * ```ts + * const button = await page.evaluateHandle<ElementHandle>(...); + * ``` + * + * @param pageFunction - a function that is run within the page + * @param args - arguments to be passed to the pageFunction + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + return await this.mainFrame().evaluateHandle(pageFunction, ...args); + } + + /** + * This method iterates the JavaScript heap and finds all objects with the + * given prototype. + * + * @example + * + * ```ts + * // Create a Map object + * await page.evaluate(() => (window.map = new Map())); + * // Get a handle to the Map object prototype + * const mapPrototype = await page.evaluateHandle(() => Map.prototype); + * // Query all map instances into an array + * const mapInstances = await page.queryObjects(mapPrototype); + * // Count amount of map objects in heap + * const count = await page.evaluate(maps => maps.length, mapInstances); + * await mapInstances.dispose(); + * await mapPrototype.dispose(); + * ``` + * + * @param prototypeHandle - a handle to the object prototype. + * @returns Promise which resolves to a handle to an array of objects with + * this prototype. + */ + abstract queryObjects<Prototype>( + prototypeHandle: JSHandle<Prototype> + ): Promise<JSHandle<Prototype[]>>; + + /** + * This method runs `document.querySelector` within the page and passes the + * result as the first argument to the `pageFunction`. + * + * @remarks + * + * If no element is found matching `selector`, the method will throw an error. + * + * If `pageFunction` returns a promise `$eval` will wait for the promise to + * resolve and then return its value. + * + * @example + * + * ```ts + * const searchValue = await page.$eval('#search', el => el.value); + * const preloadHref = await page.$eval('link[rel=preload]', el => el.href); + * const html = await page.$eval('.main-container', el => el.outerHTML); + * ``` + * + * If you are using TypeScript, you may have to provide an explicit type to the + * first argument of the `pageFunction`. + * By default it is typed as `Element`, but you may need to provide a more + * specific sub-type: + * + * @example + * + * ```ts + * // if you don't provide HTMLInputElement here, TS will error + * // as `value` is not on `Element` + * const searchValue = await page.$eval( + * '#search', + * (el: HTMLInputElement) => el.value + * ); + * ``` + * + * The compiler should be able to infer the return type + * from the `pageFunction` you provide. If it is unable to, you can use the generic + * type to tell the compiler what return type you expect from `$eval`: + * + * @example + * + * ```ts + * // The compiler can infer the return type in this case, but if it can't + * // or if you want to be more explicit, provide it as the generic type. + * const searchValue = await page.$eval<string>( + * '#search', + * (el: HTMLInputElement) => el.value + * ); + * ``` + * + * @param selector - the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query for + * @param pageFunction - the function to be evaluated in the page context. + * Will be passed the result of `document.querySelector(selector)` as its + * first argument. + * @param args - any additional arguments to pass through to `pageFunction`. + * + * @returns The result of calling `pageFunction`. If it returns an element it + * is wrapped in an {@link ElementHandle}, else the raw value itself is + * returned. + */ + async $eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith< + NodeFor<Selector>, + Params + >, + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction); + return await this.mainFrame().$eval(selector, pageFunction, ...args); + } + + /** + * This method runs `Array.from(document.querySelectorAll(selector))` within + * the page and passes the result as the first argument to the `pageFunction`. + * + * @remarks + * If `pageFunction` returns a promise `$$eval` will wait for the promise to + * resolve and then return its value. + * + * @example + * + * ```ts + * // get the amount of divs on the page + * const divCount = await page.$$eval('div', divs => divs.length); + * + * // get the text content of all the `.options` elements: + * const options = await page.$$eval('div > span.options', options => { + * return options.map(option => option.textContent); + * }); + * ``` + * + * If you are using TypeScript, you may have to provide an explicit type to the + * first argument of the `pageFunction`. + * By default it is typed as `Element[]`, but you may need to provide a more + * specific sub-type: + * + * @example + * + * ```ts + * // if you don't provide HTMLInputElement here, TS will error + * // as `value` is not on `Element` + * await page.$$eval('input', (elements: HTMLInputElement[]) => { + * return elements.map(e => e.value); + * }); + * ``` + * + * The compiler should be able to infer the return type + * from the `pageFunction` you provide. If it is unable to, you can use the generic + * type to tell the compiler what return type you expect from `$$eval`: + * + * @example + * + * ```ts + * // The compiler can infer the return type in this case, but if it can't + * // or if you want to be more explicit, provide it as the generic type. + * const allInputValues = await page.$$eval<string[]>( + * 'input', + * (elements: HTMLInputElement[]) => elements.map(e => e.textContent) + * ); + * ``` + * + * @param selector - the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query for + * @param pageFunction - the function to be evaluated in the page context. + * Will be passed the result of + * `Array.from(document.querySelectorAll(selector))` as its first argument. + * @param args - any additional arguments to pass through to `pageFunction`. + * + * @returns The result of calling `pageFunction`. If it returns an element it + * is wrapped in an {@link ElementHandle}, else the raw value itself is + * returned. + */ + async $$eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith< + Array<NodeFor<Selector>>, + Params + > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>, + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); + return await this.mainFrame().$$eval(selector, pageFunction, ...args); + } + + /** + * The method evaluates the XPath expression relative to the page document as + * its context node. If there are no such elements, the method resolves to an + * empty array. + * + * @remarks + * Shortcut for {@link Frame.$x | Page.mainFrame().$x(expression) }. + * + * @param expression - Expression to evaluate + */ + async $x(expression: string): Promise<Array<ElementHandle<Node>>> { + return await this.mainFrame().$x(expression); + } + + /** + * If no URLs are specified, this method returns cookies for the current page + * URL. If URLs are specified, only cookies for those URLs are returned. + */ + abstract cookies(...urls: string[]): Promise<Protocol.Network.Cookie[]>; + + abstract deleteCookie( + ...cookies: Protocol.Network.DeleteCookiesRequest[] + ): Promise<void>; + + /** + * @example + * + * ```ts + * await page.setCookie(cookieObject1, cookieObject2); + * ``` + */ + abstract setCookie(...cookies: Protocol.Network.CookieParam[]): Promise<void>; + + /** + * Adds a `<script>` tag into the page with the desired URL or content. + * + * @remarks + * Shortcut for + * {@link Frame.addScriptTag | page.mainFrame().addScriptTag(options)}. + * + * @param options - Options for the script. + * @returns An {@link ElementHandle | element handle} to the injected + * `<script>` element. + */ + async addScriptTag( + options: FrameAddScriptTagOptions + ): Promise<ElementHandle<HTMLScriptElement>> { + return await this.mainFrame().addScriptTag(options); + } + + /** + * Adds a `<link rel="stylesheet">` tag into the page with the desired URL or + * a `<style type="text/css">` tag with the content. + * + * Shortcut for + * {@link Frame.(addStyleTag:2) | page.mainFrame().addStyleTag(options)}. + * + * @returns An {@link ElementHandle | element handle} to the injected `<link>` + * or `<style>` element. + */ + async addStyleTag( + options: Omit<FrameAddStyleTagOptions, 'url'> + ): Promise<ElementHandle<HTMLStyleElement>>; + async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLLinkElement>>; + async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> { + return await this.mainFrame().addStyleTag(options); + } + + /** + * The method adds a function called `name` on the page's `window` object. + * When called, the function executes `puppeteerFunction` in node.js and + * returns a `Promise` which resolves to the return value of + * `puppeteerFunction`. + * + * If the puppeteerFunction returns a `Promise`, it will be awaited. + * + * :::note + * + * Functions installed via `page.exposeFunction` survive navigations. + * + * :::note + * + * @example + * An example of adding an `md5` function into the page: + * + * ```ts + * import puppeteer from 'puppeteer'; + * import crypto from 'crypto'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * page.on('console', msg => console.log(msg.text())); + * await page.exposeFunction('md5', text => + * crypto.createHash('md5').update(text).digest('hex') + * ); + * await page.evaluate(async () => { + * // use window.md5 to compute hashes + * const myString = 'PUPPETEER'; + * const myHash = await window.md5(myString); + * console.log(`md5 of ${myString} is ${myHash}`); + * }); + * await browser.close(); + * })(); + * ``` + * + * @example + * An example of adding a `window.readfile` function into the page: + * + * ```ts + * import puppeteer from 'puppeteer'; + * import fs from 'fs'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * page.on('console', msg => console.log(msg.text())); + * await page.exposeFunction('readfile', async filePath => { + * return new Promise((resolve, reject) => { + * fs.readFile(filePath, 'utf8', (err, text) => { + * if (err) reject(err); + * else resolve(text); + * }); + * }); + * }); + * await page.evaluate(async () => { + * // use window.readfile to read contents of a file + * const content = await window.readfile('/etc/hosts'); + * console.log(content); + * }); + * await browser.close(); + * })(); + * ``` + * + * @param name - Name of the function on the window object + * @param pptrFunction - Callback function which will be called in Puppeteer's + * context. + */ + abstract exposeFunction( + name: string, + pptrFunction: Function | {default: Function} + ): Promise<void>; + + /** + * The method removes a previously added function via ${@link Page.exposeFunction} + * called `name` from the page's `window` object. + */ + abstract removeExposedFunction(name: string): Promise<void>; + + /** + * Provide credentials for `HTTP authentication`. + * + * @remarks + * To disable authentication, pass `null`. + */ + abstract authenticate(credentials: Credentials): Promise<void>; + + /** + * The extra HTTP headers will be sent with every request the page initiates. + * + * :::tip + * + * All HTTP header names are lowercased. (HTTP headers are + * case-insensitive, so this shouldn’t impact your server code.) + * + * ::: + * + * :::note + * + * page.setExtraHTTPHeaders does not guarantee the order of headers in + * the outgoing requests. + * + * ::: + * + * @param headers - An object containing additional HTTP headers to be sent + * with every request. All header values must be strings. + */ + abstract setExtraHTTPHeaders(headers: Record<string, string>): Promise<void>; + + /** + * @param userAgent - Specific user agent to use in this page + * @param userAgentData - Specific user agent client hint data to use in this + * page + * @returns Promise which resolves when the user agent is set. + */ + abstract setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata + ): Promise<void>; + + /** + * Object containing metrics as key/value pairs. + * + * @returns + * + * - `Timestamp` : The timestamp when the metrics sample was taken. + * + * - `Documents` : Number of documents in the page. + * + * - `Frames` : Number of frames in the page. + * + * - `JSEventListeners` : Number of events in the page. + * + * - `Nodes` : Number of DOM nodes in the page. + * + * - `LayoutCount` : Total number of full or partial page layout. + * + * - `RecalcStyleCount` : Total number of page style recalculations. + * + * - `LayoutDuration` : Combined durations of all page layouts. + * + * - `RecalcStyleDuration` : Combined duration of all page style + * recalculations. + * + * - `ScriptDuration` : Combined duration of JavaScript execution. + * + * - `TaskDuration` : Combined duration of all tasks performed by the browser. + * + * - `JSHeapUsedSize` : Used JavaScript heap size. + * + * - `JSHeapTotalSize` : Total JavaScript heap size. + * + * @remarks + * All timestamps are in monotonic time: monotonically increasing time + * in seconds since an arbitrary point in the past. + */ + abstract metrics(): Promise<Metrics>; + + /** + * The page's URL. + * + * @remarks + * + * Shortcut for {@link Frame.url | page.mainFrame().url()}. + */ + url(): string { + return this.mainFrame().url(); + } + + /** + * The full HTML contents of the page, including the DOCTYPE. + */ + async content(): Promise<string> { + return await this.mainFrame().content(); + } + + /** + * Set the content of the page. + * + * @param html - HTML markup to assign to the page. + * @param options - Parameters that has some properties. + * + * @remarks + * + * The parameter `options` might have the following options. + * + * - `timeout` : Maximum time in milliseconds for resources to load, defaults + * to 30 seconds, pass `0` to disable timeout. The default value can be + * changed by using the {@link Page.setDefaultNavigationTimeout} or + * {@link Page.setDefaultTimeout} methods. + * + * - `waitUntil`: When to consider setting markup succeeded, defaults to + * `load`. Given an array of event strings, setting content is considered + * to be successful after all events have been fired. Events can be + * either:<br/> + * - `load` : consider setting content to be finished when the `load` event + * is fired.<br/> + * - `domcontentloaded` : consider setting content to be finished when the + * `DOMContentLoaded` event is fired.<br/> + * - `networkidle0` : consider setting content to be finished when there are + * no more than 0 network connections for at least `500` ms.<br/> + * - `networkidle2` : consider setting content to be finished when there are + * no more than 2 network connections for at least `500` ms. + */ + async setContent(html: string, options?: WaitForOptions): Promise<void> { + await this.mainFrame().setContent(html, options); + } + + /** + * Navigates the page to the given `url`. + * + * @remarks + * + * Navigation to `about:blank` or navigation to the same URL with a different + * hash will succeed and return `null`. + * + * :::warning + * + * Headless mode doesn't support navigation to a PDF document. See the {@link + * https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream + * issue}. + * + * ::: + * + * Shortcut for {@link Frame.goto | page.mainFrame().goto(url, options)}. + * + * @param url - URL to navigate page to. The URL should include scheme, e.g. + * `https://` + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. + * @throws If: + * + * - there's an SSL error (e.g. in case of self-signed certificates). + * - target URL is invalid. + * - the timeout is exceeded during navigation. + * - the remote server does not respond or is unreachable. + * - the main resource failed to load. + * + * This method will not throw an error when any valid HTTP status code is + * returned by the remote server, including 404 "Not Found" and 500 "Internal + * Server Error". The status code for such responses can be retrieved by + * calling {@link HTTPResponse.status}. + */ + async goto(url: string, options?: GoToOptions): Promise<HTTPResponse | null> { + return await this.mainFrame().goto(url, options); + } + + /** + * Reloads the page. + * + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. + */ + abstract reload(options?: WaitForOptions): Promise<HTTPResponse | null>; + + /** + * Waits for the page to navigate to a new URL or to reload. It is useful when + * you run code that will indirectly cause the page to navigate. + * + * @example + * + * ```ts + * const [response] = await Promise.all([ + * page.waitForNavigation(), // The promise resolves after navigation has finished + * page.click('a.my-link'), // Clicking the link will indirectly cause a navigation + * ]); + * ``` + * + * @remarks + * + * Usage of the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API} + * to change the URL is considered a navigation. + * + * @param options - Navigation parameters which might have the following + * properties: + * @returns A `Promise` which resolves to the main resource response. + * + * - In case of multiple redirects, the navigation will resolve with the + * response of the last redirect. + * - In case of navigation to a different anchor or navigation due to History + * API usage, the navigation will resolve with `null`. + */ + async waitForNavigation( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.mainFrame().waitForNavigation(options); + } + + /** + * @param urlOrPredicate - A URL or predicate to wait for + * @param options - Optional waiting parameters + * @returns Promise which resolves to the matched request + * @example + * + * ```ts + * const firstRequest = await page.waitForRequest( + * 'https://example.com/resource' + * ); + * const finalRequest = await page.waitForRequest( + * request => request.url() === 'https://example.com' + * ); + * return finalRequest.response()?.ok(); + * ``` + * + * @remarks + * Optional Waiting Parameters have: + * + * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds, pass + * `0` to disable the timeout. The default value can be changed by using the + * {@link Page.setDefaultTimeout} method. + */ + waitForRequest( + urlOrPredicate: string | AwaitablePredicate<HTTPRequest>, + options: WaitTimeoutOptions = {} + ): Promise<HTTPRequest> { + const {timeout: ms = this._timeoutSettings.timeout()} = options; + if (typeof urlOrPredicate === 'string') { + const url = urlOrPredicate; + urlOrPredicate = (request: HTTPRequest) => { + return request.url() === url; + }; + } + const observable$ = fromEmitterEvent(this, PageEvent.Request).pipe( + filterAsync(urlOrPredicate), + raceWith( + timeout(ms), + fromEmitterEvent(this, PageEvent.Close).pipe( + map(() => { + throw new TargetCloseError('Page closed!'); + }) + ) + ) + ); + return firstValueFrom(observable$); + } + + /** + * @param urlOrPredicate - A URL or predicate to wait for. + * @param options - Optional waiting parameters + * @returns Promise which resolves to the matched response. + * @example + * + * ```ts + * const firstResponse = await page.waitForResponse( + * 'https://example.com/resource' + * ); + * const finalResponse = await page.waitForResponse( + * response => + * response.url() === 'https://example.com' && response.status() === 200 + * ); + * const finalResponse = await page.waitForResponse(async response => { + * return (await response.text()).includes('<html>'); + * }); + * return finalResponse.ok(); + * ``` + * + * @remarks + * Optional Parameter have: + * + * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds, + * pass `0` to disable the timeout. The default value can be changed by using + * the {@link Page.setDefaultTimeout} method. + */ + waitForResponse( + urlOrPredicate: string | AwaitablePredicate<HTTPResponse>, + options: WaitTimeoutOptions = {} + ): Promise<HTTPResponse> { + const {timeout: ms = this._timeoutSettings.timeout()} = options; + if (typeof urlOrPredicate === 'string') { + const url = urlOrPredicate; + urlOrPredicate = (response: HTTPResponse) => { + return response.url() === url; + }; + } + const observable$ = fromEmitterEvent(this, PageEvent.Response).pipe( + filterAsync(urlOrPredicate), + raceWith( + timeout(ms), + fromEmitterEvent(this, PageEvent.Close).pipe( + map(() => { + throw new TargetCloseError('Page closed!'); + }) + ) + ) + ); + return firstValueFrom(observable$); + } + + /** + * Waits for the network to be idle. + * + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves once the network is idle. + */ + waitForNetworkIdle(options: WaitForNetworkIdleOptions = {}): Promise<void> { + return firstValueFrom(this.waitForNetworkIdle$(options)); + } + + /** + * @internal + */ + waitForNetworkIdle$( + options: WaitForNetworkIdleOptions = {} + ): Observable<void> { + const { + timeout: ms = this._timeoutSettings.timeout(), + idleTime = NETWORK_IDLE_TIME, + concurrency = 0, + } = options; + + return this.#inflight$.pipe( + startWith(this.#requestsInFlight), + switchMap(() => { + if (this.#requestsInFlight > concurrency) { + return EMPTY; + } else { + return timer(idleTime); + } + }), + map(() => {}), + raceWith( + timeout(ms), + fromEmitterEvent(this, PageEvent.Close).pipe( + map(() => { + throw new TargetCloseError('Page closed!'); + }) + ) + ) + ); + } + + /** + * Waits for a frame matching the given conditions to appear. + * + * @example + * + * ```ts + * const frame = await page.waitForFrame(async frame => { + * return frame.name() === 'Test'; + * }); + * ``` + */ + async waitForFrame( + urlOrPredicate: string | ((frame: Frame) => Awaitable<boolean>), + options: WaitTimeoutOptions = {} + ): Promise<Frame> { + const {timeout: ms = this.getDefaultTimeout()} = options; + + if (isString(urlOrPredicate)) { + urlOrPredicate = (frame: Frame) => { + return urlOrPredicate === frame.url(); + }; + } + + return await firstValueFrom( + merge( + fromEmitterEvent(this, PageEvent.FrameAttached), + fromEmitterEvent(this, PageEvent.FrameNavigated), + from(this.frames()) + ).pipe( + filterAsync(urlOrPredicate), + first(), + raceWith( + timeout(ms), + fromEmitterEvent(this, PageEvent.Close).pipe( + map(() => { + throw new TargetCloseError('Page closed.'); + }) + ) + ) + ) + ); + } + + /** + * This method navigate to the previous page in history. + * @param options - Navigation parameters + * @returns Promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. If can not go back, resolves to `null`. + * @remarks + * The argument `options` might have the following properties: + * + * - `timeout` : Maximum navigation time in milliseconds, defaults to 30 + * seconds, pass 0 to disable timeout. The default value can be changed by + * using the {@link Page.setDefaultNavigationTimeout} or + * {@link Page.setDefaultTimeout} methods. + * + * - `waitUntil` : When to consider navigation succeeded, defaults to `load`. + * Given an array of event strings, navigation is considered to be + * successful after all events have been fired. Events can be either:<br/> + * - `load` : consider navigation to be finished when the load event is + * fired.<br/> + * - `domcontentloaded` : consider navigation to be finished when the + * DOMContentLoaded event is fired.<br/> + * - `networkidle0` : consider navigation to be finished when there are no + * more than 0 network connections for at least `500` ms.<br/> + * - `networkidle2` : consider navigation to be finished when there are no + * more than 2 network connections for at least `500` ms. + */ + abstract goBack(options?: WaitForOptions): Promise<HTTPResponse | null>; + + /** + * This method navigate to the next page in history. + * @param options - Navigation Parameter + * @returns Promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. If can not go forward, resolves to `null`. + * @remarks + * The argument `options` might have the following properties: + * + * - `timeout` : Maximum navigation time in milliseconds, defaults to 30 + * seconds, pass 0 to disable timeout. The default value can be changed by + * using the {@link Page.setDefaultNavigationTimeout} or + * {@link Page.setDefaultTimeout} methods. + * + * - `waitUntil`: When to consider navigation succeeded, defaults to `load`. + * Given an array of event strings, navigation is considered to be + * successful after all events have been fired. Events can be either:<br/> + * - `load` : consider navigation to be finished when the load event is + * fired.<br/> + * - `domcontentloaded` : consider navigation to be finished when the + * DOMContentLoaded event is fired.<br/> + * - `networkidle0` : consider navigation to be finished when there are no + * more than 0 network connections for at least `500` ms.<br/> + * - `networkidle2` : consider navigation to be finished when there are no + * more than 2 network connections for at least `500` ms. + */ + abstract goForward(options?: WaitForOptions): Promise<HTTPResponse | null>; + + /** + * Brings page to front (activates tab). + */ + abstract bringToFront(): Promise<void>; + + /** + * Emulates a given device's metrics and user agent. + * + * To aid emulation, Puppeteer provides a list of known devices that can be + * via {@link KnownDevices}. + * + * @remarks + * This method is a shortcut for calling two methods: + * {@link Page.setUserAgent} and {@link Page.setViewport}. + * + * This method will resize the page. A lot of websites don't expect phones to + * change size, so you should emulate before navigating to the page. + * + * @example + * + * ```ts + * import {KnownDevices} from 'puppeteer'; + * const iPhone = KnownDevices['iPhone 6']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulate(iPhone); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + */ + async emulate(device: Device): Promise<void> { + await Promise.all([ + this.setUserAgent(device.userAgent), + this.setViewport(device.viewport), + ]); + } + + /** + * @param enabled - Whether or not to enable JavaScript on the page. + * @remarks + * NOTE: changing this value won't affect scripts that have already been run. + * It will take full effect on the next navigation. + */ + abstract setJavaScriptEnabled(enabled: boolean): Promise<void>; + + /** + * Toggles bypassing page's Content-Security-Policy. + * @param enabled - sets bypassing of page's Content-Security-Policy. + * @remarks + * NOTE: CSP bypassing happens at the moment of CSP initialization rather than + * evaluation. Usually, this means that `page.setBypassCSP` should be called + * before navigating to the domain. + */ + abstract setBypassCSP(enabled: boolean): Promise<void>; + + /** + * @param type - Changes the CSS media type of the page. The only allowed + * values are `screen`, `print` and `null`. Passing `null` disables CSS media + * emulation. + * @example + * + * ```ts + * await page.evaluate(() => matchMedia('screen').matches); + * // → true + * await page.evaluate(() => matchMedia('print').matches); + * // → false + * + * await page.emulateMediaType('print'); + * await page.evaluate(() => matchMedia('screen').matches); + * // → false + * await page.evaluate(() => matchMedia('print').matches); + * // → true + * + * await page.emulateMediaType(null); + * await page.evaluate(() => matchMedia('screen').matches); + * // → true + * await page.evaluate(() => matchMedia('print').matches); + * // → false + * ``` + */ + abstract emulateMediaType(type?: string): Promise<void>; + + /** + * Enables CPU throttling to emulate slow CPUs. + * @param factor - slowdown factor (1 is no throttle, 2 is 2x slowdown, etc). + */ + abstract emulateCPUThrottling(factor: number | null): Promise<void>; + + /** + * @param features - `<?Array<Object>>` Given an array of media feature + * objects, emulates CSS media features on the page. Each media feature object + * must have the following properties: + * @example + * + * ```ts + * await page.emulateMediaFeatures([ + * {name: 'prefers-color-scheme', value: 'dark'}, + * ]); + * await page.evaluate( + * () => matchMedia('(prefers-color-scheme: dark)').matches + * ); + * // → true + * await page.evaluate( + * () => matchMedia('(prefers-color-scheme: light)').matches + * ); + * // → false + * + * await page.emulateMediaFeatures([ + * {name: 'prefers-reduced-motion', value: 'reduce'}, + * ]); + * await page.evaluate( + * () => matchMedia('(prefers-reduced-motion: reduce)').matches + * ); + * // → true + * await page.evaluate( + * () => matchMedia('(prefers-reduced-motion: no-preference)').matches + * ); + * // → false + * + * await page.emulateMediaFeatures([ + * {name: 'prefers-color-scheme', value: 'dark'}, + * {name: 'prefers-reduced-motion', value: 'reduce'}, + * ]); + * await page.evaluate( + * () => matchMedia('(prefers-color-scheme: dark)').matches + * ); + * // → true + * await page.evaluate( + * () => matchMedia('(prefers-color-scheme: light)').matches + * ); + * // → false + * await page.evaluate( + * () => matchMedia('(prefers-reduced-motion: reduce)').matches + * ); + * // → true + * await page.evaluate( + * () => matchMedia('(prefers-reduced-motion: no-preference)').matches + * ); + * // → false + * + * await page.emulateMediaFeatures([{name: 'color-gamut', value: 'p3'}]); + * await page.evaluate(() => matchMedia('(color-gamut: srgb)').matches); + * // → true + * await page.evaluate(() => matchMedia('(color-gamut: p3)').matches); + * // → true + * await page.evaluate(() => matchMedia('(color-gamut: rec2020)').matches); + * // → false + * ``` + */ + abstract emulateMediaFeatures(features?: MediaFeature[]): Promise<void>; + + /** + * @param timezoneId - Changes the timezone of the page. See + * {@link https://source.chromium.org/chromium/chromium/deps/icu.git/+/faee8bc70570192d82d2978a71e2a615788597d1:source/data/misc/metaZones.txt | ICU’s metaZones.txt} + * for a list of supported timezone IDs. Passing + * `null` disables timezone emulation. + */ + abstract emulateTimezone(timezoneId?: string): Promise<void>; + + /** + * Emulates the idle state. + * If no arguments set, clears idle state emulation. + * + * @example + * + * ```ts + * // set idle emulation + * await page.emulateIdleState({isUserActive: true, isScreenUnlocked: false}); + * + * // do some checks here + * ... + * + * // clear idle emulation + * await page.emulateIdleState(); + * ``` + * + * @param overrides - Mock idle state. If not set, clears idle overrides + */ + abstract emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void>; + + /** + * Simulates the given vision deficiency on the page. + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://v8.dev/blog/10-years'); + * + * await page.emulateVisionDeficiency('achromatopsia'); + * await page.screenshot({path: 'achromatopsia.png'}); + * + * await page.emulateVisionDeficiency('deuteranopia'); + * await page.screenshot({path: 'deuteranopia.png'}); + * + * await page.emulateVisionDeficiency('blurredVision'); + * await page.screenshot({path: 'blurred-vision.png'}); + * + * await browser.close(); + * })(); + * ``` + * + * @param type - the type of deficiency to simulate, or `'none'` to reset. + */ + abstract emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void>; + + /** + * `page.setViewport` will resize the page. A lot of websites don't expect + * phones to change size, so you should set the viewport before navigating to + * the page. + * + * In the case of multiple pages in a single browser, each page can have its + * own viewport size. + * @example + * + * ```ts + * const page = await browser.newPage(); + * await page.setViewport({ + * width: 640, + * height: 480, + * deviceScaleFactor: 1, + * }); + * await page.goto('https://example.com'); + * ``` + * + * @param viewport - + * @remarks + * NOTE: in certain cases, setting viewport will reload the page in order to + * set the isMobile or hasTouch properties. + */ + abstract setViewport(viewport: Viewport): Promise<void>; + + /** + * Returns the current page viewport settings without checking the actual page + * viewport. + * + * This is either the viewport set with the previous {@link Page.setViewport} + * call or the default viewport set via + * {@link BrowserConnectOptions | BrowserConnectOptions.defaultViewport}. + */ + abstract viewport(): Viewport | null; + + /** + * Evaluates a function in the page's context and returns the result. + * + * If the function passed to `page.evaluate` returns a Promise, the + * function will wait for the promise to resolve and return its value. + * + * @example + * + * ```ts + * const result = await frame.evaluate(() => { + * return Promise.resolve(8 * 7); + * }); + * console.log(result); // prints "56" + * ``` + * + * You can pass a string instead of a function (although functions are + * recommended as they are easier to debug and use with TypeScript): + * + * @example + * + * ```ts + * const aHandle = await page.evaluate('1 + 2'); + * ``` + * + * To get the best TypeScript experience, you should pass in as the + * generic the type of `pageFunction`: + * + * ```ts + * const aHandle = await page.evaluate(() => 2); + * ``` + * + * @example + * + * {@link ElementHandle} instances (including {@link JSHandle}s) can be passed + * as arguments to the `pageFunction`: + * + * ```ts + * const bodyHandle = await page.$('body'); + * const html = await page.evaluate(body => body.innerHTML, bodyHandle); + * await bodyHandle.dispose(); + * ``` + * + * @param pageFunction - a function that is run within the page + * @param args - arguments to be passed to the pageFunction + * + * @returns the return value of `pageFunction`. + */ + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + return await this.mainFrame().evaluate(pageFunction, ...args); + } + + /** + * Adds a function which would be invoked in one of the following scenarios: + * + * - whenever the page is navigated + * + * - whenever the child frame is attached or navigated. In this case, the + * function is invoked in the context of the newly attached frame. + * + * The function is invoked after the document was created but before any of + * its scripts were run. This is useful to amend the JavaScript environment, + * e.g. to seed `Math.random`. + * @param pageFunction - Function to be evaluated in browser context + * @param args - Arguments to pass to `pageFunction` + * @example + * An example of overriding the navigator.languages property before the page loads: + * + * ```ts + * // preload.js + * + * // overwrite the `languages` property to use a custom getter + * Object.defineProperty(navigator, 'languages', { + * get: function () { + * return ['en-US', 'en', 'bn']; + * }, + * }); + * + * // In your puppeteer script, assuming the preload.js file is + * // in same folder of our script. + * const preloadFile = fs.readFileSync('./preload.js', 'utf8'); + * await page.evaluateOnNewDocument(preloadFile); + * ``` + */ + abstract evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<NewDocumentScriptEvaluation>; + + /** + * Removes script that injected into page by Page.evaluateOnNewDocument. + * + * @param identifier - script identifier + */ + abstract removeScriptToEvaluateOnNewDocument( + identifier: string + ): Promise<void>; + + /** + * Toggles ignoring cache for each request based on the enabled state. By + * default, caching is enabled. + * @param enabled - sets the `enabled` state of cache + * @defaultValue `true` + */ + abstract setCacheEnabled(enabled?: boolean): Promise<void>; + + /** + * @internal + */ + async _maybeWriteBufferToFile( + path: string | undefined, + buffer: Buffer + ): Promise<void> { + if (!path) { + return; + } + + const fs = await importFSPromises(); + + await fs.writeFile(path, buffer); + } + + /** + * Captures a screencast of this {@link Page | page}. + * + * @example + * Recording a {@link Page | page}: + * + * ``` + * import puppeteer from 'puppeteer'; + * + * // Launch a browser + * const browser = await puppeteer.launch(); + * + * // Create a new page + * const page = await browser.newPage(); + * + * // Go to your site. + * await page.goto("https://www.example.com"); + * + * // Start recording. + * const recorder = await page.screencast({path: 'recording.webm'}); + * + * // Do something. + * + * // Stop recording. + * await recorder.stop(); + * + * browser.close(); + * ``` + * + * @param options - Configures screencast behavior. + * + * @experimental + * + * @remarks + * + * All recordings will be {@link https://www.webmproject.org/ | WebM} format using + * the {@link https://www.webmproject.org/vp9/ | VP9} video codec. The FPS is 30. + * + * You must have {@link https://ffmpeg.org/ | ffmpeg} installed on your system. + */ + async screencast( + options: Readonly<ScreencastOptions> = {} + ): Promise<ScreenRecorder> { + const [{ScreenRecorder}, [width, height, devicePixelRatio]] = + await Promise.all([ + import('../node/ScreenRecorder.js'), + this.#getNativePixelDimensions(), + ]); + + let crop: BoundingBox | undefined; + if (options.crop) { + const { + x, + y, + width: cropWidth, + height: cropHeight, + } = roundRectangle(normalizeRectangle(options.crop)); + if (x < 0 || y < 0) { + throw new Error( + `\`crop.x\` and \`crop.y\` must be greater than or equal to 0.` + ); + } + if (cropWidth <= 0 || cropHeight <= 0) { + throw new Error( + `\`crop.height\` and \`crop.width\` must be greater than or equal to 0.` + ); + } + + const viewportWidth = width / devicePixelRatio; + const viewportHeight = height / devicePixelRatio; + if (x + cropWidth > viewportWidth) { + throw new Error( + `\`crop.width\` cannot be larger than the viewport width (${viewportWidth}).` + ); + } + if (y + cropHeight > viewportHeight) { + throw new Error( + `\`crop.height\` cannot be larger than the viewport height (${viewportHeight}).` + ); + } + + crop = { + x: x * devicePixelRatio, + y: y * devicePixelRatio, + width: cropWidth * devicePixelRatio, + height: cropHeight * devicePixelRatio, + }; + } + if (options.speed !== undefined && options.speed <= 0) { + throw new Error(`\`speed\` must be greater than 0.`); + } + if (options.scale !== undefined && options.scale <= 0) { + throw new Error(`\`scale\` must be greater than 0.`); + } + + const recorder = new ScreenRecorder(this, width, height, { + ...options, + path: options.ffmpegPath, + crop, + }); + try { + await this._startScreencast(); + } catch (error) { + void recorder.stop(); + throw error; + } + if (options.path) { + const {createWriteStream} = await import('fs'); + const stream = createWriteStream(options.path, 'binary'); + recorder.pipe(stream); + } + return recorder; + } + + #screencastSessionCount = 0; + #startScreencastPromise: Promise<void> | undefined; + + /** + * @internal + */ + async _startScreencast(): Promise<void> { + ++this.#screencastSessionCount; + if (!this.#startScreencastPromise) { + this.#startScreencastPromise = this.mainFrame() + .client.send('Page.startScreencast', {format: 'png'}) + .then(() => { + // Wait for the first frame. + return new Promise(resolve => { + return this.mainFrame().client.once('Page.screencastFrame', () => { + return resolve(); + }); + }); + }); + } + await this.#startScreencastPromise; + } + + /** + * @internal + */ + async _stopScreencast(): Promise<void> { + --this.#screencastSessionCount; + if (!this.#startScreencastPromise) { + return; + } + this.#startScreencastPromise = undefined; + if (this.#screencastSessionCount === 0) { + await this.mainFrame().client.send('Page.stopScreencast'); + } + } + + /** + * Gets the native, non-emulated dimensions of the viewport. + */ + async #getNativePixelDimensions(): Promise< + readonly [width: number, height: number, devicePixelRatio: number] + > { + const viewport = this.viewport(); + using stack = new DisposableStack(); + if (viewport && viewport.deviceScaleFactor !== 0) { + await this.setViewport({...viewport, deviceScaleFactor: 0}); + stack.defer(() => { + void this.setViewport(viewport).catch(debugError); + }); + } + return await this.mainFrame() + .isolatedRealm() + .evaluate(() => { + return [ + window.visualViewport!.width * window.devicePixelRatio, + window.visualViewport!.height * window.devicePixelRatio, + window.devicePixelRatio, + ] as const; + }); + } + + /** + * Captures a screenshot of this {@link Page | page}. + * + * @param options - Configures screenshot behavior. + */ + async screenshot( + options: Readonly<ScreenshotOptions> & {encoding: 'base64'} + ): Promise<string>; + async screenshot(options?: Readonly<ScreenshotOptions>): Promise<Buffer>; + @guarded(function () { + return this.browser(); + }) + async screenshot( + userOptions: Readonly<ScreenshotOptions> = {} + ): Promise<Buffer | string> { + await this.bringToFront(); + + // TODO: use structuredClone after Node 16 support is dropped. + const options = { + ...userOptions, + clip: userOptions.clip + ? { + ...userOptions.clip, + } + : undefined, + }; + if (options.type === undefined && options.path !== undefined) { + const filePath = options.path; + // Note we cannot use Node.js here due to browser compatability. + const extension = filePath + .slice(filePath.lastIndexOf('.') + 1) + .toLowerCase(); + switch (extension) { + case 'png': + options.type = 'png'; + break; + case 'jpeg': + case 'jpg': + options.type = 'jpeg'; + break; + case 'webp': + options.type = 'webp'; + break; + } + } + if (options.quality !== undefined) { + if (options.quality < 0 && options.quality > 100) { + throw new Error( + `Expected 'quality' (${options.quality}) to be between 0 and 100, inclusive.` + ); + } + if ( + options.type === undefined || + !['jpeg', 'webp'].includes(options.type) + ) { + throw new Error( + `${options.type ?? 'png'} screenshots do not support 'quality'.` + ); + } + } + if (options.clip) { + if (options.clip.width <= 0) { + throw new Error("'width' in 'clip' must be positive."); + } + if (options.clip.height <= 0) { + throw new Error("'height' in 'clip' must be positive."); + } + } + + setDefaultScreenshotOptions(options); + + await using stack = new AsyncDisposableStack(); + if (options.clip) { + if (options.fullPage) { + throw new Error("'clip' and 'fullPage' are mutually exclusive"); + } + + options.clip = roundRectangle(normalizeRectangle(options.clip)); + } else { + if (options.fullPage) { + // If `captureBeyondViewport` is `false`, then we set the viewport to + // capture the full page. Note this may be affected by on-page CSS and + // JavaScript. + if (!options.captureBeyondViewport) { + const scrollDimensions = await this.mainFrame() + .isolatedRealm() + .evaluate(() => { + const element = document.documentElement; + return { + width: element.scrollWidth, + height: element.scrollHeight, + }; + }); + const viewport = this.viewport(); + await this.setViewport({ + ...viewport, + ...scrollDimensions, + }); + stack.defer(async () => { + if (viewport) { + await this.setViewport(viewport).catch(debugError); + } else { + await this.setViewport({ + width: 0, + height: 0, + }).catch(debugError); + } + }); + } + } else { + options.captureBeyondViewport = false; + } + } + + const data = await this._screenshot(options); + if (options.encoding === 'base64') { + return data; + } + const buffer = Buffer.from(data, 'base64'); + await this._maybeWriteBufferToFile(options.path, buffer); + return buffer; + } + + /** + * @internal + */ + abstract _screenshot(options: Readonly<ScreenshotOptions>): Promise<string>; + + /** + * Generates a PDF of the page with the `print` CSS media type. + * + * @param options - options for generating the PDF. + * + * @remarks + * + * To generate a PDF with the `screen` media type, call + * {@link Page.emulateMediaType | `page.emulateMediaType('screen')`} before + * calling `page.pdf()`. + * + * By default, `page.pdf()` generates a pdf with modified colors for printing. + * Use the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-print-color-adjust | `-webkit-print-color-adjust`} + * property to force rendering of exact colors. + */ + abstract createPDFStream(options?: PDFOptions): Promise<Readable>; + + /** + * {@inheritDoc Page.createPDFStream} + */ + abstract pdf(options?: PDFOptions): Promise<Buffer>; + + /** + * The page's title + * + * @remarks + * + * Shortcut for {@link Frame.title | page.mainFrame().title()}. + */ + async title(): Promise<string> { + return await this.mainFrame().title(); + } + + abstract close(options?: {runBeforeUnload?: boolean}): Promise<void>; + + /** + * Indicates that the page has been closed. + * @returns + */ + abstract isClosed(): boolean; + + /** + * {@inheritDoc Mouse} + */ + abstract get mouse(): Mouse; + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page | Page.mouse} to click in the center of the + * element. If there's no element matching `selector`, the method throws an + * error. + * + * @remarks + * + * Bear in mind that if `click()` triggers a navigation event and + * there's a separate `page.waitForNavigation()` promise to be resolved, you + * may end up with a race condition that yields unexpected results. The + * correct pattern for click and wait for navigation is the following: + * + * ```ts + * const [response] = await Promise.all([ + * page.waitForNavigation(waitOptions), + * page.click(selector, clickOptions), + * ]); + * ``` + * + * Shortcut for {@link Frame.click | page.mainFrame().click(selector[, options]) }. + * @param selector - A `selector` to search for element to click. If there are + * multiple elements satisfying the `selector`, the first will be clicked + * @param options - `Object` + * @returns Promise which resolves when the element matching `selector` is + * successfully clicked. The Promise will be rejected if there is no element + * matching `selector`. + */ + click(selector: string, options?: Readonly<ClickOptions>): Promise<void> { + return this.mainFrame().click(selector, options); + } + + /** + * This method fetches an element with `selector` and focuses it. If there's no + * element matching `selector`, the method throws an error. + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector } + * of an element to focus. If there are multiple elements satisfying the + * selector, the first will be focused. + * @returns Promise which resolves when the element matching selector is + * successfully focused. The promise will be rejected if there is no element + * matching selector. + * + * @remarks + * + * Shortcut for {@link Frame.focus | page.mainFrame().focus(selector)}. + */ + focus(selector: string): Promise<void> { + return this.mainFrame().focus(selector); + } + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page | Page.mouse} + * to hover over the center of the element. + * If there's no element matching `selector`, the method throws an error. + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to search for element to hover. If there are multiple elements satisfying + * the selector, the first will be hovered. + * @returns Promise which resolves when the element matching `selector` is + * successfully hovered. Promise gets rejected if there's no element matching + * `selector`. + * + * @remarks + * + * Shortcut for {@link Page.hover | page.mainFrame().hover(selector)}. + */ + hover(selector: string): Promise<void> { + return this.mainFrame().hover(selector); + } + + /** + * Triggers a `change` and `input` event once all the provided options have been + * selected. If there's no `<select>` element matching `selector`, the method + * throws an error. + * + * @example + * + * ```ts + * page.select('select#colors', 'blue'); // single selection + * page.select('select#colors', 'red', 'green', 'blue'); // multiple selections + * ``` + * + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | Selector} + * to query the page for + * @param values - Values of options to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first one + * is taken into account. + * @returns + * + * @remarks + * + * Shortcut for {@link Frame.select | page.mainFrame().select()} + */ + select(selector: string, ...values: string[]): Promise<string[]> { + return this.mainFrame().select(selector, ...values); + } + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page | Page.touchscreen} + * to tap in the center of the element. + * If there's no element matching `selector`, the method throws an error. + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | Selector} + * to search for element to tap. If there are multiple elements satisfying the + * selector, the first will be tapped. + * + * @remarks + * + * Shortcut for {@link Frame.tap | page.mainFrame().tap(selector)}. + */ + tap(selector: string): Promise<void> { + return this.mainFrame().tap(selector); + } + + /** + * Sends a `keydown`, `keypress/input`, and `keyup` event for each character + * in the text. + * + * To press a special key, like `Control` or `ArrowDown`, use {@link Keyboard.press}. + * @example + * + * ```ts + * await page.type('#mytextarea', 'Hello'); + * // Types instantly + * await page.type('#mytextarea', 'World', {delay: 100}); + * // Types slower, like a user + * ``` + * + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * of an element to type into. If there are multiple elements satisfying the + * selector, the first will be used. + * @param text - A text to type into a focused element. + * @param options - have property `delay` which is the Time to wait between + * key presses in milliseconds. Defaults to `0`. + * @returns + */ + type( + selector: string, + text: string, + options?: Readonly<KeyboardTypeOptions> + ): Promise<void> { + return this.mainFrame().type(selector, text, options); + } + + /** + * @deprecated Replace with `new Promise(r => setTimeout(r, milliseconds));`. + * + * Causes your script to wait for the given number of milliseconds. + * + * @remarks + * + * It's generally recommended to not wait for a number of seconds, but instead + * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or + * {@link Frame.waitForFunction} to wait for exactly the conditions you want. + * + * @example + * + * Wait for 1 second: + * + * ```ts + * await page.waitForTimeout(1000); + * ``` + * + * @param milliseconds - the number of milliseconds to wait. + */ + waitForTimeout(milliseconds: number): Promise<void> { + return this.mainFrame().waitForTimeout(milliseconds); + } + + /** + * Wait for the `selector` to appear in page. If at the moment of calling the + * method the `selector` already exists, the method will return immediately. If + * the `selector` doesn't appear after the `timeout` milliseconds of waiting, the + * function will throw. + * + * @example + * This method works across navigations: + * + * ```ts + * import puppeteer from 'puppeteer'; + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page + * .waitForSelector('img') + * .then(() => console.log('First URL with image: ' + currentURL)); + * for (currentURL of [ + * 'https://example.com', + * 'https://google.com', + * 'https://bbc.com', + * ]) { + * await page.goto(currentURL); + * } + * await browser.close(); + * })(); + * ``` + * + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * of an element to wait for + * @param options - Optional waiting parameters + * @returns Promise which resolves when element specified by selector string + * is added to DOM. Resolves to `null` if waiting for hidden: `true` and + * selector is not found in DOM. + * + * @remarks + * The optional Parameter in Arguments `options` are: + * + * - `visible`: A boolean wait for element to be present in DOM and to be + * visible, i.e. to not have `display: none` or `visibility: hidden` CSS + * properties. Defaults to `false`. + * + * - `hidden`: Wait for element to not be found in the DOM or to be hidden, + * i.e. have `display: none` or `visibility: hidden` CSS properties. Defaults to + * `false`. + * + * - `timeout`: maximum time to wait for in milliseconds. Defaults to `30000` + * (30 seconds). Pass `0` to disable timeout. The default value can be changed + * by using the {@link Page.setDefaultTimeout} method. + */ + async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + return await this.mainFrame().waitForSelector(selector, options); + } + + /** + * Wait for the `xpath` to appear in page. If at the moment of calling the + * method the `xpath` already exists, the method will return immediately. If + * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the + * function will throw. + * + * @example + * This method works across navigation + * + * ```ts + * import puppeteer from 'puppeteer'; + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page + * .waitForXPath('//img') + * .then(() => console.log('First URL with image: ' + currentURL)); + * for (currentURL of [ + * 'https://example.com', + * 'https://google.com', + * 'https://bbc.com', + * ]) { + * await page.goto(currentURL); + * } + * await browser.close(); + * })(); + * ``` + * + * @param xpath - A + * {@link https://developer.mozilla.org/en-US/docs/Web/XPath | xpath} of an + * element to wait for + * @param options - Optional waiting parameters + * @returns Promise which resolves when element specified by xpath string is + * added to DOM. Resolves to `null` if waiting for `hidden: true` and xpath is + * not found in DOM, otherwise resolves to `ElementHandle`. + * @remarks + * The optional Argument `options` have properties: + * + * - `visible`: A boolean to wait for element to be present in DOM and to be + * visible, i.e. to not have `display: none` or `visibility: hidden` CSS + * properties. Defaults to `false`. + * + * - `hidden`: A boolean wait for element to not be found in the DOM or to be + * hidden, i.e. have `display: none` or `visibility: hidden` CSS properties. + * Defaults to `false`. + * + * - `timeout`: A number which is maximum time to wait for in milliseconds. + * Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default + * value can be changed by using the {@link Page.setDefaultTimeout} method. + */ + waitForXPath( + xpath: string, + options?: WaitForSelectorOptions + ): Promise<ElementHandle<Node> | null> { + return this.mainFrame().waitForXPath(xpath, options); + } + + /** + * Waits for the provided function, `pageFunction`, to return a truthy value when + * evaluated in the page's context. + * + * @example + * {@link Page.waitForFunction} can be used to observe a viewport size change: + * + * ```ts + * import puppeteer from 'puppeteer'; + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * const watchDog = page.waitForFunction('window.innerWidth < 100'); + * await page.setViewport({width: 50, height: 50}); + * await watchDog; + * await browser.close(); + * })(); + * ``` + * + * @example + * Arguments can be passed from Node.js to `pageFunction`: + * + * ```ts + * const selector = '.foo'; + * await page.waitForFunction( + * selector => !!document.querySelector(selector), + * {}, + * selector + * ); + * ``` + * + * @example + * The provided `pageFunction` can be asynchronous: + * + * ```ts + * const username = 'github-username'; + * await page.waitForFunction( + * async username => { + * const githubResponse = await fetch( + * `https://api.github.com/users/${username}` + * ); + * const githubUser = await githubResponse.json(); + * // show the avatar + * const img = document.createElement('img'); + * img.src = githubUser.avatar_url; + * // wait 3 seconds + * await new Promise((resolve, reject) => setTimeout(resolve, 3000)); + * img.remove(); + * }, + * {}, + * username + * ); + * ``` + * + * @param pageFunction - Function to be evaluated in browser context until it returns a + * truthy value. + * @param options - Options for configuring waiting behavior. + */ + waitForFunction< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + options?: FrameWaitForFunctionOptions, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return this.mainFrame().waitForFunction(pageFunction, options, ...args); + } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + */ + abstract waitForDevicePrompt( + options?: WaitTimeoutOptions + ): Promise<DeviceRequestPrompt>; + + /** @internal */ + [disposeSymbol](): void { + return void this.close().catch(debugError); + } + + /** @internal */ + [asyncDisposeSymbol](): Promise<void> { + return this.close(); + } +} + +/** + * @internal + */ +export const supportedMetrics = new Set<string>([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); + +/** @see https://w3c.github.io/webdriver-bidi/#normalize-rect */ +function normalizeRectangle<BoundingBoxType extends BoundingBox>( + clip: Readonly<BoundingBoxType> +): BoundingBoxType { + return { + ...clip, + ...(clip.width < 0 + ? { + x: clip.x + clip.width, + width: -clip.width, + } + : { + x: clip.x, + width: clip.width, + }), + ...(clip.height < 0 + ? { + y: clip.y + clip.height, + height: -clip.height, + } + : { + y: clip.y, + height: clip.height, + }), + }; +} + +function roundRectangle<BoundingBoxType extends BoundingBox>( + clip: Readonly<BoundingBoxType> +): BoundingBoxType { + const x = Math.round(clip.x); + const y = Math.round(clip.y); + const width = Math.round(clip.width + clip.x - x); + const height = Math.round(clip.height + clip.y - y); + return {...clip, x, y, width, height}; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts new file mode 100644 index 0000000000..eee1f2c1dd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type { + EvaluateFunc, + HandleFor, + InnerLazyParams, +} from '../common/types.js'; +import {TaskManager, WaitTask} from '../common/WaitTask.js'; +import {disposeSymbol} from '../util/disposable.js'; + +import type {ElementHandle} from './ElementHandle.js'; +import type {Environment} from './Environment.js'; +import type {JSHandle} from './JSHandle.js'; + +/** + * @internal + */ +export abstract class Realm implements Disposable { + protected readonly timeoutSettings: TimeoutSettings; + readonly taskManager = new TaskManager(); + + constructor(timeoutSettings: TimeoutSettings) { + this.timeoutSettings = timeoutSettings; + } + + abstract get environment(): Environment; + + abstract adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T>; + abstract transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T>; + abstract evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>>; + abstract evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>>; + + async waitForFunction< + Params extends unknown[], + Func extends EvaluateFunc<InnerLazyParams<Params>> = EvaluateFunc< + InnerLazyParams<Params> + >, + >( + pageFunction: Func | string, + options: { + polling?: 'raf' | 'mutation' | number; + timeout?: number; + root?: ElementHandle<Node>; + signal?: AbortSignal; + } = {}, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + const { + polling = 'raf', + timeout = this.timeoutSettings.timeout(), + root, + signal, + } = options; + if (typeof polling === 'number' && polling < 0) { + throw new Error('Cannot poll with non-positive interval'); + } + const waitTask = new WaitTask( + this, + { + polling, + root, + timeout, + signal, + }, + pageFunction as unknown as + | ((...args: unknown[]) => Promise<Awaited<ReturnType<Func>>>) + | string, + ...args + ); + return await waitTask.result; + } + + abstract adoptBackendNode(backendNodeId?: number): Promise<JSHandle<Node>>; + + get disposed(): boolean { + return this.#disposed; + } + + #disposed = false; + /** @internal */ + [disposeSymbol](): void { + this.#disposed = true; + this.taskManager.terminateAll( + new Error('waitForFunction failed: frame got detached.') + ); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts new file mode 100644 index 0000000000..f91b91df12 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Browser} from './Browser.js'; +import type {BrowserContext} from './BrowserContext.js'; +import type {CDPSession} from './CDPSession.js'; +import type {Page} from './Page.js'; +import type {WebWorker} from './WebWorker.js'; + +/** + * @public + */ +export enum TargetType { + PAGE = 'page', + BACKGROUND_PAGE = 'background_page', + SERVICE_WORKER = 'service_worker', + SHARED_WORKER = 'shared_worker', + BROWSER = 'browser', + WEBVIEW = 'webview', + OTHER = 'other', + /** + * @internal + */ + TAB = 'tab', +} + +/** + * Target represents a + * {@link https://chromedevtools.github.io/devtools-protocol/tot/Target/ | CDP target}. + * In CDP a target is something that can be debugged such a frame, a page or a + * worker. + * @public + */ +export abstract class Target { + /** + * @internal + */ + protected constructor() {} + + /** + * If the target is not of type `"service_worker"` or `"shared_worker"`, returns `null`. + */ + async worker(): Promise<WebWorker | null> { + return null; + } + + /** + * If the target is not of type `"page"`, `"webview"` or `"background_page"`, + * returns `null`. + */ + async page(): Promise<Page | null> { + return null; + } + + /** + * Forcefully creates a page for a target of any type. It is useful if you + * want to handle a CDP target of type `other` as a page. If you deal with a + * regular page target, use {@link Target.page}. + */ + abstract asPage(): Promise<Page>; + + abstract url(): string; + + /** + * Creates a Chrome Devtools Protocol session attached to the target. + */ + abstract createCDPSession(): Promise<CDPSession>; + + /** + * Identifies what kind of target this is. + * + * @remarks + * + * See {@link https://developer.chrome.com/extensions/background_pages | docs} for more info about background pages. + */ + abstract type(): TargetType; + + /** + * Get the browser the target belongs to. + */ + abstract browser(): Browser; + + /** + * Get the browser context the target belongs to. + */ + abstract browserContext(): BrowserContext; + + /** + * Get the target that opened this target. Top-level targets return `null`. + */ + abstract opener(): Target | undefined; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts new file mode 100644 index 0000000000..4de287f146 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import {withSourcePuppeteerURLIfNone} from '../common/util.js'; + +import type {CDPSession} from './CDPSession.js'; +import type {Realm} from './Realm.js'; + +/** + * This class represents a + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}. + * + * @remarks + * The events `workercreated` and `workerdestroyed` are emitted on the page + * object to signal the worker lifecycle. + * + * @example + * + * ```ts + * page.on('workercreated', worker => + * console.log('Worker created: ' + worker.url()) + * ); + * page.on('workerdestroyed', worker => + * console.log('Worker destroyed: ' + worker.url()) + * ); + * + * console.log('Current workers:'); + * for (const worker of page.workers()) { + * console.log(' ' + worker.url()); + * } + * ``` + * + * @public + */ +export abstract class WebWorker extends EventEmitter< + Record<EventType, unknown> +> { + /** + * @internal + */ + readonly timeoutSettings = new TimeoutSettings(); + + readonly #url: string; + + /** + * @internal + */ + constructor(url: string) { + super(); + + this.#url = url; + } + + /** + * @internal + */ + abstract mainRealm(): Realm; + + /** + * The URL of this web worker. + */ + url(): string { + return this.#url; + } + + /** + * The CDP session client the WebWorker belongs to. + */ + abstract get client(): CDPSession; + + /** + * Evaluates a given function in the {@link WebWorker | worker}. + * + * @remarks If the given function returns a promise, + * {@link WebWorker.evaluate | evaluate} will wait for the promise to resolve. + * + * As a rule of thumb, if the return value of the given function is more + * complicated than a JSON object (e.g. most classes), then + * {@link WebWorker.evaluate | evaluate} will _likely_ return some truncated + * value (or `{}`). This is because we are not returning the actual return + * value, but a deserialized version as a result of transferring the return + * value through a protocol to Puppeteer. + * + * In general, you should use + * {@link WebWorker.evaluateHandle | evaluateHandle} if + * {@link WebWorker.evaluate | evaluate} cannot serialize the return value + * properly or you need a mutable {@link JSHandle | handle} to the return + * object. + * + * @param func - Function to be evaluated. + * @param args - Arguments to pass into `func`. + * @returns The result of `func`. + */ + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >(func: Func | string, ...args: Params): Promise<Awaited<ReturnType<Func>>> { + func = withSourcePuppeteerURLIfNone(this.evaluate.name, func); + return await this.mainRealm().evaluate(func, ...args); + } + + /** + * Evaluates a given function in the {@link WebWorker | worker}. + * + * @remarks If the given function returns a promise, + * {@link WebWorker.evaluate | evaluate} will wait for the promise to resolve. + * + * In general, you should use + * {@link WebWorker.evaluateHandle | evaluateHandle} if + * {@link WebWorker.evaluate | evaluate} cannot serialize the return value + * properly or you need a mutable {@link JSHandle | handle} to the return + * object. + * + * @param func - Function to be evaluated. + * @param args - Arguments to pass into `func`. + * @returns A {@link JSHandle | handle} to the return value of `func`. + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + func: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + func = withSourcePuppeteerURLIfNone(this.evaluateHandle.name, func); + return await this.mainRealm().evaluateHandle(func, ...args); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts new file mode 100644 index 0000000000..d2bf832a6d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './Browser.js'; +export * from './BrowserContext.js'; +export * from './CDPSession.js'; +export * from './Dialog.js'; +export * from './ElementHandle.js'; +export * from './Environment.js'; +export * from './Frame.js'; +export * from './HTTPRequest.js'; +export * from './HTTPResponse.js'; +export * from './Input.js'; +export * from './JSHandle.js'; +export * from './Page.js'; +export * from './Realm.js'; +export * from './Target.js'; +export * from './WebWorker.js'; +export * from './locators/locators.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts new file mode 100644 index 0000000000..7bec11e38e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts @@ -0,0 +1,1088 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type { + Observable, + OperatorFunction, +} from '../../../third_party/rxjs/rxjs.js'; +import { + EMPTY, + catchError, + defaultIfEmpty, + defer, + filter, + first, + firstValueFrom, + from, + fromEvent, + identity, + ignoreElements, + map, + merge, + mergeMap, + noop, + pipe, + race, + raceWith, + retry, + tap, + throwIfEmpty, +} from '../../../third_party/rxjs/rxjs.js'; +import type {EventType} from '../../common/EventEmitter.js'; +import {EventEmitter} from '../../common/EventEmitter.js'; +import type {Awaitable, HandleFor, NodeFor} from '../../common/types.js'; +import {debugError, timeout} from '../../common/util.js'; +import type { + BoundingBox, + ClickOptions, + ElementHandle, +} from '../ElementHandle.js'; +import type {Frame} from '../Frame.js'; +import type {Page} from '../Page.js'; + +/** + * @public + */ +export type VisibilityOption = 'hidden' | 'visible' | null; +/** + * @public + */ +export interface LocatorOptions { + /** + * Whether to wait for the element to be `visible` or `hidden`. `null` to + * disable visibility checks. + */ + visibility: VisibilityOption; + /** + * Total timeout for the entire locator operation. + * + * Pass `0` to disable timeout. + * + * @defaultValue `Page.getDefaultTimeout()` + */ + timeout: number; + /** + * Whether to scroll the element into viewport if not in the viewprot already. + * @defaultValue `true` + */ + ensureElementIsInTheViewport: boolean; + /** + * Whether to wait for input elements to become enabled before the action. + * Applicable to `click` and `fill` actions. + * @defaultValue `true` + */ + waitForEnabled: boolean; + /** + * Whether to wait for the element's bounding box to be same between two + * animation frames. + * @defaultValue `true` + */ + waitForStableBoundingBox: boolean; +} +/** + * @public + */ +export interface ActionOptions { + signal?: AbortSignal; +} +/** + * @public + */ +export type LocatorClickOptions = ClickOptions & ActionOptions; +/** + * @public + */ +export interface LocatorScrollOptions extends ActionOptions { + scrollTop?: number; + scrollLeft?: number; +} +/** + * All the events that a locator instance may emit. + * + * @public + */ +export enum LocatorEvent { + /** + * Emitted every time before the locator performs an action on the located element(s). + */ + Action = 'action', +} +export { + /** + * @deprecated Use {@link LocatorEvent}. + */ + LocatorEvent as LocatorEmittedEvents, +}; +/** + * @public + */ +export interface LocatorEvents extends Record<EventType, unknown> { + [LocatorEvent.Action]: undefined; +} +export type { + /** + * @deprecated Use {@link LocatorEvents}. + */ + LocatorEvents as LocatorEventObject, +}; +/** + * Locators describe a strategy of locating objects and performing an action on + * them. If the action fails because the object is not ready for the action, the + * whole operation is retried. Various preconditions for a successful action are + * checked automatically. + * + * @public + */ +export abstract class Locator<T> extends EventEmitter<LocatorEvents> { + /** + * Creates a race between multiple locators but ensures that only a single one + * acts. + * + * @public + */ + static race<Locators extends readonly unknown[] | []>( + locators: Locators + ): Locator<AwaitedLocator<Locators[number]>> { + return RaceLocator.create(locators); + } + + /** + * Used for nominally typing {@link Locator}. + */ + declare _?: T; + + /** + * @internal + */ + protected visibility: VisibilityOption = null; + /** + * @internal + */ + protected _timeout = 30000; + #ensureElementIsInTheViewport = true; + #waitForEnabled = true; + #waitForStableBoundingBox = true; + + /** + * @internal + */ + protected operators = { + conditions: ( + conditions: Array<Action<T, never>>, + signal?: AbortSignal + ): OperatorFunction<HandleFor<T>, HandleFor<T>> => { + return mergeMap((handle: HandleFor<T>) => { + return merge( + ...conditions.map(condition => { + return condition(handle, signal); + }) + ).pipe(defaultIfEmpty(handle)); + }); + }, + retryAndRaceWithSignalAndTimer: <T>( + signal?: AbortSignal + ): OperatorFunction<T, T> => { + const candidates = []; + if (signal) { + candidates.push( + fromEvent(signal, 'abort').pipe( + map(() => { + throw signal.reason; + }) + ) + ); + } + candidates.push(timeout(this._timeout)); + return pipe( + retry({delay: RETRY_DELAY}), + raceWith<T, never[]>(...candidates) + ); + }, + }; + + // Determines when the locator will timeout for actions. + get timeout(): number { + return this._timeout; + } + + setTimeout(timeout: number): Locator<T> { + const locator = this._clone(); + locator._timeout = timeout; + return locator; + } + + setVisibility<NodeType extends Node>( + this: Locator<NodeType>, + visibility: VisibilityOption + ): Locator<NodeType> { + const locator = this._clone(); + locator.visibility = visibility; + return locator; + } + + setWaitForEnabled<NodeType extends Node>( + this: Locator<NodeType>, + value: boolean + ): Locator<NodeType> { + const locator = this._clone(); + locator.#waitForEnabled = value; + return locator; + } + + setEnsureElementIsInTheViewport<ElementType extends Element>( + this: Locator<ElementType>, + value: boolean + ): Locator<ElementType> { + const locator = this._clone(); + locator.#ensureElementIsInTheViewport = value; + return locator; + } + + setWaitForStableBoundingBox<ElementType extends Element>( + this: Locator<ElementType>, + value: boolean + ): Locator<ElementType> { + const locator = this._clone(); + locator.#waitForStableBoundingBox = value; + return locator; + } + + /** + * @internal + */ + copyOptions<T>(locator: Locator<T>): this { + this._timeout = locator._timeout; + this.visibility = locator.visibility; + this.#waitForEnabled = locator.#waitForEnabled; + this.#ensureElementIsInTheViewport = locator.#ensureElementIsInTheViewport; + this.#waitForStableBoundingBox = locator.#waitForStableBoundingBox; + return this; + } + + /** + * If the element has a "disabled" property, wait for the element to be + * enabled. + */ + #waitForEnabledIfNeeded = <ElementType extends Node>( + handle: HandleFor<ElementType>, + signal?: AbortSignal + ): Observable<never> => { + if (!this.#waitForEnabled) { + return EMPTY; + } + return from( + handle.frame.waitForFunction( + element => { + if (!(element instanceof HTMLElement)) { + return true; + } + const isNativeFormControl = [ + 'BUTTON', + 'INPUT', + 'SELECT', + 'TEXTAREA', + 'OPTION', + 'OPTGROUP', + ].includes(element.nodeName); + return !isNativeFormControl || !element.hasAttribute('disabled'); + }, + { + timeout: this._timeout, + signal, + }, + handle + ) + ).pipe(ignoreElements()); + }; + + /** + * Compares the bounding box of the element for two consecutive animation + * frames and waits till they are the same. + */ + #waitForStableBoundingBoxIfNeeded = <ElementType extends Element>( + handle: HandleFor<ElementType> + ): Observable<never> => { + if (!this.#waitForStableBoundingBox) { + return EMPTY; + } + return defer(() => { + // Note we don't use waitForFunction because that relies on RAF. + return from( + handle.evaluate(element => { + return new Promise<[BoundingBox, BoundingBox]>(resolve => { + window.requestAnimationFrame(() => { + const rect1 = element.getBoundingClientRect(); + window.requestAnimationFrame(() => { + const rect2 = element.getBoundingClientRect(); + resolve([ + { + x: rect1.x, + y: rect1.y, + width: rect1.width, + height: rect1.height, + }, + { + x: rect2.x, + y: rect2.y, + width: rect2.width, + height: rect2.height, + }, + ]); + }); + }); + }); + }) + ); + }).pipe( + first(([rect1, rect2]) => { + return ( + rect1.x === rect2.x && + rect1.y === rect2.y && + rect1.width === rect2.width && + rect1.height === rect2.height + ); + }), + retry({delay: RETRY_DELAY}), + ignoreElements() + ); + }; + + /** + * Checks if the element is in the viewport and auto-scrolls it if it is not. + */ + #ensureElementIsInTheViewportIfNeeded = <ElementType extends Element>( + handle: HandleFor<ElementType> + ): Observable<never> => { + if (!this.#ensureElementIsInTheViewport) { + return EMPTY; + } + return from(handle.isIntersectingViewport({threshold: 0})).pipe( + filter(isIntersectingViewport => { + return !isIntersectingViewport; + }), + mergeMap(() => { + return from(handle.scrollIntoView()); + }), + mergeMap(() => { + return defer(() => { + return from(handle.isIntersectingViewport({threshold: 0})); + }).pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); + }) + ); + }; + + #click<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<LocatorClickOptions> + ): Observable<void> { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + this.#waitForEnabledIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from(handle.click(options)).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #fill<ElementType extends Element>( + this: Locator<ElementType>, + value: string, + options?: Readonly<ActionOptions> + ): Observable<void> { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + this.#waitForEnabledIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from( + (handle as unknown as ElementHandle<HTMLElement>).evaluate(el => { + if (el instanceof HTMLSelectElement) { + return 'select'; + } + if (el instanceof HTMLTextAreaElement) { + return 'typeable-input'; + } + if (el instanceof HTMLInputElement) { + if ( + new Set([ + 'textarea', + 'text', + 'url', + 'tel', + 'search', + 'password', + 'number', + 'email', + ]).has(el.type) + ) { + return 'typeable-input'; + } else { + return 'other-input'; + } + } + + if (el.isContentEditable) { + return 'contenteditable'; + } + + return 'unknown'; + }) + ) + .pipe( + mergeMap(inputType => { + switch (inputType) { + case 'select': + return from(handle.select(value).then(noop)); + case 'contenteditable': + case 'typeable-input': + return from( + ( + handle as unknown as ElementHandle<HTMLInputElement> + ).evaluate((input, newValue) => { + const currentValue = input.isContentEditable + ? input.innerText + : input.value; + + // Clear the input if the current value does not match the filled + // out value. + if ( + newValue.length <= currentValue.length || + !newValue.startsWith(input.value) + ) { + if (input.isContentEditable) { + input.innerText = ''; + } else { + input.value = ''; + } + return newValue; + } + const originalValue = input.isContentEditable + ? input.innerText + : input.value; + + // If the value is partially filled out, only type the rest. Move + // cursor to the end of the common prefix. + if (input.isContentEditable) { + input.innerText = ''; + input.innerText = originalValue; + } else { + input.value = ''; + input.value = originalValue; + } + return newValue.substring(originalValue.length); + }, value) + ).pipe( + mergeMap(textToType => { + return from(handle.type(textToType)); + }) + ); + case 'other-input': + return from(handle.focus()).pipe( + mergeMap(() => { + return from( + handle.evaluate((input, value) => { + (input as HTMLInputElement).value = value; + input.dispatchEvent( + new Event('input', {bubbles: true}) + ); + input.dispatchEvent( + new Event('change', {bubbles: true}) + ); + }, value) + ); + }) + ); + case 'unknown': + throw new Error(`Element cannot be filled out.`); + } + }) + ) + .pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #hover<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<ActionOptions> + ): Observable<void> { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from(handle.hover()).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #scroll<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<LocatorScrollOptions> + ): Observable<void> { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from( + handle.evaluate( + (el, scrollTop, scrollLeft) => { + if (scrollTop !== undefined) { + el.scrollTop = scrollTop; + } + if (scrollLeft !== undefined) { + el.scrollLeft = scrollLeft; + } + }, + options?.scrollTop, + options?.scrollLeft + ) + ).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + /** + * @internal + */ + abstract _clone(): Locator<T>; + + /** + * @internal + */ + abstract _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>>; + + /** + * Clones the locator. + */ + clone(): Locator<T> { + return this._clone(); + } + + /** + * Waits for the locator to get a handle from the page. + * + * @public + */ + async waitHandle(options?: Readonly<ActionOptions>): Promise<HandleFor<T>> { + return await firstValueFrom( + this._wait(options).pipe( + this.operators.retryAndRaceWithSignalAndTimer(options?.signal) + ) + ); + } + + /** + * Waits for the locator to get the serialized value from the page. + * + * Note this requires the value to be JSON-serializable. + * + * @public + */ + async wait(options?: Readonly<ActionOptions>): Promise<T> { + using handle = await this.waitHandle(options); + return await handle.jsonValue(); + } + + /** + * Maps the locator using the provided mapper. + * + * @public + */ + map<To>(mapper: Mapper<T, To>): Locator<To> { + return new MappedLocator(this._clone(), handle => { + // SAFETY: TypeScript cannot deduce the type. + return (handle as any).evaluateHandle(mapper); + }); + } + + /** + * Creates an expectation that is evaluated against located values. + * + * If the expectations do not match, then the locator will retry. + * + * @public + */ + filter<S extends T>(predicate: Predicate<T, S>): Locator<S> { + return new FilteredLocator(this._clone(), async (handle, signal) => { + await (handle as ElementHandle<Node>).frame.waitForFunction( + predicate, + {signal, timeout: this._timeout}, + handle + ); + return true; + }); + } + + /** + * Creates an expectation that is evaluated against located handles. + * + * If the expectations do not match, then the locator will retry. + * + * @internal + */ + filterHandle<S extends T>( + predicate: Predicate<HandleFor<T>, HandleFor<S>> + ): Locator<S> { + return new FilteredLocator(this._clone(), predicate); + } + + /** + * Maps the locator using the provided mapper. + * + * @internal + */ + mapHandle<To>(mapper: HandleMapper<T, To>): Locator<To> { + return new MappedLocator(this._clone(), mapper); + } + + click<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<LocatorClickOptions> + ): Promise<void> { + return firstValueFrom(this.#click(options)); + } + + /** + * Fills out the input identified by the locator using the provided value. The + * type of the input is determined at runtime and the appropriate fill-out + * method is chosen based on the type. contenteditable, selector, inputs are + * supported. + */ + fill<ElementType extends Element>( + this: Locator<ElementType>, + value: string, + options?: Readonly<ActionOptions> + ): Promise<void> { + return firstValueFrom(this.#fill(value, options)); + } + + hover<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<ActionOptions> + ): Promise<void> { + return firstValueFrom(this.#hover(options)); + } + + scroll<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<LocatorScrollOptions> + ): Promise<void> { + return firstValueFrom(this.#scroll(options)); + } +} + +/** + * @internal + */ +export class FunctionLocator<T> extends Locator<T> { + static create<Ret>( + pageOrFrame: Page | Frame, + func: () => Awaitable<Ret> + ): Locator<Ret> { + return new FunctionLocator<Ret>(pageOrFrame, func).setTimeout( + 'getDefaultTimeout' in pageOrFrame + ? pageOrFrame.getDefaultTimeout() + : pageOrFrame.page().getDefaultTimeout() + ); + } + + #pageOrFrame: Page | Frame; + #func: () => Awaitable<T>; + + private constructor(pageOrFrame: Page | Frame, func: () => Awaitable<T>) { + super(); + + this.#pageOrFrame = pageOrFrame; + this.#func = func; + } + + override _clone(): FunctionLocator<T> { + return new FunctionLocator(this.#pageOrFrame, this.#func); + } + + _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> { + const signal = options?.signal; + return defer(() => { + return from( + this.#pageOrFrame.waitForFunction(this.#func, { + timeout: this.timeout, + signal, + }) + ); + }).pipe(throwIfEmpty()); + } +} + +/** + * @public + */ +export type Predicate<From, To extends From = From> = + | ((value: From) => value is To) + | ((value: From) => Awaitable<boolean>); +/** + * @internal + */ +export type HandlePredicate<From, To extends From = From> = + | ((value: HandleFor<From>, signal?: AbortSignal) => value is HandleFor<To>) + | ((value: HandleFor<From>, signal?: AbortSignal) => Awaitable<boolean>); + +/** + * @internal + */ +export abstract class DelegatedLocator<T, U> extends Locator<U> { + #delegate: Locator<T>; + + constructor(delegate: Locator<T>) { + super(); + + this.#delegate = delegate; + this.copyOptions(this.#delegate); + } + + protected get delegate(): Locator<T> { + return this.#delegate; + } + + override setTimeout(timeout: number): DelegatedLocator<T, U> { + const locator = super.setTimeout(timeout) as DelegatedLocator<T, U>; + locator.#delegate = this.#delegate.setTimeout(timeout); + return locator; + } + + override setVisibility<ValueType extends Node, NodeType extends Node>( + this: DelegatedLocator<ValueType, NodeType>, + visibility: VisibilityOption + ): DelegatedLocator<ValueType, NodeType> { + const locator = super.setVisibility<NodeType>( + visibility + ) as DelegatedLocator<ValueType, NodeType>; + locator.#delegate = locator.#delegate.setVisibility<ValueType>(visibility); + return locator; + } + + override setWaitForEnabled<ValueType extends Node, NodeType extends Node>( + this: DelegatedLocator<ValueType, NodeType>, + value: boolean + ): DelegatedLocator<ValueType, NodeType> { + const locator = super.setWaitForEnabled<NodeType>( + value + ) as DelegatedLocator<ValueType, NodeType>; + locator.#delegate = this.#delegate.setWaitForEnabled(value); + return locator; + } + + override setEnsureElementIsInTheViewport< + ValueType extends Element, + ElementType extends Element, + >( + this: DelegatedLocator<ValueType, ElementType>, + value: boolean + ): DelegatedLocator<ValueType, ElementType> { + const locator = super.setEnsureElementIsInTheViewport<ElementType>( + value + ) as DelegatedLocator<ValueType, ElementType>; + locator.#delegate = this.#delegate.setEnsureElementIsInTheViewport(value); + return locator; + } + + override setWaitForStableBoundingBox< + ValueType extends Element, + ElementType extends Element, + >( + this: DelegatedLocator<ValueType, ElementType>, + value: boolean + ): DelegatedLocator<ValueType, ElementType> { + const locator = super.setWaitForStableBoundingBox<ElementType>( + value + ) as DelegatedLocator<ValueType, ElementType>; + locator.#delegate = this.#delegate.setWaitForStableBoundingBox(value); + return locator; + } + + abstract override _clone(): DelegatedLocator<T, U>; + abstract override _wait(): Observable<HandleFor<U>>; +} + +/** + * @internal + */ +export class FilteredLocator<From, To extends From> extends DelegatedLocator< + From, + To +> { + #predicate: HandlePredicate<From, To>; + + constructor(base: Locator<From>, predicate: HandlePredicate<From, To>) { + super(base); + this.#predicate = predicate; + } + + override _clone(): FilteredLocator<From, To> { + return new FilteredLocator( + this.delegate.clone(), + this.#predicate + ).copyOptions(this); + } + + override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<To>> { + return this.delegate._wait(options).pipe( + mergeMap(handle => { + return from( + Promise.resolve(this.#predicate(handle, options?.signal)) + ).pipe( + filter(value => { + return value; + }), + map(() => { + // SAFETY: It passed the predicate, so this is correct. + return handle as HandleFor<To>; + }) + ); + }), + throwIfEmpty() + ); + } +} + +/** + * @public + */ +export type Mapper<From, To> = (value: From) => Awaitable<To>; +/** + * @internal + */ +export type HandleMapper<From, To> = ( + value: HandleFor<From>, + signal?: AbortSignal +) => Awaitable<HandleFor<To>>; +/** + * @internal + */ +export class MappedLocator<From, To> extends DelegatedLocator<From, To> { + #mapper: HandleMapper<From, To>; + + constructor(base: Locator<From>, mapper: HandleMapper<From, To>) { + super(base); + this.#mapper = mapper; + } + + override _clone(): MappedLocator<From, To> { + return new MappedLocator(this.delegate.clone(), this.#mapper).copyOptions( + this + ); + } + + override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<To>> { + return this.delegate._wait(options).pipe( + mergeMap(handle => { + return from(Promise.resolve(this.#mapper(handle, options?.signal))); + }) + ); + } +} + +/** + * @internal + */ +export type Action<T, U> = ( + element: HandleFor<T>, + signal?: AbortSignal +) => Observable<U>; +/** + * @internal + */ +export class NodeLocator<T extends Node> extends Locator<T> { + static create<Selector extends string>( + pageOrFrame: Page | Frame, + selector: Selector + ): Locator<NodeFor<Selector>> { + return new NodeLocator<NodeFor<Selector>>(pageOrFrame, selector).setTimeout( + 'getDefaultTimeout' in pageOrFrame + ? pageOrFrame.getDefaultTimeout() + : pageOrFrame.page().getDefaultTimeout() + ); + } + + #pageOrFrame: Page | Frame; + #selector: string; + + private constructor(pageOrFrame: Page | Frame, selector: string) { + super(); + + this.#pageOrFrame = pageOrFrame; + this.#selector = selector; + } + + /** + * Waits for the element to become visible or hidden. visibility === 'visible' + * means that the element has a computed style, the visibility property other + * than 'hidden' or 'collapse' and non-empty bounding box. visibility === + * 'hidden' means the opposite of that. + */ + #waitForVisibilityIfNeeded = (handle: HandleFor<T>): Observable<never> => { + if (!this.visibility) { + return EMPTY; + } + + return (() => { + switch (this.visibility) { + case 'hidden': + return defer(() => { + return from(handle.isHidden()); + }); + case 'visible': + return defer(() => { + return from(handle.isVisible()); + }); + } + })().pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); + }; + + override _clone(): NodeLocator<T> { + return new NodeLocator<T>(this.#pageOrFrame, this.#selector).copyOptions( + this + ); + } + + override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> { + const signal = options?.signal; + return defer(() => { + return from( + this.#pageOrFrame.waitForSelector(this.#selector, { + visible: false, + timeout: this._timeout, + signal, + }) as Promise<HandleFor<T> | null> + ); + }).pipe( + filter((value): value is NonNullable<typeof value> => { + return value !== null; + }), + throwIfEmpty(), + this.operators.conditions([this.#waitForVisibilityIfNeeded], signal) + ); + } +} + +/** + * @public + */ +export type AwaitedLocator<T> = T extends Locator<infer S> ? S : never; +function checkLocatorArray<T extends readonly unknown[] | []>( + locators: T +): ReadonlyArray<Locator<AwaitedLocator<T[number]>>> { + for (const locator of locators) { + if (!(locator instanceof Locator)) { + throw new Error('Unknown locator for race candidate'); + } + } + return locators as ReadonlyArray<Locator<AwaitedLocator<T[number]>>>; +} +/** + * @internal + */ +export class RaceLocator<T> extends Locator<T> { + static create<T extends readonly unknown[]>( + locators: T + ): Locator<AwaitedLocator<T[number]>> { + const array = checkLocatorArray(locators); + return new RaceLocator(array); + } + + #locators: ReadonlyArray<Locator<T>>; + + constructor(locators: ReadonlyArray<Locator<T>>) { + super(); + this.#locators = locators; + } + + override _clone(): RaceLocator<T> { + return new RaceLocator<T>( + this.#locators.map(locator => { + return locator.clone(); + }) + ).copyOptions(this); + } + + override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> { + return race( + ...this.#locators.map(locator => { + return locator._wait(options); + }) + ); + } +} + +/** + * For observables coming from promises, a delay is needed, otherwise RxJS will + * never yield in a permanent failure for a promise. + * + * We also don't want RxJS to do promise operations to often, so we bump the + * delay up to 100ms. + * + * @internal + */ +export const RETRY_DELAY = 100; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts new file mode 100644 index 0000000000..ace35a52b0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as BidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/BidiMapper.js'; +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import type {CDPEvents, CDPSession} from '../api/CDPSession.js'; +import type {Connection as CdpConnection} from '../cdp/Connection.js'; +import {debug} from '../common/Debug.js'; +import {TargetCloseError} from '../common/Errors.js'; +import type {Handler} from '../common/EventEmitter.js'; + +import {BidiConnection} from './Connection.js'; + +const bidiServerLogger = (prefix: string, ...args: unknown[]): void => { + debug(`bidi:${prefix}`)(args); +}; + +/** + * @internal + */ +export async function connectBidiOverCdp( + cdp: CdpConnection, + // TODO: replace with `BidiMapper.MapperOptions`, once it's exported in + // https://github.com/puppeteer/puppeteer/pull/11415. + options: {acceptInsecureCerts: boolean} +): Promise<BidiConnection> { + const transportBiDi = new NoOpTransport(); + const cdpConnectionAdapter = new CdpConnectionAdapter(cdp); + const pptrTransport = { + send(message: string): void { + // Forwards a BiDi command sent by Puppeteer to the input of the BidiServer. + transportBiDi.emitMessage(JSON.parse(message)); + }, + close(): void { + bidiServer.close(); + cdpConnectionAdapter.close(); + cdp.dispose(); + }, + onmessage(_message: string): void { + // The method is overridden by the Connection. + }, + }; + transportBiDi.on('bidiResponse', (message: object) => { + // Forwards a BiDi event sent by BidiServer to Puppeteer. + pptrTransport.onmessage(JSON.stringify(message)); + }); + const pptrBiDiConnection = new BidiConnection(cdp.url(), pptrTransport); + const bidiServer = await BidiMapper.BidiServer.createAndStart( + transportBiDi, + cdpConnectionAdapter, + // TODO: most likely need a little bit of refactoring + cdpConnectionAdapter.browserClient(), + '', + options, + undefined, + bidiServerLogger + ); + return pptrBiDiConnection; +} + +/** + * Manages CDPSessions for BidiServer. + * @internal + */ +class CdpConnectionAdapter { + #cdp: CdpConnection; + #adapters = new Map<CDPSession, CDPClientAdapter<CDPSession>>(); + #browserCdpConnection: CDPClientAdapter<CdpConnection>; + + constructor(cdp: CdpConnection) { + this.#cdp = cdp; + this.#browserCdpConnection = new CDPClientAdapter(cdp); + } + + browserClient(): CDPClientAdapter<CdpConnection> { + return this.#browserCdpConnection; + } + + getCdpClient(id: string) { + const session = this.#cdp.session(id); + if (!session) { + throw new Error(`Unknown CDP session with id ${id}`); + } + if (!this.#adapters.has(session)) { + const adapter = new CDPClientAdapter( + session, + id, + this.#browserCdpConnection + ); + this.#adapters.set(session, adapter); + return adapter; + } + return this.#adapters.get(session)!; + } + + close() { + this.#browserCdpConnection.close(); + for (const adapter of this.#adapters.values()) { + adapter.close(); + } + } +} + +/** + * Wrapper on top of CDPSession/CDPConnection to satisfy CDP interface that + * BidiServer needs. + * + * @internal + */ +class CDPClientAdapter<T extends CDPSession | CdpConnection> + extends BidiMapper.EventEmitter<CDPEvents> + implements BidiMapper.CdpClient +{ + #closed = false; + #client: T; + sessionId: string | undefined = undefined; + #browserClient?: BidiMapper.CdpClient; + + constructor( + client: T, + sessionId?: string, + browserClient?: BidiMapper.CdpClient + ) { + super(); + this.#client = client; + this.sessionId = sessionId; + this.#browserClient = browserClient; + this.#client.on('*', this.#forwardMessage as Handler<any>); + } + + browserClient(): BidiMapper.CdpClient { + return this.#browserClient!; + } + + #forwardMessage = <T extends keyof CDPEvents>( + method: T, + event: CDPEvents[T] + ) => { + this.emit(method, event); + }; + + async sendCommand<T extends keyof ProtocolMapping.Commands>( + method: T, + ...params: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (this.#closed) { + return; + } + try { + return await this.#client.send(method, ...params); + } catch (err) { + if (this.#closed) { + return; + } + throw err; + } + } + + close() { + this.#client.off('*', this.#forwardMessage as Handler<any>); + this.#closed = true; + } + + isCloseError(error: unknown): boolean { + return error instanceof TargetCloseError; + } +} + +/** + * This transport is given to the BiDi server instance and allows Puppeteer + * to send and receive commands to the BiDiServer. + * @internal + */ +class NoOpTransport + extends BidiMapper.EventEmitter<{ + bidiResponse: Bidi.ChromiumBidi.Message; + }> + implements BidiMapper.BidiTransport +{ + #onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void = + async (_m: Bidi.ChromiumBidi.Command): Promise<void> => { + return; + }; + + emitMessage(message: Bidi.ChromiumBidi.Command) { + void this.#onMessage(message); + } + + setOnMessage( + onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void + ): void { + this.#onMessage = onMessage; + } + + async sendMessage(message: Bidi.ChromiumBidi.Message): Promise<void> { + this.emit('bidiResponse', message); + } + + close() { + this.#onMessage = async (_m: Bidi.ChromiumBidi.Command): Promise<void> => { + return; + }; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts new file mode 100644 index 0000000000..42979790c9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcess} from 'child_process'; + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import { + Browser, + BrowserEvent, + type BrowserCloseCallback, + type BrowserContextOptions, + type DebugInfo, +} from '../api/Browser.js'; +import {BrowserContextEvent} from '../api/BrowserContext.js'; +import type {Page} from '../api/Page.js'; +import type {Target} from '../api/Target.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import type {Handler} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; + +import {BidiBrowserContext} from './BrowserContext.js'; +import {BrowsingContext, BrowsingContextEvent} from './BrowsingContext.js'; +import type {BidiConnection} from './Connection.js'; +import type {Browser as BrowserCore} from './core/Browser.js'; +import {Session} from './core/Session.js'; +import type {UserContext} from './core/UserContext.js'; +import { + BiDiBrowserTarget, + BiDiBrowsingContextTarget, + BiDiPageTarget, + type BidiTarget, +} from './Target.js'; + +/** + * @internal + */ +export interface BidiBrowserOptions { + process?: ChildProcess; + closeCallback?: BrowserCloseCallback; + connection: BidiConnection; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; +} + +/** + * @internal + */ +export class BidiBrowser extends Browser { + readonly protocol = 'webDriverBiDi'; + + // TODO: Update generator to include fully module + static readonly subscribeModules: string[] = [ + 'browsingContext', + 'network', + 'log', + 'script', + ]; + static readonly subscribeCdpEvents: Bidi.Cdp.EventNames[] = [ + // Coverage + 'cdp.Debugger.scriptParsed', + 'cdp.CSS.styleSheetAdded', + 'cdp.Runtime.executionContextsCleared', + // Tracing + 'cdp.Tracing.tracingComplete', + // TODO: subscribe to all CDP events in the future. + 'cdp.Network.requestWillBeSent', + 'cdp.Debugger.scriptParsed', + 'cdp.Page.screencastFrame', + ]; + + static async create(opts: BidiBrowserOptions): Promise<BidiBrowser> { + const session = await Session.from(opts.connection, { + alwaysMatch: { + acceptInsecureCerts: opts.ignoreHTTPSErrors, + webSocketUrl: true, + }, + }); + + await session.subscribe( + session.capabilities.browserName.toLocaleLowerCase().includes('firefox') + ? BidiBrowser.subscribeModules + : [...BidiBrowser.subscribeModules, ...BidiBrowser.subscribeCdpEvents] + ); + + const browser = new BidiBrowser(session.browser, opts); + browser.#initialize(); + await browser.#getTree(); + return browser; + } + + #process?: ChildProcess; + #closeCallback?: BrowserCloseCallback; + #browserCore: BrowserCore; + #defaultViewport: Viewport | null; + #targets = new Map<string, BidiTarget>(); + #browserContexts = new WeakMap<UserContext, BidiBrowserContext>(); + #browserTarget: BiDiBrowserTarget; + + #connectionEventHandlers = new Map< + Bidi.BrowsingContextEvent['method'], + Handler<any> + >([ + ['browsingContext.contextCreated', this.#onContextCreated.bind(this)], + ['browsingContext.contextDestroyed', this.#onContextDestroyed.bind(this)], + ['browsingContext.domContentLoaded', this.#onContextDomLoaded.bind(this)], + ['browsingContext.fragmentNavigated', this.#onContextNavigation.bind(this)], + ['browsingContext.navigationStarted', this.#onContextNavigation.bind(this)], + ]); + + private constructor(browserCore: BrowserCore, opts: BidiBrowserOptions) { + super(); + this.#process = opts.process; + this.#closeCallback = opts.closeCallback; + this.#browserCore = browserCore; + this.#defaultViewport = opts.defaultViewport; + this.#browserTarget = new BiDiBrowserTarget(this); + this.#createBrowserContext(this.#browserCore.defaultUserContext); + } + + #initialize() { + this.#browserCore.once('disconnected', () => { + this.emit(BrowserEvent.Disconnected, undefined); + }); + this.#process?.once('close', () => { + this.#browserCore.dispose('Browser process exited.', true); + this.connection.dispose(); + }); + + for (const [eventName, handler] of this.#connectionEventHandlers) { + this.connection.on(eventName, handler); + } + } + + get #browserName() { + return this.#browserCore.session.capabilities.browserName; + } + get #browserVersion() { + return this.#browserCore.session.capabilities.browserVersion; + } + + override userAgent(): never { + throw new UnsupportedOperation(); + } + + #createBrowserContext(userContext: UserContext) { + const browserContext = new BidiBrowserContext(this, userContext, { + defaultViewport: this.#defaultViewport, + }); + this.#browserContexts.set(userContext, browserContext); + return browserContext; + } + + #onContextDomLoaded(event: Bidi.BrowsingContext.Info) { + const target = this.#targets.get(event.context); + if (target) { + this.emit(BrowserEvent.TargetChanged, target); + } + } + + #onContextNavigation(event: Bidi.BrowsingContext.NavigationInfo) { + const target = this.#targets.get(event.context); + if (target) { + this.emit(BrowserEvent.TargetChanged, target); + target.browserContext().emit(BrowserContextEvent.TargetChanged, target); + } + } + + #onContextCreated(event: Bidi.BrowsingContext.ContextCreated['params']) { + const context = new BrowsingContext( + this.connection, + event, + this.#browserName + ); + this.connection.registerBrowsingContexts(context); + // TODO: once more browsing context types are supported, this should be + // updated to support those. Currently, all top-level contexts are treated + // as pages. + const browserContext = this.browserContexts().at(-1); + if (!browserContext) { + throw new Error('Missing browser contexts'); + } + const target = !context.parent + ? new BiDiPageTarget(browserContext, context) + : new BiDiBrowsingContextTarget(browserContext, context); + this.#targets.set(event.context, target); + + this.emit(BrowserEvent.TargetCreated, target); + target.browserContext().emit(BrowserContextEvent.TargetCreated, target); + + if (context.parent) { + const topLevel = this.connection.getTopLevelContext(context.parent); + topLevel.emit(BrowsingContextEvent.Created, context); + } + } + + async #getTree(): Promise<void> { + const {result} = await this.connection.send('browsingContext.getTree', {}); + for (const context of result.contexts) { + this.#onContextCreated(context); + } + } + + async #onContextDestroyed( + event: Bidi.BrowsingContext.ContextDestroyed['params'] + ) { + const context = this.connection.getBrowsingContext(event.context); + const topLevelContext = this.connection.getTopLevelContext(event.context); + topLevelContext.emit(BrowsingContextEvent.Destroyed, context); + const target = this.#targets.get(event.context); + const page = await target?.page(); + await page?.close().catch(debugError); + this.#targets.delete(event.context); + if (target) { + this.emit(BrowserEvent.TargetDestroyed, target); + target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target); + } + } + + get connection(): BidiConnection { + // SAFETY: We only have one implementation. + return this.#browserCore.session.connection as BidiConnection; + } + + override wsEndpoint(): string { + return this.connection.url; + } + + override async close(): Promise<void> { + for (const [eventName, handler] of this.#connectionEventHandlers) { + this.connection.off(eventName, handler); + } + if (this.connection.closed) { + return; + } + + try { + await this.#browserCore.close(); + await this.#closeCallback?.call(null); + } catch (error) { + // Fail silently. + debugError(error); + } finally { + this.connection.dispose(); + } + } + + override get connected(): boolean { + return !this.#browserCore.disposed; + } + + override process(): ChildProcess | null { + return this.#process ?? null; + } + + override async createIncognitoBrowserContext( + _options?: BrowserContextOptions + ): Promise<BidiBrowserContext> { + const userContext = await this.#browserCore.createUserContext(); + return this.#createBrowserContext(userContext); + } + + override async version(): Promise<string> { + return `${this.#browserName}/${this.#browserVersion}`; + } + + override browserContexts(): BidiBrowserContext[] { + return [...this.#browserCore.userContexts].map(context => { + return this.#browserContexts.get(context)!; + }); + } + + override defaultBrowserContext(): BidiBrowserContext { + return this.#browserContexts.get(this.#browserCore.defaultUserContext)!; + } + + override newPage(): Promise<Page> { + return this.defaultBrowserContext().newPage(); + } + + override targets(): Target[] { + return [this.#browserTarget, ...Array.from(this.#targets.values())]; + } + + _getTargetById(id: string): BidiTarget { + const target = this.#targets.get(id); + if (!target) { + throw new Error('Target not found'); + } + return target; + } + + override target(): Target { + return this.#browserTarget; + } + + override async disconnect(): Promise<void> { + try { + await this.#browserCore.session.end(); + } catch (error) { + // Fail silently. + debugError(error); + } finally { + this.connection.dispose(); + } + } + + override get debugInfo(): DebugInfo { + return { + pendingProtocolErrors: this.connection.getPendingProtocolErrors(), + }; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts new file mode 100644 index 0000000000..f616e90561 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BrowserCloseCallback} from '../api/Browser.js'; +import {Connection} from '../cdp/Connection.js'; +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import type { + BrowserConnectOptions, + ConnectOptions, +} from '../common/ConnectOptions.js'; +import {ProtocolError, UnsupportedOperation} from '../common/Errors.js'; +import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiConnection} from './Connection.js'; + +/** + * Users should never call this directly; it's called when calling `puppeteer.connect` + * with `protocol: 'webDriverBiDi'`. This method attaches Puppeteer to an existing browser + * instance. First it tries to connect to the browser using pure BiDi. If the protocol is + * not supported, connects to the browser using BiDi over CDP. + * + * @internal + */ +export async function _connectToBiDiBrowser( + connectionTransport: ConnectionTransport, + url: string, + options: BrowserConnectOptions & ConnectOptions +): Promise<BidiBrowser> { + const {ignoreHTTPSErrors = false, defaultViewport = DEFAULT_VIEWPORT} = + options; + + const {bidiConnection, closeCallback} = await getBiDiConnection( + connectionTransport, + url, + options + ); + const BiDi = await import(/* webpackIgnore: true */ './bidi.js'); + const bidiBrowser = await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: undefined, + defaultViewport: defaultViewport, + ignoreHTTPSErrors: ignoreHTTPSErrors, + }); + return bidiBrowser; +} + +/** + * Returns a BiDiConnection established to the endpoint specified by the options and a + * callback closing the browser. Callback depends on whether the connection is pure BiDi + * or BiDi over CDP. + * The method tries to connect to the browser using pure BiDi protocol, and falls back + * to BiDi over CDP. + */ +async function getBiDiConnection( + connectionTransport: ConnectionTransport, + url: string, + options: BrowserConnectOptions +): Promise<{ + bidiConnection: BidiConnection; + closeCallback: BrowserCloseCallback; +}> { + const BiDi = await import(/* webpackIgnore: true */ './bidi.js'); + const {ignoreHTTPSErrors = false, slowMo = 0, protocolTimeout} = options; + + // Try pure BiDi first. + const pureBidiConnection = new BiDi.BidiConnection( + url, + connectionTransport, + slowMo, + protocolTimeout + ); + try { + const result = await pureBidiConnection.send('session.status', {}); + if ('type' in result && result.type === 'success') { + // The `browserWSEndpoint` points to an endpoint supporting pure WebDriver BiDi. + return { + bidiConnection: pureBidiConnection, + closeCallback: async () => { + await pureBidiConnection.send('browser.close', {}).catch(debugError); + }, + }; + } + } catch (e) { + if (!(e instanceof ProtocolError)) { + // Unexpected exception not related to BiDi / CDP. Rethrow. + throw e; + } + } + // Unbind the connection to avoid memory leaks. + pureBidiConnection.unbind(); + + // Fall back to CDP over BiDi reusing the WS connection. + const cdpConnection = new Connection( + url, + connectionTransport, + slowMo, + protocolTimeout + ); + + const version = await cdpConnection.send('Browser.getVersion'); + if (version.product.toLowerCase().includes('firefox')) { + throw new UnsupportedOperation( + 'Firefox is not supported in BiDi over CDP mode.' + ); + } + + // TODO: use other options too. + const bidiOverCdpConnection = await BiDi.connectBidiOverCdp(cdpConnection, { + acceptInsecureCerts: ignoreHTTPSErrors, + }); + return { + bidiConnection: bidiOverCdpConnection, + closeCallback: async () => { + // In case of BiDi over CDP, we need to close browser via CDP. + await cdpConnection.send('Browser.close').catch(debugError); + }, + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts new file mode 100644 index 0000000000..feb5e9951d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {WaitForTargetOptions} from '../api/Browser.js'; +import {BrowserContext} from '../api/BrowserContext.js'; +import type {Page} from '../api/Page.js'; +import type {Target} from '../api/Target.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiConnection} from './Connection.js'; +import {UserContext} from './core/UserContext.js'; +import type {BidiPage} from './Page.js'; + +/** + * @internal + */ +export interface BidiBrowserContextOptions { + defaultViewport: Viewport | null; +} + +/** + * @internal + */ +export class BidiBrowserContext extends BrowserContext { + #browser: BidiBrowser; + #connection: BidiConnection; + #defaultViewport: Viewport | null; + #userContext: UserContext; + + constructor( + browser: BidiBrowser, + userContext: UserContext, + options: BidiBrowserContextOptions + ) { + super(); + this.#browser = browser; + this.#userContext = userContext; + this.#connection = this.#browser.connection; + this.#defaultViewport = options.defaultViewport; + } + + override targets(): Target[] { + return this.#browser.targets().filter(target => { + return target.browserContext() === this; + }); + } + + override waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options: WaitForTargetOptions = {} + ): Promise<Target> { + return this.#browser.waitForTarget(target => { + return target.browserContext() === this && predicate(target); + }, options); + } + + get connection(): BidiConnection { + return this.#connection; + } + + override async newPage(): Promise<Page> { + const {result} = await this.#connection.send('browsingContext.create', { + type: Bidi.BrowsingContext.CreateType.Tab, + }); + const target = this.#browser._getTargetById(result.context); + + // TODO: once BiDi has some concept matching BrowserContext, the newly + // created contexts should get automatically assigned to the right + // BrowserContext. For now, we assume that only explicitly created pages go + // to the current BrowserContext. Otherwise, the contexts get assigned to + // the default BrowserContext by the Browser. + target._setBrowserContext(this); + + const page = await target.page(); + if (!page) { + throw new Error('Page is not found'); + } + if (this.#defaultViewport) { + try { + await page.setViewport(this.#defaultViewport); + } catch { + // No support for setViewport in Firefox. + } + } + + return page; + } + + override async close(): Promise<void> { + if (!this.isIncognito()) { + throw new Error('Default context cannot be closed!'); + } + + // TODO: Remove once we have adopted the new browsing contexts. + for (const target of this.targets()) { + const page = await target?.page(); + try { + await page?.close(); + } catch (error) { + debugError(error); + } + } + + try { + await this.#userContext.remove(); + } catch (error) { + debugError(error); + } + } + + override browser(): BidiBrowser { + return this.#browser; + } + + override async pages(): Promise<BidiPage[]> { + const results = await Promise.all( + [...this.targets()].map(t => { + return t.page(); + }) + ); + return results.filter((p): p is BidiPage => { + return p !== null; + }); + } + + override isIncognito(): boolean { + return this.#userContext.id !== UserContext.DEFAULT; + } + + override overridePermissions(): never { + throw new UnsupportedOperation(); + } + + override clearPermissionOverrides(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts new file mode 100644 index 0000000000..0804628c06 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts @@ -0,0 +1,187 @@ +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.js'; + +import {CDPSession} from '../api/CDPSession.js'; +import type {Connection as CdpConnection} from '../cdp/Connection.js'; +import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; +import type {EventType} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {BidiConnection} from './Connection.js'; +import {BidiRealm} from './Realm.js'; + +/** + * @internal + */ +export const cdpSessions = new Map<string, CdpSessionWrapper>(); + +/** + * @internal + */ +export class CdpSessionWrapper extends CDPSession { + #context: BrowsingContext; + #sessionId = Deferred.create<string>(); + #detached = false; + + constructor(context: BrowsingContext, sessionId?: string) { + super(); + this.#context = context; + if (!this.#context.supportsCdp()) { + return; + } + if (sessionId) { + this.#sessionId.resolve(sessionId); + cdpSessions.set(sessionId, this); + } else { + context.connection + .send('cdp.getSession', { + context: context.id, + }) + .then(session => { + this.#sessionId.resolve(session.result.session!); + cdpSessions.set(session.result.session!, this); + }) + .catch(err => { + this.#sessionId.reject(err); + }); + } + } + + override connection(): CdpConnection | undefined { + return undefined; + } + + override async send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (!this.#context.supportsCdp()) { + throw new UnsupportedOperation( + 'CDP support is required for this feature. The current browser does not support CDP.' + ); + } + if (this.#detached) { + throw new TargetCloseError( + `Protocol error (${method}): Session closed. Most likely the page has been closed.` + ); + } + const session = await this.#sessionId.valueOrThrow(); + const {result} = await this.#context.connection.send('cdp.sendCommand', { + method: method, + params: paramArgs[0], + session, + }); + return result.result; + } + + override async detach(): Promise<void> { + cdpSessions.delete(this.id()); + if (!this.#detached && this.#context.supportsCdp()) { + await this.#context.cdpSession.send('Target.detachFromTarget', { + sessionId: this.id(), + }); + } + this.#detached = true; + } + + override id(): string { + const val = this.#sessionId.value(); + return val instanceof Error || val === undefined ? '' : val; + } +} + +/** + * Internal events that the BrowsingContext class emits. + * + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace BrowsingContextEvent { + /** + * Emitted on the top-level context, when a descendant context is created. + */ + export const Created = Symbol('BrowsingContext.created'); + /** + * Emitted on the top-level context, when a descendant context or the + * top-level context itself is destroyed. + */ + export const Destroyed = Symbol('BrowsingContext.destroyed'); +} + +/** + * @internal + */ +export interface BrowsingContextEvents extends Record<EventType, unknown> { + [BrowsingContextEvent.Created]: BrowsingContext; + [BrowsingContextEvent.Destroyed]: BrowsingContext; +} + +/** + * @internal + */ +export class BrowsingContext extends BidiRealm { + #id: string; + #url: string; + #cdpSession: CDPSession; + #parent?: string | null; + #browserName = ''; + + constructor( + connection: BidiConnection, + info: Bidi.BrowsingContext.Info, + browserName: string + ) { + super(connection); + this.#id = info.context; + this.#url = info.url; + this.#parent = info.parent; + this.#browserName = browserName; + this.#cdpSession = new CdpSessionWrapper(this, undefined); + + this.on('browsingContext.domContentLoaded', this.#updateUrl.bind(this)); + this.on('browsingContext.fragmentNavigated', this.#updateUrl.bind(this)); + this.on('browsingContext.load', this.#updateUrl.bind(this)); + } + + supportsCdp(): boolean { + return !this.#browserName.toLowerCase().includes('firefox'); + } + + #updateUrl(info: Bidi.BrowsingContext.NavigationInfo) { + this.#url = info.url; + } + + createRealmForSandbox(): BidiRealm { + return new BidiRealm(this.connection); + } + + get url(): string { + return this.#url; + } + + get id(): string { + return this.#id; + } + + get parent(): string | undefined | null { + return this.#parent; + } + + get cdpSession(): CDPSession { + return this.#cdpSession; + } + + async sendCdpCommand<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + return await this.#cdpSession.send(method, ...paramArgs); + } + + dispose(): void { + this.removeAllListeners(); + this.connection.unregisterBrowsingContexts(this.#id); + void this.#cdpSession.detach().catch(debugError); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts new file mode 100644 index 0000000000..9f37e38661 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; + +import {BidiConnection} from './Connection.js'; + +describe('WebDriver BiDi Connection', () => { + class TestConnectionTransport implements ConnectionTransport { + sent: string[] = []; + closed = false; + + send(message: string) { + this.sent.push(message); + } + + close(): void { + this.closed = true; + } + } + + it('should work', async () => { + const transport = new TestConnectionTransport(); + const connection = new BidiConnection('ws://127.0.0.1', transport); + const responsePromise = connection.send('session.new', { + capabilities: {}, + }); + expect(transport.sent).toEqual([ + `{"id":1,"method":"session.new","params":{"capabilities":{}}}`, + ]); + const id = JSON.parse(transport.sent[0]!).id; + const rawResponse = { + id, + type: 'success', + result: {ready: false, message: 'already connected'}, + }; + (transport as ConnectionTransport).onmessage?.(JSON.stringify(rawResponse)); + const response = await responsePromise; + expect(response).toEqual(rawResponse); + connection.dispose(); + expect(transport.closed).toBeTruthy(); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts new file mode 100644 index 0000000000..bce952ba39 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {CallbackRegistry} from '../common/CallbackRegistry.js'; +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {debug} from '../common/Debug.js'; +import type {EventsWithWildcard} from '../common/EventEmitter.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import {cdpSessions, type BrowsingContext} from './BrowsingContext.js'; +import type { + BidiEvents, + Commands as BidiCommands, + Connection, +} from './core/Connection.js'; + +const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND ►'); +const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV ◀'); + +/** + * @internal + */ +export interface Commands extends BidiCommands { + 'cdp.sendCommand': { + params: Bidi.Cdp.SendCommandParameters; + returnType: Bidi.Cdp.SendCommandResult; + }; + 'cdp.getSession': { + params: Bidi.Cdp.GetSessionParameters; + returnType: Bidi.Cdp.GetSessionResult; + }; +} + +/** + * @internal + */ +export class BidiConnection + extends EventEmitter<BidiEvents> + implements Connection +{ + #url: string; + #transport: ConnectionTransport; + #delay: number; + #timeout? = 0; + #closed = false; + #callbacks = new CallbackRegistry(); + #browsingContexts = new Map<string, BrowsingContext>(); + #emitters: Array<EventEmitter<any>> = []; + + constructor( + url: string, + transport: ConnectionTransport, + delay = 0, + timeout?: number + ) { + super(); + this.#url = url; + this.#delay = delay; + this.#timeout = timeout ?? 180_000; + + this.#transport = transport; + this.#transport.onmessage = this.onMessage.bind(this); + this.#transport.onclose = this.unbind.bind(this); + } + + get closed(): boolean { + return this.#closed; + } + + get url(): string { + return this.#url; + } + + pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void { + this.#emitters.push(emitter); + } + + override emit<Key extends keyof EventsWithWildcard<BidiEvents>>( + type: Key, + event: EventsWithWildcard<BidiEvents>[Key] + ): boolean { + for (const emitter of this.#emitters) { + emitter.emit(type, event); + } + return super.emit(type, event); + } + + send<T extends keyof Commands>( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}> { + assert(!this.#closed, 'Protocol error: Connection closed.'); + + return this.#callbacks.create(method, this.#timeout, id => { + const stringifiedMessage = JSON.stringify({ + id, + method, + params, + } as Bidi.Command); + debugProtocolSend(stringifiedMessage); + this.#transport.send(stringifiedMessage); + }) as Promise<{result: Commands[T]['returnType']}>; + } + + /** + * @internal + */ + protected async onMessage(message: string): Promise<void> { + if (this.#delay) { + await new Promise(f => { + return setTimeout(f, this.#delay); + }); + } + debugProtocolReceive(message); + const object: Bidi.ChromiumBidi.Message = JSON.parse(message); + if ('type' in object) { + switch (object.type) { + case 'success': + this.#callbacks.resolve(object.id, object); + return; + case 'error': + if (object.id === null) { + break; + } + this.#callbacks.reject( + object.id, + createProtocolError(object), + object.message + ); + return; + case 'event': + if (isCdpEvent(object)) { + cdpSessions + .get(object.params.session) + ?.emit(object.params.event, object.params.params); + return; + } + this.#maybeEmitOnContext(object); + // SAFETY: We know the method and parameter still match here. + this.emit( + object.method, + object.params as BidiEvents[keyof BidiEvents] + ); + return; + } + } + // Even if the response in not in BiDi protocol format but `id` is provided, reject + // the callback. This can happen if the endpoint supports CDP instead of BiDi. + if ('id' in object) { + this.#callbacks.reject( + (object as {id: number}).id, + `Protocol Error. Message is not in BiDi protocol format: '${message}'`, + object.message + ); + } + debugError(object); + } + + #maybeEmitOnContext(event: Bidi.ChromiumBidi.Event) { + let context: BrowsingContext | undefined; + // Context specific events + if ('context' in event.params && event.params.context !== null) { + context = this.#browsingContexts.get(event.params.context); + // `log.entryAdded` specific context + } else if ( + 'source' in event.params && + event.params.source.context !== undefined + ) { + context = this.#browsingContexts.get(event.params.source.context); + } + context?.emit(event.method, event.params); + } + + registerBrowsingContexts(context: BrowsingContext): void { + this.#browsingContexts.set(context.id, context); + } + + getBrowsingContext(contextId: string): BrowsingContext { + const currentContext = this.#browsingContexts.get(contextId); + if (!currentContext) { + throw new Error(`BrowsingContext ${contextId} does not exist.`); + } + return currentContext; + } + + getTopLevelContext(contextId: string): BrowsingContext { + let currentContext = this.#browsingContexts.get(contextId); + if (!currentContext) { + throw new Error(`BrowsingContext ${contextId} does not exist.`); + } + while (currentContext.parent) { + contextId = currentContext.parent; + currentContext = this.#browsingContexts.get(contextId); + if (!currentContext) { + throw new Error(`BrowsingContext ${contextId} does not exist.`); + } + } + return currentContext; + } + + unregisterBrowsingContexts(id: string): void { + this.#browsingContexts.delete(id); + } + + /** + * Unbinds the connection, but keeps the transport open. Useful when the transport will + * be reused by other connection e.g. with different protocol. + * @internal + */ + unbind(): void { + if (this.#closed) { + return; + } + this.#closed = true; + // Both may still be invoked and produce errors + this.#transport.onmessage = () => {}; + this.#transport.onclose = () => {}; + + this.#browsingContexts.clear(); + this.#callbacks.clear(); + } + + /** + * Unbinds the connection and closes the transport. + */ + dispose(): void { + this.unbind(); + this.#transport.close(); + } + + getPendingProtocolErrors(): Error[] { + return this.#callbacks.getPendingProtocolErrors(); + } +} + +/** + * @internal + */ +function createProtocolError(object: Bidi.ErrorResponse): string { + let message = `${object.error} ${object.message}`; + if (object.stacktrace) { + message += ` ${object.stacktrace}`; + } + return message; +} + +function isCdpEvent(event: Bidi.ChromiumBidi.Event): event is Bidi.Cdp.Event { + return event.method.startsWith('cdp.'); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts new file mode 100644 index 0000000000..14b87d403b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {debugError} from '../common/util.js'; + +/** + * @internal + */ +export class BidiDeserializer { + static deserializeNumber(value: Bidi.Script.SpecialNumber | number): number { + switch (value) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + return value; + } + } + + static deserializeLocalValue(result: Bidi.Script.RemoteValue): unknown { + switch (result.type) { + case 'array': + return result.value?.map(value => { + return BidiDeserializer.deserializeLocalValue(value); + }); + case 'set': + return result.value?.reduce((acc: Set<unknown>, value) => { + return acc.add(BidiDeserializer.deserializeLocalValue(value)); + }, new Set()); + case 'object': + return result.value?.reduce((acc: Record<any, unknown>, tuple) => { + const {key, value} = BidiDeserializer.deserializeTuple(tuple); + acc[key as any] = value; + return acc; + }, {}); + case 'map': + return result.value?.reduce((acc: Map<unknown, unknown>, tuple) => { + const {key, value} = BidiDeserializer.deserializeTuple(tuple); + return acc.set(key, value); + }, new Map()); + case 'promise': + return {}; + case 'regexp': + return new RegExp(result.value.pattern, result.value.flags); + case 'date': + return new Date(result.value); + case 'undefined': + return undefined; + case 'null': + return null; + case 'number': + return BidiDeserializer.deserializeNumber(result.value); + case 'bigint': + return BigInt(result.value); + case 'boolean': + return Boolean(result.value); + case 'string': + return result.value; + } + + debugError(`Deserialization of type ${result.type} not supported.`); + return undefined; + } + + static deserializeTuple([serializedKey, serializedValue]: [ + Bidi.Script.RemoteValue | string, + Bidi.Script.RemoteValue, + ]): {key: unknown; value: unknown} { + const key = + typeof serializedKey === 'string' + ? serializedKey + : BidiDeserializer.deserializeLocalValue(serializedKey); + const value = BidiDeserializer.deserializeLocalValue(serializedValue); + + return {key, value}; + } + + static deserialize(result: Bidi.Script.RemoteValue): any { + if (!result) { + debugError('Service did not produce a result.'); + return undefined; + } + + return BidiDeserializer.deserializeLocalValue(result); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts new file mode 100644 index 0000000000..ce22223461 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {Dialog} from '../api/Dialog.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export class BidiDialog extends Dialog { + #context: BrowsingContext; + + /** + * @internal + */ + constructor( + context: BrowsingContext, + type: Bidi.BrowsingContext.UserPromptOpenedParameters['type'], + message: string, + defaultValue?: string + ) { + super(type, message, defaultValue); + this.#context = context; + } + + /** + * @internal + */ + override async handle(options: { + accept: boolean; + text?: string; + }): Promise<void> { + await this.#context.connection.send('browsingContext.handleUserPrompt', { + context: this.#context.id, + accept: options.accept, + userText: options.text, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts new file mode 100644 index 0000000000..fd886e8c26 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {type AutofillData, ElementHandle} from '../api/ElementHandle.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import {throwIfDisposed} from '../util/decorators.js'; + +import type {BidiFrame} from './Frame.js'; +import {BidiJSHandle} from './JSHandle.js'; +import type {BidiRealm} from './Realm.js'; +import type {Sandbox} from './Sandbox.js'; + +/** + * @internal + */ +export class BidiElementHandle< + ElementType extends Node = Element, +> extends ElementHandle<ElementType> { + declare handle: BidiJSHandle<ElementType>; + + constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) { + super(new BidiJSHandle(sandbox, remoteValue)); + } + + override get realm(): Sandbox { + return this.handle.realm; + } + + override get frame(): BidiFrame { + return this.realm.environment; + } + + context(): BidiRealm { + return this.handle.context(); + } + + get isPrimitiveValue(): boolean { + return this.handle.isPrimitiveValue; + } + + remoteValue(): Bidi.Script.RemoteValue { + return this.handle.remoteValue(); + } + + @throwIfDisposed() + override async autofill(data: AutofillData): Promise<void> { + const client = this.frame.client; + const nodeInfo = await client.send('DOM.describeNode', { + objectId: this.handle.id, + }); + const fieldId = nodeInfo.node.backendNodeId; + const frameId = this.frame._id; + await client.send('Autofill.trigger', { + fieldId, + frameId, + card: data.creditCard, + }); + } + + override async contentFrame( + this: BidiElementHandle<HTMLIFrameElement> + ): Promise<BidiFrame>; + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async contentFrame(): Promise<BidiFrame | null> { + using handle = (await this.evaluateHandle(element => { + if (element instanceof HTMLIFrameElement) { + return element.contentWindow; + } + return; + })) as BidiJSHandle; + const value = handle.remoteValue(); + if (value.type === 'window') { + return this.frame.page().frame(value.value.context); + } + return null; + } + + override uploadFile(this: ElementHandle<HTMLInputElement>): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts new file mode 100644 index 0000000000..de95695785 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Viewport} from '../common/Viewport.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export class EmulationManager { + #browsingContext: BrowsingContext; + + constructor(browsingContext: BrowsingContext) { + this.#browsingContext = browsingContext; + } + + async emulateViewport(viewport: Viewport): Promise<void> { + await this.#browsingContext.connection.send('browsingContext.setViewport', { + context: this.#browsingContext.id, + viewport: + viewport.width && viewport.height + ? { + width: viewport.width, + height: viewport.height, + } + : null, + devicePixelRatio: viewport.deviceScaleFactor + ? viewport.deviceScaleFactor + : null, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts new file mode 100644 index 0000000000..62c6b5e37e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts @@ -0,0 +1,295 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {Awaitable, FlattenHandle} from '../common/types.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import type {BidiConnection} from './Connection.js'; +import {BidiDeserializer} from './Deserializer.js'; +import type {BidiFrame} from './Frame.js'; +import {BidiSerializer} from './Serializer.js'; + +type SendArgsChannel<Args> = (value: [id: number, args: Args]) => void; +type SendResolveChannel<Ret> = ( + value: [id: number, resolve: (ret: FlattenHandle<Awaited<Ret>>) => void] +) => void; +type SendRejectChannel = ( + value: [id: number, reject: (error: unknown) => void] +) => void; + +interface RemotePromiseCallbacks { + resolve: Deferred<Bidi.Script.RemoteValue>; + reject: Deferred<Bidi.Script.RemoteValue>; +} + +/** + * @internal + */ +export class ExposeableFunction<Args extends unknown[], Ret> { + readonly #frame; + + readonly name; + readonly #apply; + + readonly #channels; + readonly #callerInfos = new Map< + string, + Map<number, RemotePromiseCallbacks> + >(); + + #preloadScriptId?: Bidi.Script.PreloadScript; + + constructor( + frame: BidiFrame, + name: string, + apply: (...args: Args) => Awaitable<Ret> + ) { + this.#frame = frame; + this.name = name; + this.#apply = apply; + + this.#channels = { + args: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_args`, + resolve: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_resolve`, + reject: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_reject`, + }; + } + + async expose(): Promise<void> { + const connection = this.#connection; + const channelArguments = this.#channelArguments; + + // TODO(jrandolf): Implement cleanup with removePreloadScript. + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleArgumentsMessage + ); + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleResolveMessage + ); + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleRejectMessage + ); + + const functionDeclaration = stringifyFunction( + interpolateFunction( + ( + sendArgs: SendArgsChannel<Args>, + sendResolve: SendResolveChannel<Ret>, + sendReject: SendRejectChannel + ) => { + let id = 0; + Object.assign(globalThis, { + [PLACEHOLDER('name') as string]: function (...args: Args) { + return new Promise<FlattenHandle<Awaited<Ret>>>( + (resolve, reject) => { + sendArgs([id, args]); + sendResolve([id, resolve]); + sendReject([id, reject]); + ++id; + } + ); + }, + }); + }, + {name: JSON.stringify(this.name)} + ) + ); + + const {result} = await connection.send('script.addPreloadScript', { + functionDeclaration, + arguments: channelArguments, + contexts: [this.#frame.page().mainFrame()._id], + }); + this.#preloadScriptId = result.script; + + await Promise.all( + this.#frame + .page() + .frames() + .map(async frame => { + return await connection.send('script.callFunction', { + functionDeclaration, + arguments: channelArguments, + awaitPromise: false, + target: frame.mainRealm().realm.target, + }); + }) + ); + } + + #handleArgumentsMessage = async (params: Bidi.Script.MessageParameters) => { + if (params.channel !== this.#channels.args) { + return; + } + const connection = this.#connection; + const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); + const args = remoteValue.value?.[1]; + assert(args); + try { + const result = await this.#apply(...BidiDeserializer.deserialize(args)); + await connection.send('script.callFunction', { + functionDeclaration: stringifyFunction(([_, resolve]: any, result) => { + resolve(result); + }), + arguments: [ + (await callbacks.resolve.valueOrThrow()) as Bidi.Script.LocalValue, + BidiSerializer.serializeRemoteValue(result), + ], + awaitPromise: false, + target: { + realm: params.source.realm, + }, + }); + } catch (error) { + try { + if (error instanceof Error) { + await connection.send('script.callFunction', { + functionDeclaration: stringifyFunction( + ( + [_, reject]: [unknown, (error: Error) => void], + name: string, + message: string, + stack?: string + ) => { + const error = new Error(message); + error.name = name; + if (stack) { + error.stack = stack; + } + reject(error); + } + ), + arguments: [ + (await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue, + BidiSerializer.serializeRemoteValue(error.name), + BidiSerializer.serializeRemoteValue(error.message), + BidiSerializer.serializeRemoteValue(error.stack), + ], + awaitPromise: false, + target: { + realm: params.source.realm, + }, + }); + } else { + await connection.send('script.callFunction', { + functionDeclaration: stringifyFunction( + ( + [_, reject]: [unknown, (error: unknown) => void], + error: unknown + ) => { + reject(error); + } + ), + arguments: [ + (await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue, + BidiSerializer.serializeRemoteValue(error), + ], + awaitPromise: false, + target: { + realm: params.source.realm, + }, + }); + } + } catch (error) { + debugError(error); + } + } + }; + + get #connection(): BidiConnection { + return this.#frame.context().connection; + } + + get #channelArguments() { + return [ + { + type: 'channel' as const, + value: { + channel: this.#channels.args, + ownership: Bidi.Script.ResultOwnership.Root, + }, + }, + { + type: 'channel' as const, + value: { + channel: this.#channels.resolve, + ownership: Bidi.Script.ResultOwnership.Root, + }, + }, + { + type: 'channel' as const, + value: { + channel: this.#channels.reject, + ownership: Bidi.Script.ResultOwnership.Root, + }, + }, + ]; + } + + #handleResolveMessage = (params: Bidi.Script.MessageParameters) => { + if (params.channel !== this.#channels.resolve) { + return; + } + const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); + callbacks.resolve.resolve(remoteValue); + }; + + #handleRejectMessage = (params: Bidi.Script.MessageParameters) => { + if (params.channel !== this.#channels.reject) { + return; + } + const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); + callbacks.reject.resolve(remoteValue); + }; + + #getCallbacksAndRemoteValue(params: Bidi.Script.MessageParameters) { + const {data, source} = params; + assert(data.type === 'array'); + assert(data.value); + + const callerIdRemote = data.value[0]; + assert(callerIdRemote); + assert(callerIdRemote.type === 'number'); + assert(typeof callerIdRemote.value === 'number'); + + let bindingMap = this.#callerInfos.get(source.realm); + if (!bindingMap) { + bindingMap = new Map(); + this.#callerInfos.set(source.realm, bindingMap); + } + + const callerId = callerIdRemote.value; + let callbacks = bindingMap.get(callerId); + if (!callbacks) { + callbacks = { + resolve: new Deferred(), + reject: new Deferred(), + }; + bindingMap.set(callerId, callbacks); + } + return {callbacks, remoteValue: data}; + } + + [Symbol.dispose](): void { + void this[Symbol.asyncDispose]().catch(debugError); + } + + async [Symbol.asyncDispose](): Promise<void> { + if (this.#preloadScriptId) { + await this.#connection.send('script.removePreloadScript', { + script: this.#preloadScriptId, + }); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts new file mode 100644 index 0000000000..1638c2cbdf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts @@ -0,0 +1,313 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import { + first, + firstValueFrom, + forkJoin, + from, + map, + merge, + raceWith, + zip, +} from '../../third_party/rxjs/rxjs.js'; +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import { + Frame, + throwIfDetached, + type GoToOptions, + type WaitForOptions, +} from '../api/Frame.js'; +import type {WaitForSelectorOptions} from '../api/Page.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type {Awaitable, NodeFor} from '../common/types.js'; +import { + fromEmitterEvent, + NETWORK_IDLE_TIME, + timeout, + UTILITY_WORLD_NAME, +} from '../common/util.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import {ExposeableFunction} from './ExposedFunction.js'; +import type {BidiHTTPResponse} from './HTTPResponse.js'; +import { + getBiDiLifecycleEvent, + getBiDiReadinessState, + rewriteNavigationError, +} from './lifecycle.js'; +import type {BidiPage} from './Page.js'; +import { + MAIN_SANDBOX, + PUPPETEER_SANDBOX, + Sandbox, + type SandboxChart, +} from './Sandbox.js'; + +/** + * Puppeteer's Frame class could be viewed as a BiDi BrowsingContext implementation + * @internal + */ +export class BidiFrame extends Frame { + #page: BidiPage; + #context: BrowsingContext; + #timeoutSettings: TimeoutSettings; + #abortDeferred = Deferred.create<never>(); + #disposed = false; + sandboxes: SandboxChart; + override _id: string; + + constructor( + page: BidiPage, + context: BrowsingContext, + timeoutSettings: TimeoutSettings, + parentId?: string | null + ) { + super(); + this.#page = page; + this.#context = context; + this.#timeoutSettings = timeoutSettings; + this._id = this.#context.id; + this._parentId = parentId ?? undefined; + + this.sandboxes = { + [MAIN_SANDBOX]: new Sandbox(undefined, this, context, timeoutSettings), + [PUPPETEER_SANDBOX]: new Sandbox( + UTILITY_WORLD_NAME, + this, + context.createRealmForSandbox(), + timeoutSettings + ), + }; + } + + override get client(): CDPSession { + return this.context().cdpSession; + } + + override mainRealm(): Sandbox { + return this.sandboxes[MAIN_SANDBOX]; + } + + override isolatedRealm(): Sandbox { + return this.sandboxes[PUPPETEER_SANDBOX]; + } + + override page(): BidiPage { + return this.#page; + } + + override isOOPFrame(): never { + throw new UnsupportedOperation(); + } + + override url(): string { + return this.#context.url; + } + + override parentFrame(): BidiFrame | null { + return this.#page.frame(this._parentId ?? ''); + } + + override childFrames(): BidiFrame[] { + return this.#page.childFrames(this.#context.id); + } + + @throwIfDetached + override async goto( + url: string, + options: GoToOptions = {} + ): Promise<BidiHTTPResponse | null> { + const { + waitUntil = 'load', + timeout: ms = this.#timeoutSettings.navigationTimeout(), + } = options; + + const [readiness, networkIdle] = getBiDiReadinessState(waitUntil); + + const result$ = zip( + from( + this.#context.connection.send('browsingContext.navigate', { + context: this.#context.id, + url, + wait: readiness, + }) + ), + ...(networkIdle !== null + ? [ + this.#page.waitForNetworkIdle$({ + timeout: ms, + concurrency: networkIdle === 'networkidle2' ? 2 : 0, + idleTime: NETWORK_IDLE_TIME, + }), + ] + : []) + ).pipe( + map(([{result}]) => { + return result; + }), + raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())), + rewriteNavigationError(url, ms) + ); + + const result = await firstValueFrom(result$); + return this.#page.getNavigationResponse(result.navigation); + } + + @throwIfDetached + override async setContent( + html: string, + options: WaitForOptions = {} + ): Promise<void> { + const { + waitUntil = 'load', + timeout: ms = this.#timeoutSettings.navigationTimeout(), + } = options; + + const [waitEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil); + + const result$ = zip( + forkJoin([ + fromEmitterEvent(this.#context, waitEvent).pipe(first()), + from(this.setFrameContent(html)), + ]).pipe( + map(() => { + return null; + }) + ), + ...(networkIdle !== null + ? [ + this.#page.waitForNetworkIdle$({ + timeout: ms, + concurrency: networkIdle === 'networkidle2' ? 2 : 0, + idleTime: NETWORK_IDLE_TIME, + }), + ] + : []) + ).pipe( + raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())), + rewriteNavigationError('setContent', ms) + ); + + await firstValueFrom(result$); + } + + context(): BrowsingContext { + return this.#context; + } + + @throwIfDetached + override async waitForNavigation( + options: WaitForOptions = {} + ): Promise<BidiHTTPResponse | null> { + const { + waitUntil = 'load', + timeout: ms = this.#timeoutSettings.navigationTimeout(), + } = options; + + const [waitUntilEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil); + + const navigation$ = merge( + forkJoin([ + fromEmitterEvent( + this.#context, + Bidi.ChromiumBidi.BrowsingContext.EventNames.NavigationStarted + ).pipe(first()), + fromEmitterEvent(this.#context, waitUntilEvent).pipe(first()), + ]), + fromEmitterEvent( + this.#context, + Bidi.ChromiumBidi.BrowsingContext.EventNames.FragmentNavigated + ) + ).pipe( + map(result => { + if (Array.isArray(result)) { + return {result: result[1]}; + } + return {result}; + }) + ); + + const result$ = zip( + navigation$, + ...(networkIdle !== null + ? [ + this.#page.waitForNetworkIdle$({ + timeout: ms, + concurrency: networkIdle === 'networkidle2' ? 2 : 0, + idleTime: NETWORK_IDLE_TIME, + }), + ] + : []) + ).pipe( + map(([{result}]) => { + return result; + }), + raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())) + ); + + const result = await firstValueFrom(result$); + return this.#page.getNavigationResponse(result.navigation); + } + + override waitForDevicePrompt(): never { + throw new UnsupportedOperation(); + } + + override get detached(): boolean { + return this.#disposed; + } + + [disposeSymbol](): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#abortDeferred.reject(new Error('Frame detached')); + this.#context.dispose(); + this.sandboxes[MAIN_SANDBOX][disposeSymbol](); + this.sandboxes[PUPPETEER_SANDBOX][disposeSymbol](); + } + + #exposedFunctions = new Map<string, ExposeableFunction<never[], unknown>>(); + async exposeFunction<Args extends unknown[], Ret>( + name: string, + apply: (...args: Args) => Awaitable<Ret> + ): Promise<void> { + if (this.#exposedFunctions.has(name)) { + throw new Error( + `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!` + ); + } + const exposeable = new ExposeableFunction(this, name, apply); + this.#exposedFunctions.set(name, exposeable); + try { + await exposeable.expose(); + } catch (error) { + this.#exposedFunctions.delete(name); + throw error; + } + } + + override waitForSelector<Selector extends string>( + selector: Selector, + options?: WaitForSelectorOptions + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + if (selector.startsWith('aria')) { + throw new UnsupportedOperation( + 'ARIA selector is not supported for BiDi!' + ); + } + + return super.waitForSelector(selector, options); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts new file mode 100644 index 0000000000..57cb801b8c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {Frame} from '../api/Frame.js'; +import type { + ContinueRequestOverrides, + ResponseForRequest, +} from '../api/HTTPRequest.js'; +import {HTTPRequest, type ResourceType} from '../api/HTTPRequest.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import type {BidiHTTPResponse} from './HTTPResponse.js'; + +/** + * @internal + */ +export class BidiHTTPRequest extends HTTPRequest { + override _response: BidiHTTPResponse | null = null; + override _redirectChain: BidiHTTPRequest[]; + _navigationId: string | null; + + #url: string; + #resourceType: ResourceType; + + #method: string; + #postData?: string; + #headers: Record<string, string> = {}; + #initiator: Bidi.Network.Initiator; + #frame: Frame | null; + + constructor( + event: Bidi.Network.BeforeRequestSentParameters, + frame: Frame | null, + redirectChain: BidiHTTPRequest[] = [] + ) { + super(); + + this.#url = event.request.url; + this.#resourceType = event.initiator.type.toLowerCase() as ResourceType; + this.#method = event.request.method; + this.#postData = undefined; + this.#initiator = event.initiator; + this.#frame = frame; + + this._requestId = event.request.request; + this._redirectChain = redirectChain; + this._navigationId = event.navigation; + + for (const header of event.request.headers) { + // TODO: How to handle Binary Headers + // https://w3c.github.io/webdriver-bidi/#type-network-Header + if (header.value.type === 'string') { + this.#headers[header.name.toLowerCase()] = header.value.value; + } + } + } + + override get client(): never { + throw new UnsupportedOperation(); + } + + override url(): string { + return this.#url; + } + + override resourceType(): ResourceType { + return this.#resourceType; + } + + override method(): string { + return this.#method; + } + + override postData(): string | undefined { + return this.#postData; + } + + override hasPostData(): boolean { + return this.#postData !== undefined; + } + + override async fetchPostData(): Promise<string | undefined> { + return this.#postData; + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override response(): BidiHTTPResponse | null { + return this._response; + } + + override isNavigationRequest(): boolean { + return Boolean(this._navigationId); + } + + override initiator(): Bidi.Network.Initiator { + return this.#initiator; + } + + override redirectChain(): BidiHTTPRequest[] { + return this._redirectChain.slice(); + } + + override enqueueInterceptAction( + pendingHandler: () => void | PromiseLike<unknown> + ): void { + // Execute the handler when interception is not supported + void pendingHandler(); + } + + override frame(): Frame | null { + return this.#frame; + } + + override continueRequestOverrides(): never { + throw new UnsupportedOperation(); + } + + override continue(_overrides: ContinueRequestOverrides = {}): never { + throw new UnsupportedOperation(); + } + + override responseForRequest(): never { + throw new UnsupportedOperation(); + } + + override abortErrorReason(): never { + throw new UnsupportedOperation(); + } + + override interceptResolutionState(): never { + throw new UnsupportedOperation(); + } + + override isInterceptResolutionHandled(): never { + throw new UnsupportedOperation(); + } + + override finalizeInterceptions(): never { + throw new UnsupportedOperation(); + } + + override abort(): never { + throw new UnsupportedOperation(); + } + + override respond( + _response: Partial<ResponseForRequest>, + _priority?: number + ): never { + throw new UnsupportedOperation(); + } + + override failure(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts new file mode 100644 index 0000000000..ce28820a65 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type Protocol from 'devtools-protocol'; + +import type {Frame} from '../api/Frame.js'; +import { + HTTPResponse as HTTPResponse, + type RemoteAddress, +} from '../api/HTTPResponse.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import type {BidiHTTPRequest} from './HTTPRequest.js'; + +/** + * @internal + */ +export class BidiHTTPResponse extends HTTPResponse { + #request: BidiHTTPRequest; + #remoteAddress: RemoteAddress; + #status: number; + #statusText: string; + #url: string; + #fromCache: boolean; + #headers: Record<string, string> = {}; + #timings: Record<string, string> | null; + + constructor( + request: BidiHTTPRequest, + {response}: Bidi.Network.ResponseCompletedParameters + ) { + super(); + this.#request = request; + + this.#remoteAddress = { + ip: '', + port: -1, + }; + + this.#url = response.url; + this.#fromCache = response.fromCache; + this.#status = response.status; + this.#statusText = response.statusText; + // TODO: File and issue with BiDi spec + this.#timings = null; + + // TODO: Removed once the Firefox implementation is compliant with https://w3c.github.io/webdriver-bidi/#get-the-response-data. + for (const header of response.headers || []) { + // TODO: How to handle Binary Headers + // https://w3c.github.io/webdriver-bidi/#type-network-Header + if (header.value.type === 'string') { + this.#headers[header.name.toLowerCase()] = header.value.value; + } + } + } + + override remoteAddress(): RemoteAddress { + return this.#remoteAddress; + } + + override url(): string { + return this.#url; + } + + override status(): number { + return this.#status; + } + + override statusText(): string { + return this.#statusText; + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override request(): BidiHTTPRequest { + return this.#request; + } + + override fromCache(): boolean { + return this.#fromCache; + } + + override timing(): Protocol.Network.ResourceTiming | null { + return this.#timings as any; + } + + override frame(): Frame | null { + return this.#request.frame(); + } + + override fromServiceWorker(): boolean { + return false; + } + + override securityDetails(): never { + throw new UnsupportedOperation(); + } + + override buffer(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts new file mode 100644 index 0000000000..5406556d64 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts @@ -0,0 +1,732 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {Point} from '../api/ElementHandle.js'; +import { + Keyboard, + Mouse, + MouseButton, + Touchscreen, + type KeyDownOptions, + type KeyPressOptions, + type KeyboardTypeOptions, + type MouseClickOptions, + type MouseMoveOptions, + type MouseOptions, + type MouseWheelOptions, +} from '../api/Input.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import type {KeyInput} from '../common/USKeyboardLayout.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {BidiPage} from './Page.js'; + +const enum InputId { + Mouse = '__puppeteer_mouse', + Keyboard = '__puppeteer_keyboard', + Wheel = '__puppeteer_wheel', + Finger = '__puppeteer_finger', +} + +enum SourceActionsType { + None = 'none', + Key = 'key', + Pointer = 'pointer', + Wheel = 'wheel', +} + +enum ActionType { + Pause = 'pause', + KeyDown = 'keyDown', + KeyUp = 'keyUp', + PointerUp = 'pointerUp', + PointerDown = 'pointerDown', + PointerMove = 'pointerMove', + Scroll = 'scroll', +} + +const getBidiKeyValue = (key: KeyInput) => { + switch (key) { + case '\r': + case '\n': + key = 'Enter'; + break; + } + // Measures the number of code points rather than UTF-16 code units. + if ([...key].length === 1) { + return key; + } + switch (key) { + case 'Cancel': + return '\uE001'; + case 'Help': + return '\uE002'; + case 'Backspace': + return '\uE003'; + case 'Tab': + return '\uE004'; + case 'Clear': + return '\uE005'; + case 'Enter': + return '\uE007'; + case 'Shift': + case 'ShiftLeft': + return '\uE008'; + case 'Control': + case 'ControlLeft': + return '\uE009'; + case 'Alt': + case 'AltLeft': + return '\uE00A'; + case 'Pause': + return '\uE00B'; + case 'Escape': + return '\uE00C'; + case 'PageUp': + return '\uE00E'; + case 'PageDown': + return '\uE00F'; + case 'End': + return '\uE010'; + case 'Home': + return '\uE011'; + case 'ArrowLeft': + return '\uE012'; + case 'ArrowUp': + return '\uE013'; + case 'ArrowRight': + return '\uE014'; + case 'ArrowDown': + return '\uE015'; + case 'Insert': + return '\uE016'; + case 'Delete': + return '\uE017'; + case 'NumpadEqual': + return '\uE019'; + case 'Numpad0': + return '\uE01A'; + case 'Numpad1': + return '\uE01B'; + case 'Numpad2': + return '\uE01C'; + case 'Numpad3': + return '\uE01D'; + case 'Numpad4': + return '\uE01E'; + case 'Numpad5': + return '\uE01F'; + case 'Numpad6': + return '\uE020'; + case 'Numpad7': + return '\uE021'; + case 'Numpad8': + return '\uE022'; + case 'Numpad9': + return '\uE023'; + case 'NumpadMultiply': + return '\uE024'; + case 'NumpadAdd': + return '\uE025'; + case 'NumpadSubtract': + return '\uE027'; + case 'NumpadDecimal': + return '\uE028'; + case 'NumpadDivide': + return '\uE029'; + case 'F1': + return '\uE031'; + case 'F2': + return '\uE032'; + case 'F3': + return '\uE033'; + case 'F4': + return '\uE034'; + case 'F5': + return '\uE035'; + case 'F6': + return '\uE036'; + case 'F7': + return '\uE037'; + case 'F8': + return '\uE038'; + case 'F9': + return '\uE039'; + case 'F10': + return '\uE03A'; + case 'F11': + return '\uE03B'; + case 'F12': + return '\uE03C'; + case 'Meta': + case 'MetaLeft': + return '\uE03D'; + case 'ShiftRight': + return '\uE050'; + case 'ControlRight': + return '\uE051'; + case 'AltRight': + return '\uE052'; + case 'MetaRight': + return '\uE053'; + case 'Digit0': + return '0'; + case 'Digit1': + return '1'; + case 'Digit2': + return '2'; + case 'Digit3': + return '3'; + case 'Digit4': + return '4'; + case 'Digit5': + return '5'; + case 'Digit6': + return '6'; + case 'Digit7': + return '7'; + case 'Digit8': + return '8'; + case 'Digit9': + return '9'; + case 'KeyA': + return 'a'; + case 'KeyB': + return 'b'; + case 'KeyC': + return 'c'; + case 'KeyD': + return 'd'; + case 'KeyE': + return 'e'; + case 'KeyF': + return 'f'; + case 'KeyG': + return 'g'; + case 'KeyH': + return 'h'; + case 'KeyI': + return 'i'; + case 'KeyJ': + return 'j'; + case 'KeyK': + return 'k'; + case 'KeyL': + return 'l'; + case 'KeyM': + return 'm'; + case 'KeyN': + return 'n'; + case 'KeyO': + return 'o'; + case 'KeyP': + return 'p'; + case 'KeyQ': + return 'q'; + case 'KeyR': + return 'r'; + case 'KeyS': + return 's'; + case 'KeyT': + return 't'; + case 'KeyU': + return 'u'; + case 'KeyV': + return 'v'; + case 'KeyW': + return 'w'; + case 'KeyX': + return 'x'; + case 'KeyY': + return 'y'; + case 'KeyZ': + return 'z'; + case 'Semicolon': + return ';'; + case 'Equal': + return '='; + case 'Comma': + return ','; + case 'Minus': + return '-'; + case 'Period': + return '.'; + case 'Slash': + return '/'; + case 'Backquote': + return '`'; + case 'BracketLeft': + return '['; + case 'Backslash': + return '\\'; + case 'BracketRight': + return ']'; + case 'Quote': + return '"'; + default: + throw new Error(`Unknown key: "${key}"`); + } +}; + +/** + * @internal + */ +export class BidiKeyboard extends Keyboard { + #page: BidiPage; + + constructor(page: BidiPage) { + super(); + this.#page = page; + } + + override async down( + key: KeyInput, + _options?: Readonly<KeyDownOptions> + ): Promise<void> { + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions: [ + { + type: ActionType.KeyDown, + value: getBidiKeyValue(key), + }, + ], + }, + ], + }); + } + + override async up(key: KeyInput): Promise<void> { + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions: [ + { + type: ActionType.KeyUp, + value: getBidiKeyValue(key), + }, + ], + }, + ], + }); + } + + override async press( + key: KeyInput, + options: Readonly<KeyPressOptions> = {} + ): Promise<void> { + const {delay = 0} = options; + const actions: Bidi.Input.KeySourceAction[] = [ + { + type: ActionType.KeyDown, + value: getBidiKeyValue(key), + }, + ]; + if (delay > 0) { + actions.push({ + type: ActionType.Pause, + duration: delay, + }); + } + actions.push({ + type: ActionType.KeyUp, + value: getBidiKeyValue(key), + }); + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions, + }, + ], + }); + } + + override async type( + text: string, + options: Readonly<KeyboardTypeOptions> = {} + ): Promise<void> { + const {delay = 0} = options; + // This spread separates the characters into code points rather than UTF-16 + // code units. + const values = ([...text] as KeyInput[]).map(getBidiKeyValue); + const actions: Bidi.Input.KeySourceAction[] = []; + if (delay <= 0) { + for (const value of values) { + actions.push( + { + type: ActionType.KeyDown, + value, + }, + { + type: ActionType.KeyUp, + value, + } + ); + } + } else { + for (const value of values) { + actions.push( + { + type: ActionType.KeyDown, + value, + }, + { + type: ActionType.Pause, + duration: delay, + }, + { + type: ActionType.KeyUp, + value, + } + ); + } + } + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions, + }, + ], + }); + } + + override async sendCharacter(char: string): Promise<void> { + // Measures the number of code points rather than UTF-16 code units. + if ([...char].length > 1) { + throw new Error('Cannot send more than 1 character.'); + } + const frame = await this.#page.focusedFrame(); + await frame.isolatedRealm().evaluate(async char => { + document.execCommand('insertText', false, char); + }, char); + } +} + +/** + * @internal + */ +export interface BidiMouseClickOptions extends MouseClickOptions { + origin?: Bidi.Input.Origin; +} + +/** + * @internal + */ +export interface BidiMouseMoveOptions extends MouseMoveOptions { + origin?: Bidi.Input.Origin; +} + +/** + * @internal + */ +export interface BidiTouchMoveOptions { + origin?: Bidi.Input.Origin; +} + +const getBidiButton = (button: MouseButton) => { + switch (button) { + case MouseButton.Left: + return 0; + case MouseButton.Middle: + return 1; + case MouseButton.Right: + return 2; + case MouseButton.Back: + return 3; + case MouseButton.Forward: + return 4; + } +}; + +/** + * @internal + */ +export class BidiMouse extends Mouse { + #context: BrowsingContext; + #lastMovePoint: Point = {x: 0, y: 0}; + + constructor(context: BrowsingContext) { + super(); + this.#context = context; + } + + override async reset(): Promise<void> { + this.#lastMovePoint = {x: 0, y: 0}; + await this.#context.connection.send('input.releaseActions', { + context: this.#context.id, + }); + } + + override async move( + x: number, + y: number, + options: Readonly<BidiMouseMoveOptions> = {} + ): Promise<void> { + const from = this.#lastMovePoint; + const to = { + x: Math.round(x), + y: Math.round(y), + }; + const actions: Bidi.Input.PointerSourceAction[] = []; + const steps = options.steps ?? 0; + for (let i = 0; i < steps; ++i) { + actions.push({ + type: ActionType.PointerMove, + x: from.x + (to.x - from.x) * (i / steps), + y: from.y + (to.y - from.y) * (i / steps), + origin: options.origin, + }); + } + actions.push({ + type: ActionType.PointerMove, + ...to, + origin: options.origin, + }); + // https://w3c.github.io/webdriver-bidi/#command-input-performActions:~:text=input.PointerMoveAction%20%3D%20%7B%0A%20%20type%3A%20%22pointerMove%22%2C%0A%20%20x%3A%20js%2Dint%2C + this.#lastMovePoint = to; + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions, + }, + ], + }); + } + + override async down(options: Readonly<MouseOptions> = {}): Promise<void> { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions: [ + { + type: ActionType.PointerDown, + button: getBidiButton(options.button ?? MouseButton.Left), + }, + ], + }, + ], + }); + } + + override async up(options: Readonly<MouseOptions> = {}): Promise<void> { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions: [ + { + type: ActionType.PointerUp, + button: getBidiButton(options.button ?? MouseButton.Left), + }, + ], + }, + ], + }); + } + + override async click( + x: number, + y: number, + options: Readonly<BidiMouseClickOptions> = {} + ): Promise<void> { + const actions: Bidi.Input.PointerSourceAction[] = [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + ]; + const pointerDownAction = { + type: ActionType.PointerDown, + button: getBidiButton(options.button ?? MouseButton.Left), + } as const; + const pointerUpAction = { + type: ActionType.PointerUp, + button: pointerDownAction.button, + } as const; + for (let i = 1; i < (options.count ?? 1); ++i) { + actions.push(pointerDownAction, pointerUpAction); + } + actions.push(pointerDownAction); + if (options.delay) { + actions.push({ + type: ActionType.Pause, + duration: options.delay, + }); + } + actions.push(pointerUpAction); + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions, + }, + ], + }); + } + + override async wheel( + options: Readonly<MouseWheelOptions> = {} + ): Promise<void> { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Wheel, + id: InputId.Wheel, + actions: [ + { + type: ActionType.Scroll, + ...(this.#lastMovePoint ?? { + x: 0, + y: 0, + }), + deltaX: options.deltaX ?? 0, + deltaY: options.deltaY ?? 0, + }, + ], + }, + ], + }); + } + + override drag(): never { + throw new UnsupportedOperation(); + } + + override dragOver(): never { + throw new UnsupportedOperation(); + } + + override dragEnter(): never { + throw new UnsupportedOperation(); + } + + override drop(): never { + throw new UnsupportedOperation(); + } + + override dragAndDrop(): never { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BidiTouchscreen extends Touchscreen { + #context: BrowsingContext; + + constructor(context: BrowsingContext) { + super(); + this.#context = context; + } + + override async touchStart( + x: number, + y: number, + options: BidiTouchMoveOptions = {} + ): Promise<void> { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, + }, + actions: [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + { + type: ActionType.PointerDown, + button: 0, + }, + ], + }, + ], + }); + } + + override async touchMove( + x: number, + y: number, + options: BidiTouchMoveOptions = {} + ): Promise<void> { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, + }, + actions: [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + ], + }, + ], + }); + } + + override async touchEnd(): Promise<void> { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, + }, + actions: [ + { + type: ActionType.PointerUp, + button: 0, + }, + ], + }, + ], + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts new file mode 100644 index 0000000000..7104601553 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {ElementHandle} from '../api/ElementHandle.js'; +import {JSHandle} from '../api/JSHandle.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import {BidiDeserializer} from './Deserializer.js'; +import type {BidiRealm} from './Realm.js'; +import type {Sandbox} from './Sandbox.js'; +import {releaseReference} from './util.js'; + +/** + * @internal + */ +export class BidiJSHandle<T = unknown> extends JSHandle<T> { + #disposed = false; + readonly #sandbox: Sandbox; + readonly #remoteValue: Bidi.Script.RemoteValue; + + constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) { + super(); + this.#sandbox = sandbox; + this.#remoteValue = remoteValue; + } + + context(): BidiRealm { + return this.realm.environment.context(); + } + + override get realm(): Sandbox { + return this.#sandbox; + } + + override get disposed(): boolean { + return this.#disposed; + } + + override async jsonValue(): Promise<T> { + return await this.evaluate(value => { + return value; + }); + } + + override asElement(): ElementHandle<Node> | null { + return null; + } + + override async dispose(): Promise<void> { + if (this.#disposed) { + return; + } + this.#disposed = true; + if ('handle' in this.#remoteValue) { + await releaseReference( + this.context(), + this.#remoteValue as Bidi.Script.RemoteReference + ); + } + } + + get isPrimitiveValue(): boolean { + switch (this.#remoteValue.type) { + case 'string': + case 'number': + case 'bigint': + case 'boolean': + case 'undefined': + case 'null': + return true; + + default: + return false; + } + } + + override toString(): string { + if (this.isPrimitiveValue) { + return 'JSHandle:' + BidiDeserializer.deserialize(this.#remoteValue); + } + + return 'JSHandle@' + this.#remoteValue.type; + } + + override get id(): string | undefined { + return 'handle' in this.#remoteValue ? this.#remoteValue.handle : undefined; + } + + remoteValue(): Bidi.Script.RemoteValue { + return this.#remoteValue; + } + + override remoteObject(): never { + throw new UnsupportedOperation('Not available in WebDriver BiDi'); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts new file mode 100644 index 0000000000..2caaf0ad50 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter, EventSubscription} from '../common/EventEmitter.js'; +import { + NetworkManagerEvent, + type NetworkManagerEvents, +} from '../common/NetworkManagerEvents.js'; +import {DisposableStack} from '../util/disposable.js'; + +import type {BidiConnection} from './Connection.js'; +import type {BidiFrame} from './Frame.js'; +import {BidiHTTPRequest} from './HTTPRequest.js'; +import {BidiHTTPResponse} from './HTTPResponse.js'; +import type {BidiPage} from './Page.js'; + +/** + * @internal + */ +export class BidiNetworkManager extends EventEmitter<NetworkManagerEvents> { + #connection: BidiConnection; + #page: BidiPage; + #subscriptions = new DisposableStack(); + + #requestMap = new Map<string, BidiHTTPRequest>(); + #navigationMap = new Map<string, BidiHTTPResponse>(); + + constructor(connection: BidiConnection, page: BidiPage) { + super(); + this.#connection = connection; + this.#page = page; + + // TODO: Subscribe to the Frame individually + this.#subscriptions.use( + new EventSubscription( + this.#connection, + 'network.beforeRequestSent', + this.#onBeforeRequestSent.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + this.#connection, + 'network.responseStarted', + this.#onResponseStarted.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + this.#connection, + 'network.responseCompleted', + this.#onResponseCompleted.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + this.#connection, + 'network.fetchError', + this.#onFetchError.bind(this) + ) + ); + } + + #onBeforeRequestSent(event: Bidi.Network.BeforeRequestSentParameters): void { + const frame = this.#page.frame(event.context ?? ''); + if (!frame) { + return; + } + const request = this.#requestMap.get(event.request.request); + let upsertRequest: BidiHTTPRequest; + if (request) { + request._redirectChain.push(request); + upsertRequest = new BidiHTTPRequest(event, frame, request._redirectChain); + } else { + upsertRequest = new BidiHTTPRequest(event, frame, []); + } + this.#requestMap.set(event.request.request, upsertRequest); + this.emit(NetworkManagerEvent.Request, upsertRequest); + } + + #onResponseStarted(_event: Bidi.Network.ResponseStartedParameters) {} + + #onResponseCompleted(event: Bidi.Network.ResponseCompletedParameters): void { + const request = this.#requestMap.get(event.request.request); + if (!request) { + return; + } + const response = new BidiHTTPResponse(request, event); + request._response = response; + if (event.navigation) { + this.#navigationMap.set(event.navigation, response); + } + if (response.fromCache()) { + this.emit(NetworkManagerEvent.RequestServedFromCache, request); + } + this.emit(NetworkManagerEvent.Response, response); + this.emit(NetworkManagerEvent.RequestFinished, request); + } + + #onFetchError(event: Bidi.Network.FetchErrorParameters) { + const request = this.#requestMap.get(event.request.request); + if (!request) { + return; + } + request._failureText = event.errorText; + this.emit(NetworkManagerEvent.RequestFailed, request); + this.#requestMap.delete(event.request.request); + } + + getNavigationResponse(navigationId?: string | null): BidiHTTPResponse | null { + if (!navigationId) { + return null; + } + const response = this.#navigationMap.get(navigationId); + + return response ?? null; + } + + inFlightRequestsCount(): number { + let inFlightRequestCounter = 0; + for (const request of this.#requestMap.values()) { + if (!request.response() || request._failureText) { + inFlightRequestCounter++; + } + } + + return inFlightRequestCounter; + } + + clearMapAfterFrameDispose(frame: BidiFrame): void { + for (const [id, request] of this.#requestMap.entries()) { + if (request.frame() === frame) { + this.#requestMap.delete(id); + } + } + + for (const [id, response] of this.#navigationMap.entries()) { + if (response.frame() === frame) { + this.#navigationMap.delete(id); + } + } + } + + dispose(): void { + this.removeAllListeners(); + this.#requestMap.clear(); + this.#navigationMap.clear(); + this.#subscriptions.dispose(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts new file mode 100644 index 0000000000..053d23b63a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts @@ -0,0 +1,913 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Readable} from 'stream'; + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type Protocol from 'devtools-protocol'; + +import { + firstValueFrom, + from, + map, + raceWith, + zip, +} from '../../third_party/rxjs/rxjs.js'; +import type {CDPSession} from '../api/CDPSession.js'; +import type {BoundingBox} from '../api/ElementHandle.js'; +import type {WaitForOptions} from '../api/Frame.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import { + Page, + PageEvent, + type GeolocationOptions, + type MediaFeature, + type NewDocumentScriptEvaluation, + type ScreenshotOptions, +} from '../api/Page.js'; +import {Accessibility} from '../cdp/Accessibility.js'; +import {Coverage} from '../cdp/Coverage.js'; +import {EmulationManager as CdpEmulationManager} from '../cdp/EmulationManager.js'; +import {FrameTree} from '../cdp/FrameTree.js'; +import {Tracing} from '../cdp/Tracing.js'; +import { + ConsoleMessage, + type ConsoleMessageLocation, +} from '../common/ConsoleMessage.js'; +import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; +import type {Handler} from '../common/EventEmitter.js'; +import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; +import type {PDFOptions} from '../common/PDFOptions.js'; +import type {Awaitable} from '../common/types.js'; +import { + debugError, + evaluationString, + NETWORK_IDLE_TIME, + parsePDFOptions, + timeout, + validateDialogType, +} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiBrowserContext} from './BrowserContext.js'; +import { + BrowsingContextEvent, + CdpSessionWrapper, + type BrowsingContext, +} from './BrowsingContext.js'; +import type {BidiConnection} from './Connection.js'; +import {BidiDeserializer} from './Deserializer.js'; +import {BidiDialog} from './Dialog.js'; +import {BidiElementHandle} from './ElementHandle.js'; +import {EmulationManager} from './EmulationManager.js'; +import {BidiFrame} from './Frame.js'; +import type {BidiHTTPRequest} from './HTTPRequest.js'; +import type {BidiHTTPResponse} from './HTTPResponse.js'; +import {BidiKeyboard, BidiMouse, BidiTouchscreen} from './Input.js'; +import type {BidiJSHandle} from './JSHandle.js'; +import {getBiDiReadinessState, rewriteNavigationError} from './lifecycle.js'; +import {BidiNetworkManager} from './NetworkManager.js'; +import {createBidiHandle} from './Realm.js'; +import type {BiDiPageTarget} from './Target.js'; + +/** + * @internal + */ +export class BidiPage extends Page { + #accessibility: Accessibility; + #connection: BidiConnection; + #frameTree = new FrameTree<BidiFrame>(); + #networkManager: BidiNetworkManager; + #viewport: Viewport | null = null; + #closedDeferred = Deferred.create<never, TargetCloseError>(); + #subscribedEvents = new Map<Bidi.Event['method'], Handler<any>>([ + ['log.entryAdded', this.#onLogEntryAdded.bind(this)], + ['browsingContext.load', this.#onFrameLoaded.bind(this)], + [ + 'browsingContext.fragmentNavigated', + this.#onFrameFragmentNavigated.bind(this), + ], + [ + 'browsingContext.domContentLoaded', + this.#onFrameDOMContentLoaded.bind(this), + ], + ['browsingContext.userPromptOpened', this.#onDialog.bind(this)], + ]); + readonly #networkManagerEvents = [ + [ + NetworkManagerEvent.Request, + (request: BidiHTTPRequest) => { + this.emit(PageEvent.Request, request); + }, + ], + [ + NetworkManagerEvent.RequestServedFromCache, + (request: BidiHTTPRequest) => { + this.emit(PageEvent.RequestServedFromCache, request); + }, + ], + [ + NetworkManagerEvent.RequestFailed, + (request: BidiHTTPRequest) => { + this.emit(PageEvent.RequestFailed, request); + }, + ], + [ + NetworkManagerEvent.RequestFinished, + (request: BidiHTTPRequest) => { + this.emit(PageEvent.RequestFinished, request); + }, + ], + [ + NetworkManagerEvent.Response, + (response: BidiHTTPResponse) => { + this.emit(PageEvent.Response, response); + }, + ], + ] as const; + + readonly #browsingContextEvents = new Map<symbol, Handler<any>>([ + [BrowsingContextEvent.Created, this.#onContextCreated.bind(this)], + [BrowsingContextEvent.Destroyed, this.#onContextDestroyed.bind(this)], + ]); + #tracing: Tracing; + #coverage: Coverage; + #cdpEmulationManager: CdpEmulationManager; + #emulationManager: EmulationManager; + #mouse: BidiMouse; + #touchscreen: BidiTouchscreen; + #keyboard: BidiKeyboard; + #browsingContext: BrowsingContext; + #browserContext: BidiBrowserContext; + #target: BiDiPageTarget; + + _client(): CDPSession { + return this.mainFrame().context().cdpSession; + } + + constructor( + browsingContext: BrowsingContext, + browserContext: BidiBrowserContext, + target: BiDiPageTarget + ) { + super(); + this.#browsingContext = browsingContext; + this.#browserContext = browserContext; + this.#target = target; + this.#connection = browsingContext.connection; + + for (const [event, subscriber] of this.#browsingContextEvents) { + this.#browsingContext.on(event, subscriber); + } + + this.#networkManager = new BidiNetworkManager(this.#connection, this); + + for (const [event, subscriber] of this.#subscribedEvents) { + this.#connection.on(event, subscriber); + } + + for (const [event, subscriber] of this.#networkManagerEvents) { + // TODO: remove any + this.#networkManager.on(event, subscriber as any); + } + + const frame = new BidiFrame( + this, + this.#browsingContext, + this._timeoutSettings, + this.#browsingContext.parent + ); + this.#frameTree.addFrame(frame); + this.emit(PageEvent.FrameAttached, frame); + + // TODO: https://github.com/w3c/webdriver-bidi/issues/443 + this.#accessibility = new Accessibility( + this.mainFrame().context().cdpSession + ); + this.#tracing = new Tracing(this.mainFrame().context().cdpSession); + this.#coverage = new Coverage(this.mainFrame().context().cdpSession); + this.#cdpEmulationManager = new CdpEmulationManager( + this.mainFrame().context().cdpSession + ); + this.#emulationManager = new EmulationManager(browsingContext); + this.#mouse = new BidiMouse(this.mainFrame().context()); + this.#touchscreen = new BidiTouchscreen(this.mainFrame().context()); + this.#keyboard = new BidiKeyboard(this); + } + + /** + * @internal + */ + get connection(): BidiConnection { + return this.#connection; + } + + override async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata | undefined + ): Promise<void> { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Network.setUserAgentOverride', { + userAgent: userAgent, + userAgentMetadata: userAgentMetadata, + }); + } + + override async setBypassCSP(enabled: boolean): Promise<void> { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Page.setBypassCSP', {enabled}); + } + + override async queryObjects<Prototype>( + prototypeHandle: BidiJSHandle<Prototype> + ): Promise<BidiJSHandle<Prototype[]>> { + assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); + assert( + prototypeHandle.id, + 'Prototype JSHandle must not be referencing primitive value' + ); + const response = await this.mainFrame().client.send( + 'Runtime.queryObjects', + { + prototypeObjectId: prototypeHandle.id, + } + ); + return createBidiHandle(this.mainFrame().mainRealm(), { + type: 'array', + handle: response.objects.objectId, + }) as BidiJSHandle<Prototype[]>; + } + + _setBrowserContext(browserContext: BidiBrowserContext): void { + this.#browserContext = browserContext; + } + + override get accessibility(): Accessibility { + return this.#accessibility; + } + + override get tracing(): Tracing { + return this.#tracing; + } + + override get coverage(): Coverage { + return this.#coverage; + } + + override get mouse(): BidiMouse { + return this.#mouse; + } + + override get touchscreen(): BidiTouchscreen { + return this.#touchscreen; + } + + override get keyboard(): BidiKeyboard { + return this.#keyboard; + } + + override browser(): BidiBrowser { + return this.browserContext().browser(); + } + + override browserContext(): BidiBrowserContext { + return this.#browserContext; + } + + override mainFrame(): BidiFrame { + const mainFrame = this.#frameTree.getMainFrame(); + assert(mainFrame, 'Requesting main frame too early!'); + return mainFrame; + } + + /** + * @internal + */ + async focusedFrame(): Promise<BidiFrame> { + using frame = await this.mainFrame() + .isolatedRealm() + .evaluateHandle(() => { + let frame: HTMLIFrameElement | undefined; + let win: Window | null = window; + while (win?.document.activeElement instanceof HTMLIFrameElement) { + frame = win.document.activeElement; + win = frame.contentWindow; + } + return frame; + }); + if (!(frame instanceof BidiElementHandle)) { + return this.mainFrame(); + } + return await frame.contentFrame(); + } + + override frames(): BidiFrame[] { + return Array.from(this.#frameTree.frames()); + } + + frame(frameId?: string): BidiFrame | null { + return this.#frameTree.getById(frameId ?? '') || null; + } + + childFrames(frameId: string): BidiFrame[] { + return this.#frameTree.childFrames(frameId); + } + + #onFrameLoaded(info: Bidi.BrowsingContext.NavigationInfo): void { + const frame = this.frame(info.context); + if (frame && this.mainFrame() === frame) { + this.emit(PageEvent.Load, undefined); + } + } + + #onFrameFragmentNavigated(info: Bidi.BrowsingContext.NavigationInfo): void { + const frame = this.frame(info.context); + if (frame) { + this.emit(PageEvent.FrameNavigated, frame); + } + } + + #onFrameDOMContentLoaded(info: Bidi.BrowsingContext.NavigationInfo): void { + const frame = this.frame(info.context); + if (frame) { + frame._hasStartedLoading = true; + if (this.mainFrame() === frame) { + this.emit(PageEvent.DOMContentLoaded, undefined); + } + this.emit(PageEvent.FrameNavigated, frame); + } + } + + #onContextCreated(context: BrowsingContext): void { + if ( + !this.frame(context.id) && + (this.frame(context.parent ?? '') || !this.#frameTree.getMainFrame()) + ) { + const frame = new BidiFrame( + this, + context, + this._timeoutSettings, + context.parent + ); + this.#frameTree.addFrame(frame); + if (frame !== this.mainFrame()) { + this.emit(PageEvent.FrameAttached, frame); + } + } + } + + #onContextDestroyed(context: BrowsingContext): void { + const frame = this.frame(context.id); + + if (frame) { + if (frame === this.mainFrame()) { + this.emit(PageEvent.Close, undefined); + } + this.#removeFramesRecursively(frame); + } + } + + #removeFramesRecursively(frame: BidiFrame): void { + for (const child of frame.childFrames()) { + this.#removeFramesRecursively(child); + } + frame[disposeSymbol](); + this.#networkManager.clearMapAfterFrameDispose(frame); + this.#frameTree.removeFrame(frame); + this.emit(PageEvent.FrameDetached, frame); + } + + #onLogEntryAdded(event: Bidi.Log.Entry): void { + const frame = this.frame(event.source.context); + if (!frame) { + return; + } + if (isConsoleLogEntry(event)) { + const args = event.args.map(arg => { + return createBidiHandle(frame.mainRealm(), arg); + }); + + const text = args + .reduce((value, arg) => { + const parsedValue = arg.isPrimitiveValue + ? BidiDeserializer.deserialize(arg.remoteValue()) + : arg.toString(); + return `${value} ${parsedValue}`; + }, '') + .slice(1); + + this.emit( + PageEvent.Console, + new ConsoleMessage( + event.method as any, + text, + args, + getStackTraceLocations(event.stackTrace) + ) + ); + } else if (isJavaScriptLogEntry(event)) { + const error = new Error(event.text ?? ''); + + const messageHeight = error.message.split('\n').length; + const messageLines = error.stack!.split('\n').splice(0, messageHeight); + + const stackLines = []; + if (event.stackTrace) { + for (const frame of event.stackTrace.callFrames) { + // Note we need to add `1` because the values are 0-indexed. + stackLines.push( + ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ + frame.lineNumber + 1 + }:${frame.columnNumber + 1})` + ); + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [...messageLines, ...stackLines].join('\n'); + this.emit(PageEvent.PageError, error); + } else { + debugError( + `Unhandled LogEntry with type "${event.type}", text "${event.text}" and level "${event.level}"` + ); + } + } + + #onDialog(event: Bidi.BrowsingContext.UserPromptOpenedParameters): void { + const frame = this.frame(event.context); + if (!frame) { + return; + } + const type = validateDialogType(event.type); + + const dialog = new BidiDialog( + frame.context(), + type, + event.message, + event.defaultValue + ); + this.emit(PageEvent.Dialog, dialog); + } + + getNavigationResponse(id?: string | null): BidiHTTPResponse | null { + return this.#networkManager.getNavigationResponse(id); + } + + override isClosed(): boolean { + return this.#closedDeferred.finished(); + } + + override async close(options?: {runBeforeUnload?: boolean}): Promise<void> { + if (this.#closedDeferred.finished()) { + return; + } + + this.#closedDeferred.reject(new TargetCloseError('Page closed!')); + this.#networkManager.dispose(); + + await this.#connection.send('browsingContext.close', { + context: this.mainFrame()._id, + promptUnload: options?.runBeforeUnload ?? false, + }); + + this.emit(PageEvent.Close, undefined); + this.removeAllListeners(); + } + + override async reload( + options: WaitForOptions = {} + ): Promise<BidiHTTPResponse | null> { + const { + waitUntil = 'load', + timeout: ms = this._timeoutSettings.navigationTimeout(), + } = options; + + const [readiness, networkIdle] = getBiDiReadinessState(waitUntil); + + const result$ = zip( + from( + this.#connection.send('browsingContext.reload', { + context: this.mainFrame()._id, + wait: readiness, + }) + ), + ...(networkIdle !== null + ? [ + this.waitForNetworkIdle$({ + timeout: ms, + concurrency: networkIdle === 'networkidle2' ? 2 : 0, + idleTime: NETWORK_IDLE_TIME, + }), + ] + : []) + ).pipe( + map(([{result}]) => { + return result; + }), + raceWith(timeout(ms), from(this.#closedDeferred.valueOrThrow())), + rewriteNavigationError(this.url(), ms) + ); + + const result = await firstValueFrom(result$); + return this.getNavigationResponse(result.navigation); + } + + override setDefaultNavigationTimeout(timeout: number): void { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + override setDefaultTimeout(timeout: number): void { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + override getDefaultTimeout(): number { + return this._timeoutSettings.timeout(); + } + + override isJavaScriptEnabled(): boolean { + return this.#cdpEmulationManager.javascriptEnabled; + } + + override async setGeolocation(options: GeolocationOptions): Promise<void> { + return await this.#cdpEmulationManager.setGeolocation(options); + } + + override async setJavaScriptEnabled(enabled: boolean): Promise<void> { + return await this.#cdpEmulationManager.setJavaScriptEnabled(enabled); + } + + override async emulateMediaType(type?: string): Promise<void> { + return await this.#cdpEmulationManager.emulateMediaType(type); + } + + override async emulateCPUThrottling(factor: number | null): Promise<void> { + return await this.#cdpEmulationManager.emulateCPUThrottling(factor); + } + + override async emulateMediaFeatures( + features?: MediaFeature[] + ): Promise<void> { + return await this.#cdpEmulationManager.emulateMediaFeatures(features); + } + + override async emulateTimezone(timezoneId?: string): Promise<void> { + return await this.#cdpEmulationManager.emulateTimezone(timezoneId); + } + + override async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + return await this.#cdpEmulationManager.emulateIdleState(overrides); + } + + override async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + return await this.#cdpEmulationManager.emulateVisionDeficiency(type); + } + + override async setViewport(viewport: Viewport): Promise<void> { + if (!this.#browsingContext.supportsCdp()) { + await this.#emulationManager.emulateViewport(viewport); + this.#viewport = viewport; + return; + } + const needsReload = + await this.#cdpEmulationManager.emulateViewport(viewport); + this.#viewport = viewport; + if (needsReload) { + await this.reload(); + } + } + + override viewport(): Viewport | null { + return this.#viewport; + } + + override async pdf(options: PDFOptions = {}): Promise<Buffer> { + const {timeout: ms = this._timeoutSettings.timeout(), path = undefined} = + options; + const { + printBackground: background, + margin, + landscape, + width, + height, + pageRanges: ranges, + scale, + preferCSSPageSize, + } = parsePDFOptions(options, 'cm'); + const pageRanges = ranges ? ranges.split(', ') : []; + const {result} = await firstValueFrom( + from( + this.#connection.send('browsingContext.print', { + context: this.mainFrame()._id, + background, + margin, + orientation: landscape ? 'landscape' : 'portrait', + page: { + width, + height, + }, + pageRanges, + scale, + shrinkToFit: !preferCSSPageSize, + }) + ).pipe(raceWith(timeout(ms))) + ); + + const buffer = Buffer.from(result.data, 'base64'); + + await this._maybeWriteBufferToFile(path, buffer); + + return buffer; + } + + override async createPDFStream( + options?: PDFOptions | undefined + ): Promise<Readable> { + const buffer = await this.pdf(options); + try { + const {Readable} = await import('stream'); + return Readable.from(buffer); + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + 'Can only pass a file path in a Node-like environment.' + ); + } + throw error; + } + } + + override async _screenshot( + options: Readonly<ScreenshotOptions> + ): Promise<string> { + const {clip, type, captureBeyondViewport, quality} = options; + if (options.omitBackground !== undefined && options.omitBackground) { + throw new UnsupportedOperation(`BiDi does not support 'omitBackground'.`); + } + if (options.optimizeForSpeed !== undefined && options.optimizeForSpeed) { + throw new UnsupportedOperation( + `BiDi does not support 'optimizeForSpeed'.` + ); + } + if (options.fromSurface !== undefined && !options.fromSurface) { + throw new UnsupportedOperation(`BiDi does not support 'fromSurface'.`); + } + if (clip !== undefined && clip.scale !== undefined && clip.scale !== 1) { + throw new UnsupportedOperation( + `BiDi does not support 'scale' in 'clip'.` + ); + } + + let box: BoundingBox | undefined; + if (clip) { + if (captureBeyondViewport) { + box = clip; + } else { + // The clip is always with respect to the document coordinates, so we + // need to convert this to viewport coordinates when we aren't capturing + // beyond the viewport. + const [pageLeft, pageTop] = await this.evaluate(() => { + if (!window.visualViewport) { + throw new Error('window.visualViewport is not supported.'); + } + return [ + window.visualViewport.pageLeft, + window.visualViewport.pageTop, + ] as const; + }); + box = { + ...clip, + x: clip.x - pageLeft, + y: clip.y - pageTop, + }; + } + } + + const { + result: {data}, + } = await this.#connection.send('browsingContext.captureScreenshot', { + context: this.mainFrame()._id, + origin: captureBeyondViewport ? 'document' : 'viewport', + format: { + type: `image/${type}`, + ...(quality !== undefined ? {quality: quality / 100} : {}), + }, + ...(box ? {clip: {type: 'box', ...box}} : {}), + }); + return data; + } + + override async createCDPSession(): Promise<CDPSession> { + const {sessionId} = await this.mainFrame() + .context() + .cdpSession.send('Target.attachToTarget', { + targetId: this.mainFrame()._id, + flatten: true, + }); + return new CdpSessionWrapper(this.mainFrame().context(), sessionId); + } + + override async bringToFront(): Promise<void> { + await this.#connection.send('browsingContext.activate', { + context: this.mainFrame()._id, + }); + } + + override async evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<NewDocumentScriptEvaluation> { + const expression = evaluationExpression(pageFunction, ...args); + const {result} = await this.#connection.send('script.addPreloadScript', { + functionDeclaration: expression, + contexts: [this.mainFrame()._id], + }); + + return {identifier: result.script}; + } + + override async removeScriptToEvaluateOnNewDocument( + id: string + ): Promise<void> { + await this.#connection.send('script.removePreloadScript', { + script: id, + }); + } + + override async exposeFunction<Args extends unknown[], Ret>( + name: string, + pptrFunction: + | ((...args: Args) => Awaitable<Ret>) + | {default: (...args: Args) => Awaitable<Ret>} + ): Promise<void> { + return await this.mainFrame().exposeFunction( + name, + 'default' in pptrFunction ? pptrFunction.default : pptrFunction + ); + } + + override isDragInterceptionEnabled(): boolean { + return false; + } + + override async setCacheEnabled(enabled?: boolean): Promise<void> { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Network.setCacheDisabled', { + cacheDisabled: !enabled, + }); + } + + override isServiceWorkerBypassed(): never { + throw new UnsupportedOperation(); + } + + override target(): BiDiPageTarget { + return this.#target; + } + + override waitForFileChooser(): never { + throw new UnsupportedOperation(); + } + + override workers(): never { + throw new UnsupportedOperation(); + } + + override setRequestInterception(): never { + throw new UnsupportedOperation(); + } + + override setDragInterception(): never { + throw new UnsupportedOperation(); + } + + override setBypassServiceWorker(): never { + throw new UnsupportedOperation(); + } + + override setOfflineMode(): never { + throw new UnsupportedOperation(); + } + + override emulateNetworkConditions(): never { + throw new UnsupportedOperation(); + } + + override cookies(): never { + throw new UnsupportedOperation(); + } + + override setCookie(): never { + throw new UnsupportedOperation(); + } + + override deleteCookie(): never { + throw new UnsupportedOperation(); + } + + override removeExposedFunction(): never { + // TODO: Quick win? + throw new UnsupportedOperation(); + } + + override authenticate(): never { + throw new UnsupportedOperation(); + } + + override setExtraHTTPHeaders(): never { + throw new UnsupportedOperation(); + } + + override metrics(): never { + throw new UnsupportedOperation(); + } + + override async goBack( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#go(-1, options); + } + + override async goForward( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#go(+1, options); + } + + async #go( + delta: number, + options: WaitForOptions + ): Promise<HTTPResponse | null> { + try { + const result = await Promise.all([ + this.waitForNavigation(options), + this.#connection.send('browsingContext.traverseHistory', { + delta, + context: this.mainFrame()._id, + }), + ]); + return result[0]; + } catch (err) { + // TODO: waitForNavigation should be cancelled if an error happens. + if (isErrorLike(err)) { + if (err.message.includes('no such history entry')) { + return null; + } + } + throw err; + } + } + + override waitForDevicePrompt(): never { + throw new UnsupportedOperation(); + } +} + +function isConsoleLogEntry( + event: Bidi.Log.Entry +): event is Bidi.Log.ConsoleLogEntry { + return event.type === 'console'; +} + +function isJavaScriptLogEntry( + event: Bidi.Log.Entry +): event is Bidi.Log.JavascriptLogEntry { + return event.type === 'javascript'; +} + +function getStackTraceLocations( + stackTrace?: Bidi.Script.StackTrace +): ConsoleMessageLocation[] { + const stackTraceLocations: ConsoleMessageLocation[] = []; + if (stackTrace) { + for (const callFrame of stackTrace.callFrames) { + stackTraceLocations.push({ + url: callFrame.url, + lineNumber: callFrame.lineNumber, + columnNumber: callFrame.columnNumber, + }); + } + } + return stackTraceLocations; +} + +function evaluationExpression(fun: Function | string, ...args: unknown[]) { + return `() => {${evaluationString(fun, ...args)}}`; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts new file mode 100644 index 0000000000..84f13bc703 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts @@ -0,0 +1,228 @@ +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {scriptInjector} from '../common/ScriptInjector.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import { + PuppeteerURL, + SOURCE_URL_REGEX, + getSourcePuppeteerURLIfAvailable, + getSourceUrlComment, + isString, +} from '../common/util.js'; +import type PuppeteerUtil from '../injected/injected.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {stringifyFunction} from '../util/Function.js'; + +import type {BidiConnection} from './Connection.js'; +import {BidiDeserializer} from './Deserializer.js'; +import {BidiElementHandle} from './ElementHandle.js'; +import {BidiJSHandle} from './JSHandle.js'; +import type {Sandbox} from './Sandbox.js'; +import {BidiSerializer} from './Serializer.js'; +import {createEvaluationError} from './util.js'; + +/** + * @internal + */ +export class BidiRealm extends EventEmitter<Record<EventType, any>> { + readonly connection: BidiConnection; + + #id!: string; + #sandbox!: Sandbox; + + constructor(connection: BidiConnection) { + super(); + this.connection = connection; + } + + get target(): Bidi.Script.Target { + return { + context: this.#sandbox.environment._id, + sandbox: this.#sandbox.name, + }; + } + + handleRealmDestroyed = async ( + params: Bidi.Script.RealmDestroyed['params'] + ): Promise<void> => { + if (params.realm === this.#id) { + // Note: The Realm is destroyed, so in theory the handle should be as + // well. + this.internalPuppeteerUtil = undefined; + this.#sandbox.environment.clearDocumentHandle(); + } + }; + + handleRealmCreated = (params: Bidi.Script.RealmCreated['params']): void => { + if ( + params.type === 'window' && + params.context === this.#sandbox.environment._id && + params.sandbox === this.#sandbox.name + ) { + this.#id = params.realm; + void this.#sandbox.taskManager.rerunAll(); + } + }; + + setSandbox(sandbox: Sandbox): void { + this.#sandbox = sandbox; + this.connection.on( + Bidi.ChromiumBidi.Script.EventNames.RealmCreated, + this.handleRealmCreated + ); + this.connection.on( + Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed, + this.handleRealmDestroyed + ); + } + + protected internalPuppeteerUtil?: Promise<BidiJSHandle<PuppeteerUtil>>; + get puppeteerUtil(): Promise<BidiJSHandle<PuppeteerUtil>> { + const promise = Promise.resolve() as Promise<unknown>; + scriptInjector.inject(script => { + if (this.internalPuppeteerUtil) { + void this.internalPuppeteerUtil.then(handle => { + void handle.dispose(); + }); + } + this.internalPuppeteerUtil = promise.then(() => { + return this.evaluateHandle(script) as Promise< + BidiJSHandle<PuppeteerUtil> + >; + }); + }, !this.internalPuppeteerUtil); + return this.internalPuppeteerUtil as Promise<BidiJSHandle<PuppeteerUtil>>; + } + + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return await this.#evaluate(false, pageFunction, ...args); + } + + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return await this.#evaluate(true, pageFunction, ...args); + } + + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: true, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: false, + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: boolean, + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> { + const sourceUrlComment = getSourceUrlComment( + getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ?? + PuppeteerURL.INTERNAL_URL + ); + + const sandbox = this.#sandbox; + + let responsePromise; + const resultOwnership = returnByValue + ? Bidi.Script.ResultOwnership.None + : Bidi.Script.ResultOwnership.Root; + const serializationOptions: Bidi.Script.SerializationOptions = returnByValue + ? {} + : { + maxObjectDepth: 0, + maxDomDepth: 0, + }; + if (isString(pageFunction)) { + const expression = SOURCE_URL_REGEX.test(pageFunction) + ? pageFunction + : `${pageFunction}\n${sourceUrlComment}\n`; + + responsePromise = this.connection.send('script.evaluate', { + expression, + target: this.target, + resultOwnership, + awaitPromise: true, + userActivation: true, + serializationOptions, + }); + } else { + let functionDeclaration = stringifyFunction(pageFunction); + functionDeclaration = SOURCE_URL_REGEX.test(functionDeclaration) + ? functionDeclaration + : `${functionDeclaration}\n${sourceUrlComment}\n`; + responsePromise = this.connection.send('script.callFunction', { + functionDeclaration, + arguments: args.length + ? await Promise.all( + args.map(arg => { + return BidiSerializer.serialize(sandbox, arg); + }) + ) + : [], + target: this.target, + resultOwnership, + awaitPromise: true, + userActivation: true, + serializationOptions, + }); + } + + const {result} = await responsePromise; + + if ('type' in result && result.type === 'exception') { + throw createEvaluationError(result.exceptionDetails); + } + + return returnByValue + ? BidiDeserializer.deserialize(result.result) + : createBidiHandle(sandbox, result.result); + } + + [disposeSymbol](): void { + this.connection.off( + Bidi.ChromiumBidi.Script.EventNames.RealmCreated, + this.handleRealmCreated + ); + this.connection.off( + Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed, + this.handleRealmDestroyed + ); + } +} + +/** + * @internal + */ +export function createBidiHandle( + sandbox: Sandbox, + result: Bidi.Script.RemoteValue +): BidiJSHandle<unknown> | BidiElementHandle<Node> { + if (result.type === 'node' || result.type === 'window') { + return new BidiElementHandle(sandbox, result); + } + return new BidiJSHandle(sandbox, result); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts new file mode 100644 index 0000000000..4411b3dbcd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JSHandle} from '../api/JSHandle.js'; +import {Realm} from '../api/Realm.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import {withSourcePuppeteerURLIfNone} from '../common/util.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import {BidiElementHandle} from './ElementHandle.js'; +import type {BidiFrame} from './Frame.js'; +import type {BidiRealm as BidiRealm} from './Realm.js'; +/** + * A unique key for {@link SandboxChart} to denote the default world. + * Realms are automatically created in the default sandbox. + * + * @internal + */ +export const MAIN_SANDBOX = Symbol('mainSandbox'); +/** + * A unique key for {@link SandboxChart} to denote the puppeteer sandbox. + * This world contains all puppeteer-internal bindings/code. + * + * @internal + */ +export const PUPPETEER_SANDBOX = Symbol('puppeteerSandbox'); + +/** + * @internal + */ +export interface SandboxChart { + [key: string]: Sandbox; + [MAIN_SANDBOX]: Sandbox; + [PUPPETEER_SANDBOX]: Sandbox; +} + +/** + * @internal + */ +export class Sandbox extends Realm { + readonly name: string | undefined; + readonly realm: BidiRealm; + #frame: BidiFrame; + + constructor( + name: string | undefined, + frame: BidiFrame, + // TODO: We should split the Realm and BrowsingContext + realm: BidiRealm | BrowsingContext, + timeoutSettings: TimeoutSettings + ) { + super(timeoutSettings); + this.name = name; + this.realm = realm; + this.#frame = frame; + this.realm.setSandbox(this); + } + + override get environment(): BidiFrame { + return this.#frame; + } + + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + return await this.realm.evaluateHandle(pageFunction, ...args); + } + + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + return await this.realm.evaluate(pageFunction, ...args); + } + + async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { + return (await this.evaluateHandle(node => { + return node; + }, handle)) as unknown as T; + } + + async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { + if (handle.realm === this) { + return handle; + } + const transferredHandle = await this.evaluateHandle(node => { + return node; + }, handle); + await handle.dispose(); + return transferredHandle as unknown as T; + } + + override async adoptBackendNode( + backendNodeId?: number + ): Promise<JSHandle<Node>> { + const {object} = await this.environment.client.send('DOM.resolveNode', { + backendNodeId: backendNodeId, + }); + return new BidiElementHandle(this, { + handle: object.objectId, + type: 'node', + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts new file mode 100644 index 0000000000..c147ec9281 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {LazyArg} from '../common/LazyArg.js'; +import {isDate, isPlainObject, isRegExp} from '../common/util.js'; + +import {BidiElementHandle} from './ElementHandle.js'; +import {BidiJSHandle} from './JSHandle.js'; +import type {Sandbox} from './Sandbox.js'; + +/** + * @internal + */ +class UnserializableError extends Error {} + +/** + * @internal + */ +export class BidiSerializer { + static serializeNumber(arg: number): Bidi.Script.LocalValue { + let value: Bidi.Script.SpecialNumber | number; + if (Object.is(arg, -0)) { + value = '-0'; + } else if (Object.is(arg, Infinity)) { + value = 'Infinity'; + } else if (Object.is(arg, -Infinity)) { + value = '-Infinity'; + } else if (Object.is(arg, NaN)) { + value = 'NaN'; + } else { + value = arg; + } + return { + type: 'number', + value, + }; + } + + static serializeObject(arg: object | null): Bidi.Script.LocalValue { + if (arg === null) { + return { + type: 'null', + }; + } else if (Array.isArray(arg)) { + const parsedArray = arg.map(subArg => { + return BidiSerializer.serializeRemoteValue(subArg); + }); + + return { + type: 'array', + value: parsedArray, + }; + } else if (isPlainObject(arg)) { + try { + JSON.stringify(arg); + } catch (error) { + if ( + error instanceof TypeError && + error.message.startsWith('Converting circular structure to JSON') + ) { + error.message += ' Recursive objects are not allowed.'; + } + throw error; + } + + const parsedObject: Bidi.Script.MappingLocalValue = []; + for (const key in arg) { + parsedObject.push([ + BidiSerializer.serializeRemoteValue(key), + BidiSerializer.serializeRemoteValue(arg[key]), + ]); + } + + return { + type: 'object', + value: parsedObject, + }; + } else if (isRegExp(arg)) { + return { + type: 'regexp', + value: { + pattern: arg.source, + flags: arg.flags, + }, + }; + } else if (isDate(arg)) { + return { + type: 'date', + value: arg.toISOString(), + }; + } + + throw new UnserializableError( + 'Custom object sterilization not possible. Use plain objects instead.' + ); + } + + static serializeRemoteValue(arg: unknown): Bidi.Script.LocalValue { + switch (typeof arg) { + case 'symbol': + case 'function': + throw new UnserializableError(`Unable to serializable ${typeof arg}`); + case 'object': + return BidiSerializer.serializeObject(arg); + + case 'undefined': + return { + type: 'undefined', + }; + case 'number': + return BidiSerializer.serializeNumber(arg); + case 'bigint': + return { + type: 'bigint', + value: arg.toString(), + }; + case 'string': + return { + type: 'string', + value: arg, + }; + case 'boolean': + return { + type: 'boolean', + value: arg, + }; + } + } + + static async serialize( + sandbox: Sandbox, + arg: unknown + ): Promise<Bidi.Script.LocalValue> { + if (arg instanceof LazyArg) { + arg = await arg.get(sandbox.realm); + } + // eslint-disable-next-line rulesdir/use-using -- We want this to continue living. + const objectHandle = + arg && (arg instanceof BidiJSHandle || arg instanceof BidiElementHandle) + ? arg + : null; + if (objectHandle) { + if ( + objectHandle.realm.environment.context() !== + sandbox.environment.context() + ) { + throw new Error( + 'JSHandles can be evaluated only in the context they were created!' + ); + } + if (objectHandle.disposed) { + throw new Error('JSHandle is disposed!'); + } + return objectHandle.remoteValue() as Bidi.Script.RemoteReference; + } + + return BidiSerializer.serializeRemoteValue(arg); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts new file mode 100644 index 0000000000..fb01c34638 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Page} from '../api/Page.js'; +import {Target, TargetType} from '../api/Target.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiBrowserContext} from './BrowserContext.js'; +import {type BrowsingContext, CdpSessionWrapper} from './BrowsingContext.js'; +import {BidiPage} from './Page.js'; + +/** + * @internal + */ +export abstract class BidiTarget extends Target { + protected _browserContext: BidiBrowserContext; + + constructor(browserContext: BidiBrowserContext) { + super(); + this._browserContext = browserContext; + } + + _setBrowserContext(browserContext: BidiBrowserContext): void { + this._browserContext = browserContext; + } + + override asPage(): Promise<Page> { + throw new UnsupportedOperation(); + } + + override browser(): BidiBrowser { + return this._browserContext.browser(); + } + + override browserContext(): BidiBrowserContext { + return this._browserContext; + } + + override opener(): never { + throw new UnsupportedOperation(); + } + + override createCDPSession(): Promise<CDPSession> { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BiDiBrowserTarget extends Target { + #browser: BidiBrowser; + + constructor(browser: BidiBrowser) { + super(); + this.#browser = browser; + } + + override url(): string { + return ''; + } + + override type(): TargetType { + return TargetType.BROWSER; + } + + override asPage(): Promise<Page> { + throw new UnsupportedOperation(); + } + + override browser(): BidiBrowser { + return this.#browser; + } + + override browserContext(): BidiBrowserContext { + return this.#browser.defaultBrowserContext(); + } + + override opener(): never { + throw new UnsupportedOperation(); + } + + override createCDPSession(): Promise<CDPSession> { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BiDiBrowsingContextTarget extends BidiTarget { + protected _browsingContext: BrowsingContext; + + constructor( + browserContext: BidiBrowserContext, + browsingContext: BrowsingContext + ) { + super(browserContext); + + this._browsingContext = browsingContext; + } + + override url(): string { + return this._browsingContext.url; + } + + override async createCDPSession(): Promise<CDPSession> { + const {sessionId} = await this._browsingContext.cdpSession.send( + 'Target.attachToTarget', + { + targetId: this._browsingContext.id, + flatten: true, + } + ); + return new CdpSessionWrapper(this._browsingContext, sessionId); + } + + override type(): TargetType { + return TargetType.PAGE; + } +} + +/** + * @internal + */ +export class BiDiPageTarget extends BiDiBrowsingContextTarget { + #page: BidiPage; + + constructor( + browserContext: BidiBrowserContext, + browsingContext: BrowsingContext + ) { + super(browserContext, browsingContext); + + this.#page = new BidiPage(browsingContext, browserContext, this); + } + + override async page(): Promise<BidiPage> { + return this.#page; + } + + override _setBrowserContext(browserContext: BidiBrowserContext): void { + super._setBrowserContext(browserContext); + this.#page._setBrowserContext(browserContext); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts new file mode 100644 index 0000000000..373d6d999c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './BidiOverCdp.js'; +export * from './Browser.js'; +export * from './BrowserContext.js'; +export * from './BrowsingContext.js'; +export * from './Connection.js'; +export * from './ElementHandle.js'; +export * from './Frame.js'; +export * from './HTTPRequest.js'; +export * from './HTTPResponse.js'; +export * from './Input.js'; +export * from './JSHandle.js'; +export * from './NetworkManager.js'; +export * from './Page.js'; +export * from './Realm.js'; +export * from './Sandbox.js'; +export * from './Target.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts new file mode 100644 index 0000000000..7c4a8ed01c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {SharedWorkerRealm} from './Realm.js'; +import type {Session} from './Session.js'; +import {UserContext} from './UserContext.js'; + +/** + * @internal + */ +export type AddPreloadScriptOptions = Omit< + Bidi.Script.AddPreloadScriptParameters, + 'functionDeclaration' | 'contexts' +> & { + contexts?: [BrowsingContext, ...BrowsingContext[]]; +}; + +/** + * @internal + */ +export class Browser extends EventEmitter<{ + /** Emitted before the browser closes. */ + closed: { + /** The reason for closing the browser. */ + reason: string; + }; + /** Emitted after the browser disconnects. */ + disconnected: { + /** The reason for disconnecting the browser. */ + reason: string; + }; + /** Emitted when a shared worker is created. */ + sharedworker: { + /** The realm of the shared worker. */ + realm: SharedWorkerRealm; + }; +}> { + static async from(session: Session): Promise<Browser> { + const browser = new Browser(session); + await browser.#initialize(); + return browser; + } + + // keep-sorted start + #closed = false; + #reason: string | undefined; + readonly #disposables = new DisposableStack(); + readonly #userContexts = new Map<string, UserContext>(); + readonly session: Session; + // keep-sorted end + + private constructor(session: Session) { + super(); + // keep-sorted start + this.session = session; + // keep-sorted end + + this.#userContexts.set( + UserContext.DEFAULT, + UserContext.create(this, UserContext.DEFAULT) + ); + } + + async #initialize() { + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.session) + ); + sessionEmitter.once('ended', ({reason}) => { + this.dispose(reason); + }); + + sessionEmitter.on('script.realmCreated', info => { + if (info.type === 'shared-worker') { + // TODO: Create a SharedWorkerRealm. + } + }); + + await this.#syncBrowsingContexts(); + } + + async #syncBrowsingContexts() { + // In case contexts are created or destroyed during `getTree`, we use this + // set to detect them. + const contextIds = new Set<string>(); + let contexts: Bidi.BrowsingContext.Info[]; + + { + using sessionEmitter = new EventEmitter(this.session); + sessionEmitter.on('browsingContext.contextCreated', info => { + contextIds.add(info.context); + }); + sessionEmitter.on('browsingContext.contextDestroyed', info => { + contextIds.delete(info.context); + }); + const {result} = await this.session.send('browsingContext.getTree', {}); + contexts = result.contexts; + } + + // Simulating events so contexts are created naturally. + for (const info of contexts) { + if (contextIds.has(info.context)) { + this.session.emit('browsingContext.contextCreated', info); + } + if (info.children) { + contexts.push(...info.children); + } + } + } + + // keep-sorted start block=yes + get closed(): boolean { + return this.#closed; + } + get defaultUserContext(): UserContext { + // SAFETY: A UserContext is always created for the default context. + return this.#userContexts.get(UserContext.DEFAULT)!; + } + get disconnected(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.disconnected; + } + get userContexts(): Iterable<UserContext> { + return this.#userContexts.values(); + } + // keep-sorted end + + @inertIfDisposed + dispose(reason?: string, closed = false): void { + this.#closed = closed; + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<Browser>(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async close(): Promise<void> { + try { + await this.session.send('browser.close', {}); + } finally { + this.dispose('Browser already closed.', true); + } + } + + @throwIfDisposed<Browser>(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async addPreloadScript( + functionDeclaration: string, + options: AddPreloadScriptOptions = {} + ): Promise<string> { + const { + result: {script}, + } = await this.session.send('script.addPreloadScript', { + functionDeclaration, + ...options, + contexts: options.contexts?.map(context => { + return context.id; + }) as [string, ...string[]], + }); + return script; + } + + @throwIfDisposed<Browser>(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async removePreloadScript(script: string): Promise<void> { + await this.session.send('script.removePreloadScript', { + script, + }); + } + + static userContextId = 0; + @throwIfDisposed<Browser>(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async createUserContext(): Promise<UserContext> { + // TODO: implement incognito context https://github.com/w3c/webdriver-bidi/issues/289. + // TODO: Call `createUserContext` once available. + // Generating a monotonically increasing context id. + const context = `${++Browser.userContextId}`; + + const userContext = UserContext.create(this, context); + this.#userContexts.set(userContext.id, userContext); + + const userContextEmitter = this.#disposables.use( + new EventEmitter(userContext) + ); + userContextEmitter.once('closed', () => { + userContextEmitter.removeAllListeners(); + + this.#userContexts.delete(context); + }); + + return userContext; + } + + [disposeSymbol](): void { + this.#reason ??= + 'Browser was disconnected, probably because the session ended.'; + if (this.closed) { + this.emit('closed', {reason: this.#reason}); + } + this.emit('disconnected', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts new file mode 100644 index 0000000000..9bec2a506c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts @@ -0,0 +1,475 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {AddPreloadScriptOptions} from './Browser.js'; +import {Navigation} from './Navigation.js'; +import {WindowRealm} from './Realm.js'; +import {Request} from './Request.js'; +import type {UserContext} from './UserContext.js'; +import {UserPrompt} from './UserPrompt.js'; + +/** + * @internal + */ +export type CaptureScreenshotOptions = Omit< + Bidi.BrowsingContext.CaptureScreenshotParameters, + 'context' +>; + +/** + * @internal + */ +export type ReloadOptions = Omit< + Bidi.BrowsingContext.ReloadParameters, + 'context' +>; + +/** + * @internal + */ +export type PrintOptions = Omit< + Bidi.BrowsingContext.PrintParameters, + 'context' +>; + +/** + * @internal + */ +export type HandleUserPromptOptions = Omit< + Bidi.BrowsingContext.HandleUserPromptParameters, + 'context' +>; + +/** + * @internal + */ +export type SetViewportOptions = Omit< + Bidi.BrowsingContext.SetViewportParameters, + 'context' +>; + +/** + * @internal + */ +export class BrowsingContext extends EventEmitter<{ + /** Emitted when this context is closed. */ + closed: { + /** The reason the browsing context was closed */ + reason: string; + }; + /** Emitted when a child browsing context is created. */ + browsingcontext: { + /** The newly created child browsing context. */ + browsingContext: BrowsingContext; + }; + /** Emitted whenever a navigation occurs. */ + navigation: { + /** The navigation that occurred. */ + navigation: Navigation; + }; + /** Emitted whenever a request is made. */ + request: { + /** The request that was made. */ + request: Request; + }; + /** Emitted whenever a log entry is added. */ + log: { + /** Entry added to the log. */ + entry: Bidi.Log.Entry; + }; + /** Emitted whenever a prompt is opened. */ + userprompt: { + /** The prompt that was opened. */ + userPrompt: UserPrompt; + }; + /** Emitted whenever the frame emits `DOMContentLoaded` */ + DOMContentLoaded: void; + /** Emitted whenever the frame emits `load` */ + load: void; +}> { + static from( + userContext: UserContext, + parent: BrowsingContext | undefined, + id: string, + url: string + ): BrowsingContext { + const browsingContext = new BrowsingContext(userContext, parent, id, url); + browsingContext.#initialize(); + return browsingContext; + } + + // keep-sorted start + #navigation: Navigation | undefined; + #reason?: string; + #url: string; + readonly #children = new Map<string, BrowsingContext>(); + readonly #disposables = new DisposableStack(); + readonly #realms = new Map<string, WindowRealm>(); + readonly #requests = new Map<string, Request>(); + readonly defaultRealm: WindowRealm; + readonly id: string; + readonly parent: BrowsingContext | undefined; + readonly userContext: UserContext; + // keep-sorted end + + private constructor( + context: UserContext, + parent: BrowsingContext | undefined, + id: string, + url: string + ) { + super(); + // keep-sorted start + this.#url = url; + this.id = id; + this.parent = parent; + this.userContext = context; + // keep-sorted end + + this.defaultRealm = WindowRealm.from(this); + } + + #initialize() { + const userContextEmitter = this.#disposables.use( + new EventEmitter(this.userContext) + ); + userContextEmitter.once('closed', ({reason}) => { + this.dispose(`Browsing context already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.contextCreated', info => { + if (info.parent !== this.id) { + return; + } + + const browsingContext = BrowsingContext.from( + this.userContext, + this, + info.context, + info.url + ); + this.#children.set(info.context, browsingContext); + + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(browsingContext) + ); + browsingContextEmitter.once('closed', () => { + browsingContextEmitter.removeAllListeners(); + + this.#children.delete(browsingContext.id); + }); + + this.emit('browsingcontext', {browsingContext}); + }); + sessionEmitter.on('browsingContext.contextDestroyed', info => { + if (info.context !== this.id) { + return; + } + this.dispose('Browsing context already closed.'); + }); + + sessionEmitter.on('browsingContext.domContentLoaded', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + this.emit('DOMContentLoaded', undefined); + }); + + sessionEmitter.on('browsingContext.load', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + this.emit('load', undefined); + }); + + sessionEmitter.on('browsingContext.navigationStarted', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + + this.#requests.clear(); + + // Note the navigation ID is null for this event. + this.#navigation = Navigation.from(this); + + const navigationEmitter = this.#disposables.use( + new EventEmitter(this.#navigation) + ); + for (const eventName of ['fragment', 'failed', 'aborted'] as const) { + navigationEmitter.once(eventName, ({url}) => { + navigationEmitter[disposeSymbol](); + + this.#url = url; + }); + } + + this.emit('navigation', {navigation: this.#navigation}); + }); + sessionEmitter.on('network.beforeRequestSent', event => { + if (event.context !== this.id) { + return; + } + if (this.#requests.has(event.request.request)) { + return; + } + + const request = Request.from(this, event); + this.#requests.set(request.id, request); + this.emit('request', {request}); + }); + + sessionEmitter.on('log.entryAdded', entry => { + if (entry.source.context !== this.id) { + return; + } + + this.emit('log', {entry}); + }); + + sessionEmitter.on('browsingContext.userPromptOpened', info => { + if (info.context !== this.id) { + return; + } + + const userPrompt = UserPrompt.from(this, info); + this.emit('userprompt', {userPrompt}); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.userContext.browser.session; + } + get children(): Iterable<BrowsingContext> { + return this.#children.values(); + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get realms(): Iterable<WindowRealm> { + return this.#realms.values(); + } + get top(): BrowsingContext { + let context = this as BrowsingContext; + for (let {parent} = context; parent; {parent} = context) { + context = parent; + } + return context; + } + get url(): string { + return this.#url; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async activate(): Promise<void> { + await this.#session.send('browsingContext.activate', { + context: this.id, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async captureScreenshot( + options: CaptureScreenshotOptions = {} + ): Promise<string> { + const { + result: {data}, + } = await this.#session.send('browsingContext.captureScreenshot', { + context: this.id, + ...options, + }); + return data; + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async close(promptUnload?: boolean): Promise<void> { + await Promise.all( + [...this.#children.values()].map(async child => { + await child.close(promptUnload); + }) + ); + await this.#session.send('browsingContext.close', { + context: this.id, + promptUnload, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async traverseHistory(delta: number): Promise<void> { + await this.#session.send('browsingContext.traverseHistory', { + context: this.id, + delta, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async navigate( + url: string, + wait?: Bidi.BrowsingContext.ReadinessState + ): Promise<Navigation> { + await this.#session.send('browsingContext.navigate', { + context: this.id, + url, + wait, + }); + return await new Promise(resolve => { + this.once('navigation', ({navigation}) => { + resolve(navigation); + }); + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async reload(options: ReloadOptions = {}): Promise<Navigation> { + await this.#session.send('browsingContext.reload', { + context: this.id, + ...options, + }); + return await new Promise(resolve => { + this.once('navigation', ({navigation}) => { + resolve(navigation); + }); + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async print(options: PrintOptions = {}): Promise<string> { + const { + result: {data}, + } = await this.#session.send('browsingContext.print', { + context: this.id, + ...options, + }); + return data; + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise<void> { + await this.#session.send('browsingContext.handleUserPrompt', { + context: this.id, + ...options, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async setViewport(options: SetViewportOptions = {}): Promise<void> { + await this.#session.send('browsingContext.setViewport', { + context: this.id, + ...options, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async performActions(actions: Bidi.Input.SourceActions[]): Promise<void> { + await this.#session.send('input.performActions', { + context: this.id, + actions, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async releaseActions(): Promise<void> { + await this.#session.send('input.releaseActions', { + context: this.id, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + createWindowRealm(sandbox: string): WindowRealm { + return WindowRealm.from(this, sandbox); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async addPreloadScript( + functionDeclaration: string, + options: AddPreloadScriptOptions = {} + ): Promise<string> { + return await this.userContext.browser.addPreloadScript( + functionDeclaration, + { + ...options, + contexts: [this, ...(options.contexts ?? [])], + } + ); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async removePreloadScript(script: string): Promise<void> { + await this.userContext.browser.removePreloadScript(script); + } + + [disposeSymbol](): void { + this.#reason ??= + 'Browsing context already closed, probably because the user context closed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts new file mode 100644 index 0000000000..b9de14372b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {EventEmitter} from '../../common/EventEmitter.js'; + +/** + * @internal + */ +export interface Commands { + 'script.evaluate': { + params: Bidi.Script.EvaluateParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.callFunction': { + params: Bidi.Script.CallFunctionParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.disown': { + params: Bidi.Script.DisownParameters; + returnType: Bidi.EmptyResult; + }; + 'script.addPreloadScript': { + params: Bidi.Script.AddPreloadScriptParameters; + returnType: Bidi.Script.AddPreloadScriptResult; + }; + 'script.removePreloadScript': { + params: Bidi.Script.RemovePreloadScriptParameters; + returnType: Bidi.EmptyResult; + }; + + 'browser.close': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + + 'browsingContext.activate': { + params: Bidi.BrowsingContext.ActivateParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.create': { + params: Bidi.BrowsingContext.CreateParameters; + returnType: Bidi.BrowsingContext.CreateResult; + }; + 'browsingContext.close': { + params: Bidi.BrowsingContext.CloseParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.getTree': { + params: Bidi.BrowsingContext.GetTreeParameters; + returnType: Bidi.BrowsingContext.GetTreeResult; + }; + 'browsingContext.navigate': { + params: Bidi.BrowsingContext.NavigateParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.reload': { + params: Bidi.BrowsingContext.ReloadParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.print': { + params: Bidi.BrowsingContext.PrintParameters; + returnType: Bidi.BrowsingContext.PrintResult; + }; + 'browsingContext.captureScreenshot': { + params: Bidi.BrowsingContext.CaptureScreenshotParameters; + returnType: Bidi.BrowsingContext.CaptureScreenshotResult; + }; + 'browsingContext.handleUserPrompt': { + params: Bidi.BrowsingContext.HandleUserPromptParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.setViewport': { + params: Bidi.BrowsingContext.SetViewportParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.traverseHistory': { + params: Bidi.BrowsingContext.TraverseHistoryParameters; + returnType: Bidi.EmptyResult; + }; + + 'input.performActions': { + params: Bidi.Input.PerformActionsParameters; + returnType: Bidi.EmptyResult; + }; + 'input.releaseActions': { + params: Bidi.Input.ReleaseActionsParameters; + returnType: Bidi.EmptyResult; + }; + + 'session.end': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + 'session.new': { + params: Bidi.Session.NewParameters; + returnType: Bidi.Session.NewResult; + }; + 'session.status': { + params: object; + returnType: Bidi.Session.StatusResult; + }; + 'session.subscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; + 'session.unsubscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; +} + +/** + * @internal + */ +export type BidiEvents = { + [K in Bidi.ChromiumBidi.Event['method']]: Extract< + Bidi.ChromiumBidi.Event, + {method: K} + >['params']; +}; + +/** + * @internal + */ +export interface Connection<Events extends BidiEvents = BidiEvents> + extends EventEmitter<Events> { + send<T extends keyof Commands>( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}>; + + // This will pipe events into the provided emitter. + pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts new file mode 100644 index 0000000000..a7efbfeb2c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed} from '../../util/decorators.js'; +import {Deferred} from '../../util/Deferred.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {Request} from './Request.js'; + +/** + * @internal + */ +export interface NavigationInfo { + url: string; + timestamp: Date; +} + +/** + * @internal + */ +export class Navigation extends EventEmitter<{ + /** Emitted when navigation has a request associated with it. */ + request: Request; + /** Emitted when fragment navigation occurred. */ + fragment: NavigationInfo; + /** Emitted when navigation failed. */ + failed: NavigationInfo; + /** Emitted when navigation was aborted. */ + aborted: NavigationInfo; +}> { + static from(context: BrowsingContext): Navigation { + const navigation = new Navigation(context); + navigation.#initialize(); + return navigation; + } + + // keep-sorted start + #request: Request | undefined; + readonly #browsingContext: BrowsingContext; + readonly #disposables = new DisposableStack(); + readonly #id = new Deferred<string>(); + // keep-sorted end + + private constructor(context: BrowsingContext) { + super(); + // keep-sorted start + this.#browsingContext = context; + // keep-sorted end + } + + #initialize() { + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(this.#browsingContext) + ); + browsingContextEmitter.once('closed', () => { + this.emit('failed', { + url: this.#browsingContext.url, + timestamp: new Date(), + }); + this.dispose(); + }); + + this.#browsingContext.on('request', ({request}) => { + if (request.navigation === this.#id.value()) { + this.#request = request; + this.emit('request', request); + } + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + // To get the navigation ID if any. + for (const eventName of [ + 'browsingContext.domContentLoaded', + 'browsingContext.load', + ] as const) { + sessionEmitter.on(eventName, info => { + if (info.context !== this.#browsingContext.id) { + return; + } + if (!info.navigation) { + return; + } + if (!this.#id.resolved()) { + this.#id.resolve(info.navigation); + } + }); + } + + for (const [eventName, event] of [ + ['browsingContext.fragmentNavigated', 'fragment'], + ['browsingContext.navigationFailed', 'failed'], + ['browsingContext.navigationAborted', 'aborted'], + ] as const) { + sessionEmitter.on(eventName, info => { + if (info.context !== this.#browsingContext.id) { + return; + } + if (!info.navigation) { + return; + } + if (!this.#id.resolved()) { + this.#id.resolve(info.navigation); + } + if (this.#id.value() !== info.navigation) { + return; + } + this.emit(event, { + url: info.url, + timestamp: new Date(info.timestamp), + }); + this.dispose(); + }); + } + } + + // keep-sorted start block=yes + get #session() { + return this.#browsingContext.userContext.browser.session; + } + get disposed(): boolean { + return this.#disposables.disposed; + } + get request(): Request | undefined { + return this.#request; + } + // keep-sorted end + + @inertIfDisposed + private dispose(): void { + this[disposeSymbol](); + } + + [disposeSymbol](): void { + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts new file mode 100644 index 0000000000..d9bbbede50 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {Session} from './Session.js'; + +/** + * @internal + */ +export type CallFunctionOptions = Omit< + Bidi.Script.CallFunctionParameters, + 'functionDeclaration' | 'awaitPromise' | 'target' +>; + +/** + * @internal + */ +export type EvaluateOptions = Omit< + Bidi.Script.EvaluateParameters, + 'expression' | 'awaitPromise' | 'target' +>; + +/** + * @internal + */ +export abstract class Realm extends EventEmitter<{ + /** Emitted when the realm is destroyed. */ + destroyed: {reason: string}; + /** Emitted when a dedicated worker is created in the realm. */ + worker: DedicatedWorkerRealm; + /** Emitted when a shared worker is created in the realm. */ + sharedworker: SharedWorkerRealm; +}> { + // keep-sorted start + #reason?: string; + protected readonly disposables = new DisposableStack(); + readonly id: string; + readonly origin: string; + // keep-sorted end + + protected constructor(id: string, origin: string) { + super(); + // keep-sorted start + this.id = id; + this.origin = origin; + // keep-sorted end + } + + protected initialize(): void { + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmDestroyed', info => { + if (info.realm !== this.id) { + return; + } + this.dispose('Realm already destroyed.'); + }); + } + + // keep-sorted start block=yes + get disposed(): boolean { + return this.#reason !== undefined; + } + protected abstract get session(): Session; + protected get target(): Bidi.Script.Target { + return {realm: this.id}; + } + // keep-sorted end + + @inertIfDisposed + protected dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<Realm>(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async disown(handles: string[]): Promise<void> { + await this.session.send('script.disown', { + target: this.target, + handles, + }); + } + + @throwIfDisposed<Realm>(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async callFunction( + functionDeclaration: string, + awaitPromise: boolean, + options: CallFunctionOptions = {} + ): Promise<Bidi.Script.EvaluateResult> { + const {result} = await this.session.send('script.callFunction', { + functionDeclaration, + awaitPromise, + target: this.target, + ...options, + }); + return result; + } + + @throwIfDisposed<Realm>(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async evaluate( + expression: string, + awaitPromise: boolean, + options: EvaluateOptions = {} + ): Promise<Bidi.Script.EvaluateResult> { + const {result} = await this.session.send('script.evaluate', { + expression, + awaitPromise, + target: this.target, + ...options, + }); + return result; + } + + [disposeSymbol](): void { + this.#reason ??= + 'Realm already destroyed, probably because all associated browsing contexts closed.'; + this.emit('destroyed', {reason: this.#reason}); + + this.disposables.dispose(); + super[disposeSymbol](); + } +} + +/** + * @internal + */ +export class WindowRealm extends Realm { + static from(context: BrowsingContext, sandbox?: string): WindowRealm { + const realm = new WindowRealm(context, sandbox); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly browsingContext: BrowsingContext; + readonly sandbox?: string; + // keep-sorted end + + readonly #workers: { + dedicated: Map<string, DedicatedWorkerRealm>; + shared: Map<string, SharedWorkerRealm>; + } = { + dedicated: new Map(), + shared: new Map(), + }; + + private constructor(context: BrowsingContext, sandbox?: string) { + super('', ''); + // keep-sorted start + this.browsingContext = context; + this.sandbox = sandbox; + // keep-sorted end + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'window') { + return; + } + (this as any).id = info.realm; + (this as any).origin = info.origin; + }); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.dedicated.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + realmEmitter.removeAllListeners(); + this.#workers.dedicated.delete(realm.id); + }); + + this.emit('worker', realm); + }); + + this.browsingContext.userContext.browser.on('sharedworker', ({realm}) => { + if (!realm.owners.has(this)) { + return; + } + + this.#workers.shared.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + realmEmitter.removeAllListeners(); + this.#workers.shared.delete(realm.id); + }); + + this.emit('sharedworker', realm); + }); + } + + override get session(): Session { + return this.browsingContext.userContext.browser.session; + } + + override get target(): Bidi.Script.Target { + return {context: this.browsingContext.id, sandbox: this.sandbox}; + } +} + +/** + * @internal + */ +export type DedicatedWorkerOwnerRealm = + | DedicatedWorkerRealm + | SharedWorkerRealm + | WindowRealm; + +/** + * @internal + */ +export class DedicatedWorkerRealm extends Realm { + static from( + owner: DedicatedWorkerOwnerRealm, + id: string, + origin: string + ): DedicatedWorkerRealm { + const realm = new DedicatedWorkerRealm(owner, id, origin); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly #workers = new Map<string, DedicatedWorkerRealm>(); + readonly owners: Set<DedicatedWorkerOwnerRealm>; + // keep-sorted end + + private constructor( + owner: DedicatedWorkerOwnerRealm, + id: string, + origin: string + ) { + super(id, origin); + this.owners = new Set([owner]); + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + this.#workers.delete(realm.id); + }); + + this.emit('worker', realm); + }); + } + + override get session(): Session { + // SAFETY: At least one owner will exist. + return this.owners.values().next().value.session; + } +} + +/** + * @internal + */ +export class SharedWorkerRealm extends Realm { + static from( + owners: [WindowRealm, ...WindowRealm[]], + id: string, + origin: string + ): SharedWorkerRealm { + const realm = new SharedWorkerRealm(owners, id, origin); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly #workers = new Map<string, DedicatedWorkerRealm>(); + readonly owners: Set<WindowRealm>; + // keep-sorted end + + private constructor( + owners: [WindowRealm, ...WindowRealm[]], + id: string, + origin: string + ) { + super(id, origin); + this.owners = new Set(owners); + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + this.#workers.delete(realm.id); + }); + + this.emit('worker', realm); + }); + } + + override get session(): Session { + // SAFETY: At least one owner will exist. + return this.owners.values().next().value.session; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts new file mode 100644 index 0000000000..2a445f7d87 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export class Request extends EventEmitter<{ + /** Emitted when the request is redirected. */ + redirect: Request; + /** Emitted when the request succeeds. */ + success: Bidi.Network.ResponseData; + /** Emitted when the request fails. */ + error: string; +}> { + static from( + browsingContext: BrowsingContext, + event: Bidi.Network.BeforeRequestSentParameters + ): Request { + const request = new Request(browsingContext, event); + request.#initialize(); + return request; + } + + // keep-sorted start + #error?: string; + #redirect?: Request; + #response?: Bidi.Network.ResponseData; + readonly #browsingContext: BrowsingContext; + readonly #disposables = new DisposableStack(); + readonly #event: Bidi.Network.BeforeRequestSentParameters; + // keep-sorted end + + private constructor( + browsingContext: BrowsingContext, + event: Bidi.Network.BeforeRequestSentParameters + ) { + super(); + // keep-sorted start + this.#browsingContext = browsingContext; + this.#event = event; + // keep-sorted end + } + + #initialize() { + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(this.#browsingContext) + ); + browsingContextEmitter.once('closed', ({reason}) => { + this.#error = reason; + this.emit('error', this.#error); + this.dispose(); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('network.beforeRequestSent', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#redirect = Request.from(this.#browsingContext, event); + this.emit('redirect', this.#redirect); + this.dispose(); + }); + sessionEmitter.on('network.fetchError', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#error = event.errorText; + this.emit('error', this.#error); + this.dispose(); + }); + sessionEmitter.on('network.responseCompleted', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#response = event.response; + this.emit('success', this.#response); + this.dispose(); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.#browsingContext.userContext.browser.session; + } + get disposed(): boolean { + return this.#disposables.disposed; + } + get error(): string | undefined { + return this.#error; + } + get headers(): Bidi.Network.Header[] { + return this.#event.request.headers; + } + get id(): string { + return this.#event.request.request; + } + get initiator(): Bidi.Network.Initiator { + return this.#event.initiator; + } + get method(): string { + return this.#event.request.method; + } + get navigation(): string | undefined { + return this.#event.navigation ?? undefined; + } + get redirect(): Request | undefined { + return this.redirect; + } + get response(): Bidi.Network.ResponseData | undefined { + return this.#response; + } + get url(): string { + return this.#event.request.url; + } + // keep-sorted end + + @inertIfDisposed + private dispose(): void { + this[disposeSymbol](); + } + + [disposeSymbol](): void { + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts new file mode 100644 index 0000000000..b6e28061f1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {debugError} from '../../common/util.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import {Browser} from './Browser.js'; +import type {BidiEvents, Commands, Connection} from './Connection.js'; + +// TODO: Once Chrome supports session.status properly, uncomment this block. +// const MAX_RETRIES = 5; + +/** + * @internal + */ +export class Session + extends EventEmitter<BidiEvents & {ended: {reason: string}}> + implements Connection<BidiEvents & {ended: {reason: string}}> +{ + static async from( + connection: Connection, + capabilities: Bidi.Session.CapabilitiesRequest + ): Promise<Session> { + // Wait until the session is ready. + // + // TODO: Once Chrome supports session.status properly, uncomment this block + // and remove `getBiDiConnection` in BrowserConnector. + + // let status = {message: '', ready: false}; + // for (let i = 0; i < MAX_RETRIES; ++i) { + // status = (await connection.send('session.status', {})).result; + // if (status.ready) { + // break; + // } + // // Backoff a little bit each time. + // await new Promise(resolve => { + // return setTimeout(resolve, (1 << i) * 100); + // }); + // } + // if (!status.ready) { + // throw new Error(status.message); + // } + + let result; + try { + result = ( + await connection.send('session.new', { + capabilities, + }) + ).result; + } catch (err) { + // Chrome does not support session.new. + debugError(err); + result = { + sessionId: '', + capabilities: { + acceptInsecureCerts: false, + browserName: '', + browserVersion: '', + platformName: '', + setWindowRect: false, + webSocketUrl: '', + }, + }; + } + + const session = new Session(connection, result); + await session.#initialize(); + return session; + } + + // keep-sorted start + #reason: string | undefined; + readonly #disposables = new DisposableStack(); + readonly #info: Bidi.Session.NewResult; + readonly browser!: Browser; + readonly connection: Connection; + // keep-sorted end + + private constructor(connection: Connection, info: Bidi.Session.NewResult) { + super(); + // keep-sorted start + this.#info = info; + this.connection = connection; + // keep-sorted end + } + + async #initialize(): Promise<void> { + this.connection.pipeTo(this); + + // SAFETY: We use `any` to allow assignment of the readonly property. + (this as any).browser = await Browser.from(this); + + const browserEmitter = this.#disposables.use(this.browser); + browserEmitter.once('closed', ({reason}) => { + this.dispose(reason); + }); + } + + // keep-sorted start block=yes + get capabilities(): Bidi.Session.NewResult['capabilities'] { + return this.#info.capabilities; + } + get disposed(): boolean { + return this.ended; + } + get ended(): boolean { + return this.#reason !== undefined; + } + get id(): string { + return this.#info.sessionId; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void { + this.connection.pipeTo(emitter); + } + + /** + * Currently, there is a 1:1 relationship between the session and the + * session. In the future, we might support multiple sessions and in that + * case we always needs to make sure that the session for the right session + * object is used, so we implement this method here, although it's not defined + * in the spec. + */ + @throwIfDisposed<Session>(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async send<T extends keyof Commands>( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}> { + return await this.connection.send(method, params); + } + + @throwIfDisposed<Session>(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async subscribe(events: string[]): Promise<void> { + await this.send('session.subscribe', { + events, + }); + } + + @throwIfDisposed<Session>(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async end(): Promise<void> { + try { + await this.send('session.end', {}); + } finally { + this.dispose(`Session already ended.`); + } + } + + [disposeSymbol](): void { + this.#reason ??= + 'Session already destroyed, probably because the connection broke.'; + this.emit('ended', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts new file mode 100644 index 0000000000..01ee5c7649 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {assert} from '../../util/assert.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {Browser} from './Browser.js'; +import {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export type CreateBrowsingContextOptions = Omit< + Bidi.BrowsingContext.CreateParameters, + 'type' | 'referenceContext' +> & { + referenceContext?: BrowsingContext; +}; + +/** + * @internal + */ +export class UserContext extends EventEmitter<{ + /** + * Emitted when a new browsing context is created. + */ + browsingcontext: { + /** The new browsing context. */ + browsingContext: BrowsingContext; + }; + /** + * Emitted when the user context is closed. + */ + closed: { + /** The reason the user context was closed. */ + reason: string; + }; +}> { + static DEFAULT = 'default'; + + static create(browser: Browser, id: string): UserContext { + const context = new UserContext(browser, id); + context.#initialize(); + return context; + } + + // keep-sorted start + #reason?: string; + // Note these are only top-level contexts. + readonly #browsingContexts = new Map<string, BrowsingContext>(); + readonly #disposables = new DisposableStack(); + readonly #id: string; + readonly browser: Browser; + // keep-sorted end + + private constructor(browser: Browser, id: string) { + super(); + // keep-sorted start + this.#id = id; + this.browser = browser; + // keep-sorted end + } + + #initialize() { + const browserEmitter = this.#disposables.use( + new EventEmitter(this.browser) + ); + browserEmitter.once('closed', ({reason}) => { + this.dispose(`User context already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.contextCreated', info => { + if (info.parent) { + return; + } + + const browsingContext = BrowsingContext.from( + this, + undefined, + info.context, + info.url + ); + this.#browsingContexts.set(browsingContext.id, browsingContext); + + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(browsingContext) + ); + browsingContextEmitter.on('closed', () => { + browsingContextEmitter.removeAllListeners(); + + this.#browsingContexts.delete(browsingContext.id); + }); + + this.emit('browsingcontext', {browsingContext}); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.browser.session; + } + get browsingContexts(): Iterable<BrowsingContext> { + return this.#browsingContexts.values(); + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get id(): string { + return this.#id; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<UserContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async createBrowsingContext( + type: Bidi.BrowsingContext.CreateType, + options: CreateBrowsingContextOptions = {} + ): Promise<BrowsingContext> { + const { + result: {context: contextId}, + } = await this.#session.send('browsingContext.create', { + type, + ...options, + referenceContext: options.referenceContext?.id, + }); + + const browsingContext = this.#browsingContexts.get(contextId); + assert( + browsingContext, + 'The WebDriver BiDi implementation is failing to create a browsing context correctly.' + ); + + // We use an array to avoid the promise from being awaited. + return browsingContext; + } + + @throwIfDisposed<UserContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async remove(): Promise<void> { + try { + // TODO: Call `removeUserContext` once available. + } finally { + this.dispose('User context already closed.'); + } + } + + [disposeSymbol](): void { + this.#reason ??= + 'User context already closed, probably because the browser disconnected/closed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts new file mode 100644 index 0000000000..073233bed0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export type HandleOptions = Omit< + Bidi.BrowsingContext.HandleUserPromptParameters, + 'context' +>; + +/** + * @internal + */ +export type UserPromptResult = Omit< + Bidi.BrowsingContext.UserPromptClosedParameters, + 'context' +>; + +/** + * @internal + */ +export class UserPrompt extends EventEmitter<{ + /** Emitted when the user prompt is handled. */ + handled: UserPromptResult; + /** Emitted when the user prompt is closed. */ + closed: { + /** The reason the user prompt was closed. */ + reason: string; + }; +}> { + static from( + browsingContext: BrowsingContext, + info: Bidi.BrowsingContext.UserPromptOpenedParameters + ): UserPrompt { + const userPrompt = new UserPrompt(browsingContext, info); + userPrompt.#initialize(); + return userPrompt; + } + + // keep-sorted start + #reason?: string; + #result?: UserPromptResult; + readonly #disposables = new DisposableStack(); + readonly browsingContext: BrowsingContext; + readonly info: Bidi.BrowsingContext.UserPromptOpenedParameters; + // keep-sorted end + + private constructor( + context: BrowsingContext, + info: Bidi.BrowsingContext.UserPromptOpenedParameters + ) { + super(); + // keep-sorted start + this.browsingContext = context; + this.info = info; + // keep-sorted end + } + + #initialize() { + const browserContextEmitter = this.#disposables.use( + new EventEmitter(this.browsingContext) + ); + browserContextEmitter.once('closed', ({reason}) => { + this.dispose(`User prompt already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.userPromptClosed', parameters => { + if (parameters.context !== this.browsingContext.id) { + return; + } + this.#result = parameters; + this.emit('handled', parameters); + this.dispose('User prompt already handled.'); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.browsingContext.userContext.browser.session; + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get handled(): boolean { + return this.#result !== undefined; + } + get result(): UserPromptResult | undefined { + return this.#result; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<UserPrompt>(prompt => { + // SAFETY: Disposal implies this exists. + return prompt.#reason!; + }) + async handle(options: HandleOptions = {}): Promise<UserPromptResult> { + await this.#session.send('browsingContext.handleUserPrompt', { + ...options, + context: this.info.context, + }); + // SAFETY: `handled` is triggered before the above promise resolved. + return this.#result!; + } + + [disposeSymbol](): void { + this.#reason ??= + 'User prompt already closed, probably because the associated browsing context was destroyed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts new file mode 100644 index 0000000000..203281614b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './Browser.js'; +export * from './BrowsingContext.js'; +export * from './Connection.js'; +export * from './Navigation.js'; +export * from './Realm.js'; +export * from './Request.js'; +export * from './Session.js'; +export * from './UserContext.js'; +export * from './UserPrompt.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts new file mode 100644 index 0000000000..73b86cba9c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type { + ObservableInput, + ObservedValueOf, + OperatorFunction, +} from '../../third_party/rxjs/rxjs.js'; +import {catchError} from '../../third_party/rxjs/rxjs.js'; +import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js'; +import {ProtocolError, TimeoutError} from '../common/Errors.js'; + +/** + * @internal + */ +export type BiDiNetworkIdle = Extract< + PuppeteerLifeCycleEvent, + 'networkidle0' | 'networkidle2' +> | null; + +/** + * @internal + */ +export function getBiDiLifeCycles( + event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] +): [ + Extract<PuppeteerLifeCycleEvent, 'load' | 'domcontentloaded'>, + BiDiNetworkIdle, +] { + if (Array.isArray(event)) { + const pageLifeCycle = event.some(lifeCycle => { + return lifeCycle !== 'domcontentloaded'; + }) + ? 'load' + : 'domcontentloaded'; + + const networkLifeCycle = event.reduce((acc, lifeCycle) => { + if (lifeCycle === 'networkidle0') { + return lifeCycle; + } else if (acc !== 'networkidle0' && lifeCycle === 'networkidle2') { + return lifeCycle; + } + return acc; + }, null as BiDiNetworkIdle); + + return [pageLifeCycle, networkLifeCycle]; + } + + if (event === 'networkidle0' || event === 'networkidle2') { + return ['load', event]; + } + + return [event, null]; +} + +/** + * @internal + */ +export const lifeCycleToReadinessState = new Map< + PuppeteerLifeCycleEvent, + Bidi.BrowsingContext.ReadinessState +>([ + ['load', Bidi.BrowsingContext.ReadinessState.Complete], + ['domcontentloaded', Bidi.BrowsingContext.ReadinessState.Interactive], +]); + +export function getBiDiReadinessState( + event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] +): [Bidi.BrowsingContext.ReadinessState, BiDiNetworkIdle] { + const lifeCycles = getBiDiLifeCycles(event); + const readiness = lifeCycleToReadinessState.get(lifeCycles[0])!; + return [readiness, lifeCycles[1]]; +} + +/** + * @internal + */ +export const lifeCycleToSubscribedEvent = new Map< + PuppeteerLifeCycleEvent, + 'browsingContext.load' | 'browsingContext.domContentLoaded' +>([ + ['load', 'browsingContext.load'], + ['domcontentloaded', 'browsingContext.domContentLoaded'], +]); + +/** + * @internal + */ +export function getBiDiLifecycleEvent( + event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] +): [ + 'browsingContext.load' | 'browsingContext.domContentLoaded', + BiDiNetworkIdle, +] { + const lifeCycles = getBiDiLifeCycles(event); + const bidiEvent = lifeCycleToSubscribedEvent.get(lifeCycles[0])!; + return [bidiEvent, lifeCycles[1]]; +} + +/** + * @internal + */ +export function rewriteNavigationError<T, R extends ObservableInput<T>>( + message: string, + ms: number +): OperatorFunction<T, T | ObservedValueOf<R>> { + return catchError<T, R>(error => { + if (error instanceof ProtocolError) { + error.message += ` at ${message}`; + } else if (error instanceof TimeoutError) { + error.message = `Navigation timeout of ${ms} ms exceeded`; + } + throw error; + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts new file mode 100644 index 0000000000..41e88e26c2 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {PuppeteerURL, debugError} from '../common/util.js'; + +import {BidiDeserializer} from './Deserializer.js'; +import type {BidiRealm} from './Realm.js'; + +/** + * @internal + */ +export async function releaseReference( + client: BidiRealm, + remoteReference: Bidi.Script.RemoteReference +): Promise<void> { + if (!remoteReference.handle) { + return; + } + await client.connection + .send('script.disown', { + target: client.target, + handles: [remoteReference.handle], + }) + .catch(error => { + // Exceptions might happen in case of a page been navigated or closed. + // Swallow these since they are harmless and we don't leak anything in this case. + debugError(error); + }); +} + +/** + * @internal + */ +export function createEvaluationError( + details: Bidi.Script.ExceptionDetails +): unknown { + if (details.exception.type !== 'error') { + return BidiDeserializer.deserialize(details.exception); + } + const [name = '', ...parts] = details.text.split(': '); + const message = parts.join(': '); + const error = new Error(message); + error.name = name; + + // The first line is this function which we ignore. + const stackLines = []; + if (details.stackTrace && stackLines.length < Error.stackTraceLimit) { + for (const frame of details.stackTrace.callFrames.reverse()) { + if ( + PuppeteerURL.isPuppeteerURL(frame.url) && + frame.url !== PuppeteerURL.INTERNAL_URL + ) { + const url = PuppeteerURL.parse(frame.url); + stackLines.unshift( + ` at ${frame.functionName || url.functionName} (${ + url.functionName + } at ${url.siteString}, <anonymous>:${frame.lineNumber}:${ + frame.columnNumber + })` + ); + } else { + stackLines.push( + ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ + frame.lineNumber + }:${frame.columnNumber})` + ); + } + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [details.text, ...stackLines].join('\n'); + return error; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts new file mode 100644 index 0000000000..d0279e3dda --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts @@ -0,0 +1,579 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; + +/** + * Represents a Node and the properties of it that are relevant to Accessibility. + * @public + */ +export interface SerializedAXNode { + /** + * The {@link https://www.w3.org/TR/wai-aria/#usage_intro | role} of the node. + */ + role: string; + /** + * A human readable name for the node. + */ + name?: string; + /** + * The current value of the node. + */ + value?: string | number; + /** + * An additional human readable description of the node. + */ + description?: string; + /** + * Any keyboard shortcuts associated with this node. + */ + keyshortcuts?: string; + /** + * A human readable alternative to the role. + */ + roledescription?: string; + /** + * A description of the current value. + */ + valuetext?: string; + disabled?: boolean; + expanded?: boolean; + focused?: boolean; + modal?: boolean; + multiline?: boolean; + /** + * Whether more than one child can be selected. + */ + multiselectable?: boolean; + readonly?: boolean; + required?: boolean; + selected?: boolean; + /** + * Whether the checkbox is checked, or in a + * {@link https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html | mixed state}. + */ + checked?: boolean | 'mixed'; + /** + * Whether the node is checked or in a mixed state. + */ + pressed?: boolean | 'mixed'; + /** + * The level of a heading. + */ + level?: number; + valuemin?: number; + valuemax?: number; + autocomplete?: string; + haspopup?: string; + /** + * Whether and in what way this node's value is invalid. + */ + invalid?: string; + orientation?: string; + /** + * Children of this node, if there are any. + */ + children?: SerializedAXNode[]; +} + +/** + * @public + */ +export interface SnapshotOptions { + /** + * Prune uninteresting nodes from the tree. + * @defaultValue `true` + */ + interestingOnly?: boolean; + /** + * Root node to get the accessibility tree for + * @defaultValue The root node of the entire page. + */ + root?: ElementHandle<Node>; +} + +/** + * The Accessibility class provides methods for inspecting the browser's + * accessibility tree. The accessibility tree is used by assistive technology + * such as {@link https://en.wikipedia.org/wiki/Screen_reader | screen readers} or + * {@link https://en.wikipedia.org/wiki/Switch_access | switches}. + * + * @remarks + * + * Accessibility is a very platform-specific thing. On different platforms, + * there are different screen readers that might have wildly different output. + * + * Blink - Chrome's rendering engine - has a concept of "accessibility tree", + * which is then translated into different platform-specific APIs. Accessibility + * namespace gives users access to the Blink Accessibility Tree. + * + * Most of the accessibility tree gets filtered out when converting from Blink + * AX Tree to Platform-specific AX-Tree or by assistive technologies themselves. + * By default, Puppeteer tries to approximate this filtering, exposing only + * the "interesting" nodes of the tree. + * + * @public + */ +export class Accessibility { + #client: CDPSession; + + /** + * @internal + */ + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + } + + /** + * Captures the current state of the accessibility tree. + * The returned object represents the root accessible node of the page. + * + * @remarks + * + * **NOTE** The Chrome accessibility tree contains nodes that go unused on + * most platforms and by most screen readers. Puppeteer will discard them as + * well for an easier to process tree, unless `interestingOnly` is set to + * `false`. + * + * @example + * An example of dumping the entire accessibility tree: + * + * ```ts + * const snapshot = await page.accessibility.snapshot(); + * console.log(snapshot); + * ``` + * + * @example + * An example of logging the focused node's name: + * + * ```ts + * const snapshot = await page.accessibility.snapshot(); + * const node = findFocusedNode(snapshot); + * console.log(node && node.name); + * + * function findFocusedNode(node) { + * if (node.focused) return node; + * for (const child of node.children || []) { + * const foundNode = findFocusedNode(child); + * return foundNode; + * } + * return null; + * } + * ``` + * + * @returns An AXNode object representing the snapshot. + */ + public async snapshot( + options: SnapshotOptions = {} + ): Promise<SerializedAXNode | null> { + const {interestingOnly = true, root = null} = options; + const {nodes} = await this.#client.send('Accessibility.getFullAXTree'); + let backendNodeId: number | undefined; + if (root) { + const {node} = await this.#client.send('DOM.describeNode', { + objectId: root.id, + }); + backendNodeId = node.backendNodeId; + } + const defaultRoot = AXNode.createTree(nodes); + let needle: AXNode | null = defaultRoot; + if (backendNodeId) { + needle = defaultRoot.find(node => { + return node.payload.backendDOMNodeId === backendNodeId; + }); + if (!needle) { + return null; + } + } + if (!interestingOnly) { + return this.serializeTree(needle)[0] ?? null; + } + + const interestingNodes = new Set<AXNode>(); + this.collectInterestingNodes(interestingNodes, defaultRoot, false); + if (!interestingNodes.has(needle)) { + return null; + } + return this.serializeTree(needle, interestingNodes)[0] ?? null; + } + + private serializeTree( + node: AXNode, + interestingNodes?: Set<AXNode> + ): SerializedAXNode[] { + const children: SerializedAXNode[] = []; + for (const child of node.children) { + children.push(...this.serializeTree(child, interestingNodes)); + } + + if (interestingNodes && !interestingNodes.has(node)) { + return children; + } + + const serializedNode = node.serialize(); + if (children.length) { + serializedNode.children = children; + } + return [serializedNode]; + } + + private collectInterestingNodes( + collection: Set<AXNode>, + node: AXNode, + insideControl: boolean + ): void { + if (node.isInteresting(insideControl)) { + collection.add(node); + } + if (node.isLeafNode()) { + return; + } + insideControl = insideControl || node.isControl(); + for (const child of node.children) { + this.collectInterestingNodes(collection, child, insideControl); + } + } +} + +class AXNode { + public payload: Protocol.Accessibility.AXNode; + public children: AXNode[] = []; + + #richlyEditable = false; + #editable = false; + #focusable = false; + #hidden = false; + #name: string; + #role: string; + #ignored: boolean; + #cachedHasFocusableChild?: boolean; + + constructor(payload: Protocol.Accessibility.AXNode) { + this.payload = payload; + this.#name = this.payload.name ? this.payload.name.value : ''; + this.#role = this.payload.role ? this.payload.role.value : 'Unknown'; + this.#ignored = this.payload.ignored; + + for (const property of this.payload.properties || []) { + if (property.name === 'editable') { + this.#richlyEditable = property.value.value === 'richtext'; + this.#editable = true; + } + if (property.name === 'focusable') { + this.#focusable = property.value.value; + } + if (property.name === 'hidden') { + this.#hidden = property.value.value; + } + } + } + + #isPlainTextField(): boolean { + if (this.#richlyEditable) { + return false; + } + if (this.#editable) { + return true; + } + return this.#role === 'textbox' || this.#role === 'searchbox'; + } + + #isTextOnlyObject(): boolean { + const role = this.#role; + return ( + role === 'LineBreak' || + role === 'text' || + role === 'InlineTextBox' || + role === 'StaticText' + ); + } + + #hasFocusableChild(): boolean { + if (this.#cachedHasFocusableChild === undefined) { + this.#cachedHasFocusableChild = false; + for (const child of this.children) { + if (child.#focusable || child.#hasFocusableChild()) { + this.#cachedHasFocusableChild = true; + break; + } + } + } + return this.#cachedHasFocusableChild; + } + + public find(predicate: (x: AXNode) => boolean): AXNode | null { + if (predicate(this)) { + return this; + } + for (const child of this.children) { + const result = child.find(predicate); + if (result) { + return result; + } + } + return null; + } + + public isLeafNode(): boolean { + if (!this.children.length) { + return true; + } + + // These types of objects may have children that we use as internal + // implementation details, but we want to expose them as leaves to platform + // accessibility APIs because screen readers might be confused if they find + // any children. + if (this.#isPlainTextField() || this.#isTextOnlyObject()) { + return true; + } + + // Roles whose children are only presentational according to the ARIA and + // HTML5 Specs should be hidden from screen readers. + // (Note that whilst ARIA buttons can have only presentational children, HTML5 + // buttons are allowed to have content.) + switch (this.#role) { + case 'doc-cover': + case 'graphics-symbol': + case 'img': + case 'image': + case 'Meter': + case 'scrollbar': + case 'slider': + case 'separator': + case 'progressbar': + return true; + default: + break; + } + + // Here and below: Android heuristics + if (this.#hasFocusableChild()) { + return false; + } + if (this.#focusable && this.#name) { + return true; + } + if (this.#role === 'heading' && this.#name) { + return true; + } + return false; + } + + public isControl(): boolean { + switch (this.#role) { + case 'button': + case 'checkbox': + case 'ColorWell': + case 'combobox': + case 'DisclosureTriangle': + case 'listbox': + case 'menu': + case 'menubar': + case 'menuitem': + case 'menuitemcheckbox': + case 'menuitemradio': + case 'radio': + case 'scrollbar': + case 'searchbox': + case 'slider': + case 'spinbutton': + case 'switch': + case 'tab': + case 'textbox': + case 'tree': + case 'treeitem': + return true; + default: + return false; + } + } + + public isInteresting(insideControl: boolean): boolean { + const role = this.#role; + if (role === 'Ignored' || this.#hidden || this.#ignored) { + return false; + } + + if (this.#focusable || this.#richlyEditable) { + return true; + } + + // If it's not focusable but has a control role, then it's interesting. + if (this.isControl()) { + return true; + } + + // A non focusable child of a control is not interesting + if (insideControl) { + return false; + } + + return this.isLeafNode() && !!this.#name; + } + + public serialize(): SerializedAXNode { + const properties = new Map<string, number | string | boolean>(); + for (const property of this.payload.properties || []) { + properties.set(property.name.toLowerCase(), property.value.value); + } + if (this.payload.name) { + properties.set('name', this.payload.name.value); + } + if (this.payload.value) { + properties.set('value', this.payload.value.value); + } + if (this.payload.description) { + properties.set('description', this.payload.description.value); + } + + const node: SerializedAXNode = { + role: this.#role, + }; + + type UserStringProperty = + | 'name' + | 'value' + | 'description' + | 'keyshortcuts' + | 'roledescription' + | 'valuetext'; + + const userStringProperties: UserStringProperty[] = [ + 'name', + 'value', + 'description', + 'keyshortcuts', + 'roledescription', + 'valuetext', + ]; + const getUserStringPropertyValue = (key: UserStringProperty): string => { + return properties.get(key) as string; + }; + + for (const userStringProperty of userStringProperties) { + if (!properties.has(userStringProperty)) { + continue; + } + + node[userStringProperty] = getUserStringPropertyValue(userStringProperty); + } + + type BooleanProperty = + | 'disabled' + | 'expanded' + | 'focused' + | 'modal' + | 'multiline' + | 'multiselectable' + | 'readonly' + | 'required' + | 'selected'; + const booleanProperties: BooleanProperty[] = [ + 'disabled', + 'expanded', + 'focused', + 'modal', + 'multiline', + 'multiselectable', + 'readonly', + 'required', + 'selected', + ]; + const getBooleanPropertyValue = (key: BooleanProperty): boolean => { + return properties.get(key) as boolean; + }; + + for (const booleanProperty of booleanProperties) { + // RootWebArea's treat focus differently than other nodes. They report whether + // their frame has focus, not whether focus is specifically on the root + // node. + if (booleanProperty === 'focused' && this.#role === 'RootWebArea') { + continue; + } + const value = getBooleanPropertyValue(booleanProperty); + if (!value) { + continue; + } + node[booleanProperty] = getBooleanPropertyValue(booleanProperty); + } + + type TristateProperty = 'checked' | 'pressed'; + const tristateProperties: TristateProperty[] = ['checked', 'pressed']; + for (const tristateProperty of tristateProperties) { + if (!properties.has(tristateProperty)) { + continue; + } + const value = properties.get(tristateProperty); + node[tristateProperty] = + value === 'mixed' ? 'mixed' : value === 'true' ? true : false; + } + + type NumbericalProperty = 'level' | 'valuemax' | 'valuemin'; + const numericalProperties: NumbericalProperty[] = [ + 'level', + 'valuemax', + 'valuemin', + ]; + const getNumericalPropertyValue = (key: NumbericalProperty): number => { + return properties.get(key) as number; + }; + for (const numericalProperty of numericalProperties) { + if (!properties.has(numericalProperty)) { + continue; + } + node[numericalProperty] = getNumericalPropertyValue(numericalProperty); + } + + type TokenProperty = + | 'autocomplete' + | 'haspopup' + | 'invalid' + | 'orientation'; + const tokenProperties: TokenProperty[] = [ + 'autocomplete', + 'haspopup', + 'invalid', + 'orientation', + ]; + const getTokenPropertyValue = (key: TokenProperty): string => { + return properties.get(key) as string; + }; + for (const tokenProperty of tokenProperties) { + const value = getTokenPropertyValue(tokenProperty); + if (!value || value === 'false') { + continue; + } + node[tokenProperty] = getTokenPropertyValue(tokenProperty); + } + return node; + } + + public static createTree(payloads: Protocol.Accessibility.AXNode[]): AXNode { + const nodeById = new Map<string, AXNode>(); + for (const payload of payloads) { + nodeById.set(payload.nodeId, new AXNode(payload)); + } + for (const node of nodeById.values()) { + for (const childId of node.payload.childIds || []) { + const child = nodeById.get(childId); + if (child) { + node.children.push(child); + } + } + } + return nodeById.values().next().value; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts new file mode 100644 index 0000000000..2286723758 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import {QueryHandler, type QuerySelector} from '../common/QueryHandler.js'; +import type {AwaitableIterable} from '../common/types.js'; +import {assert} from '../util/assert.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +const NON_ELEMENT_NODE_ROLES = new Set(['StaticText', 'InlineTextBox']); + +const queryAXTree = async ( + client: CDPSession, + element: ElementHandle<Node>, + accessibleName?: string, + role?: string +): Promise<Protocol.Accessibility.AXNode[]> => { + const {nodes} = await client.send('Accessibility.queryAXTree', { + objectId: element.id, + accessibleName, + role, + }); + return nodes.filter((node: Protocol.Accessibility.AXNode) => { + return !node.role || !NON_ELEMENT_NODE_ROLES.has(node.role.value); + }); +}; + +interface ARIASelector { + name?: string; + role?: string; +} + +const isKnownAttribute = ( + attribute: string +): attribute is keyof ARIASelector => { + return ['name', 'role'].includes(attribute); +}; + +const normalizeValue = (value: string): string => { + return value.replace(/ +/g, ' ').trim(); +}; + +/** + * The selectors consist of an accessible name to query for and optionally + * further aria attributes on the form `[<attribute>=<value>]`. + * Currently, we only support the `name` and `role` attribute. + * The following examples showcase how the syntax works wrt. querying: + * + * - 'title[role="heading"]' queries for elements with name 'title' and role 'heading'. + * - '[role="image"]' queries for elements with role 'image' and any name. + * - 'label' queries for elements with name 'label' and any role. + * - '[name=""][role="button"]' queries for elements with no name and role 'button'. + */ +const ATTRIBUTE_REGEXP = + /\[\s*(?<attribute>\w+)\s*=\s*(?<quote>"|')(?<value>\\.|.*?(?=\k<quote>))\k<quote>\s*\]/g; +const parseARIASelector = (selector: string): ARIASelector => { + const queryOptions: ARIASelector = {}; + const defaultName = selector.replace( + ATTRIBUTE_REGEXP, + (_, attribute, __, value) => { + attribute = attribute.trim(); + assert( + isKnownAttribute(attribute), + `Unknown aria attribute "${attribute}" in selector` + ); + queryOptions[attribute] = normalizeValue(value); + return ''; + } + ); + if (defaultName && !queryOptions.name) { + queryOptions.name = normalizeValue(defaultName); + } + return queryOptions; +}; + +/** + * @internal + */ +export class ARIAQueryHandler extends QueryHandler { + static override querySelector: QuerySelector = async ( + node, + selector, + {ariaQuerySelector} + ) => { + return await ariaQuerySelector(node, selector); + }; + + static override async *queryAll( + element: ElementHandle<Node>, + selector: string + ): AwaitableIterable<ElementHandle<Node>> { + const {name, role} = parseARIASelector(selector); + const results = await queryAXTree( + element.realm.environment.client, + element, + name, + role + ); + yield* AsyncIterableUtil.map(results, node => { + return element.realm.adoptBackendNode(node.backendDOMNodeId) as Promise< + ElementHandle<Node> + >; + }); + } + + static override queryOne = async ( + element: ElementHandle<Node>, + selector: string + ): Promise<ElementHandle<Node> | null> => { + return ( + (await AsyncIterableUtil.first(this.queryAll(element, selector))) ?? null + ); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts new file mode 100644 index 0000000000..7a6a6f8582 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts @@ -0,0 +1,118 @@ +import {JSHandle} from '../api/JSHandle.js'; +import {debugError} from '../common/util.js'; +import {DisposableStack} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import type {ExecutionContext} from './ExecutionContext.js'; + +/** + * @internal + */ +export class Binding { + #name: string; + #fn: (...args: unknown[]) => unknown; + constructor(name: string, fn: (...args: unknown[]) => unknown) { + this.#name = name; + this.#fn = fn; + } + + get name(): string { + return this.#name; + } + + /** + * @param context - Context to run the binding in; the context should have + * the binding added to it beforehand. + * @param id - ID of the call. This should come from the CDP + * `onBindingCalled` response. + * @param args - Plain arguments from CDP. + */ + async run( + context: ExecutionContext, + id: number, + args: unknown[], + isTrivial: boolean + ): Promise<void> { + const stack = new DisposableStack(); + try { + if (!isTrivial) { + // Getting non-trivial arguments. + using handles = await context.evaluateHandle( + (name, seq) => { + // @ts-expect-error Code is evaluated in a different context. + return globalThis[name].args.get(seq); + }, + this.#name, + id + ); + const properties = await handles.getProperties(); + for (const [index, handle] of properties) { + // This is not straight-forward since some arguments can stringify, but + // aren't plain objects so add subtypes when the use-case arises. + if (index in args) { + switch (handle.remoteObject().subtype) { + case 'node': + args[+index] = handle; + break; + default: + stack.use(handle); + } + } else { + stack.use(handle); + } + } + } + + await context.evaluate( + (name, seq, result) => { + // @ts-expect-error Code is evaluated in a different context. + const callbacks = globalThis[name].callbacks; + callbacks.get(seq).resolve(result); + callbacks.delete(seq); + }, + this.#name, + id, + await this.#fn(...args) + ); + + for (const arg of args) { + if (arg instanceof JSHandle) { + stack.use(arg); + } + } + } catch (error) { + if (isErrorLike(error)) { + await context + .evaluate( + (name, seq, message, stack) => { + const error = new Error(message); + error.stack = stack; + // @ts-expect-error Code is evaluated in a different context. + const callbacks = globalThis[name].callbacks; + callbacks.get(seq).reject(error); + callbacks.delete(seq); + }, + this.#name, + id, + error.message, + error.stack + ) + .catch(debugError); + } else { + await context + .evaluate( + (name, seq, error) => { + // @ts-expect-error Code is evaluated in a different context. + const callbacks = globalThis[name].callbacks; + callbacks.get(seq).reject(error); + callbacks.delete(seq); + }, + this.#name, + id, + error + ) + .catch(debugError); + } + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts new file mode 100644 index 0000000000..7698acd164 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts @@ -0,0 +1,523 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcess} from 'child_process'; + +import type {Protocol} from 'devtools-protocol'; + +import type {DebugInfo} from '../api/Browser.js'; +import { + Browser as BrowserBase, + BrowserEvent, + WEB_PERMISSION_TO_PROTOCOL_PERMISSION, + type BrowserCloseCallback, + type BrowserContextOptions, + type IsPageTargetCallback, + type Permission, + type TargetFilterCallback, + type WaitForTargetOptions, +} from '../api/Browser.js'; +import {BrowserContext, BrowserContextEvent} from '../api/BrowserContext.js'; +import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; +import type {Page} from '../api/Page.js'; +import type {Target} from '../api/Target.js'; +import type {Viewport} from '../common/Viewport.js'; +import {assert} from '../util/assert.js'; + +import {ChromeTargetManager} from './ChromeTargetManager.js'; +import type {Connection} from './Connection.js'; +import {FirefoxTargetManager} from './FirefoxTargetManager.js'; +import { + DevToolsTarget, + InitializationStatus, + OtherTarget, + PageTarget, + WorkerTarget, + type CdpTarget, +} from './Target.js'; +import {TargetManagerEvent, type TargetManager} from './TargetManager.js'; + +/** + * @internal + */ +export class CdpBrowser extends BrowserBase { + readonly protocol = 'cdp'; + + static async _create( + product: 'firefox' | 'chrome' | undefined, + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport | null, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback, + targetFilterCallback?: TargetFilterCallback, + isPageTargetCallback?: IsPageTargetCallback, + waitForInitiallyDiscoveredTargets = true + ): Promise<CdpBrowser> { + const browser = new CdpBrowser( + product, + connection, + contextIds, + ignoreHTTPSErrors, + defaultViewport, + process, + closeCallback, + targetFilterCallback, + isPageTargetCallback, + waitForInitiallyDiscoveredTargets + ); + await browser._attach(); + return browser; + } + #ignoreHTTPSErrors: boolean; + #defaultViewport?: Viewport | null; + #process?: ChildProcess; + #connection: Connection; + #closeCallback: BrowserCloseCallback; + #targetFilterCallback: TargetFilterCallback; + #isPageTargetCallback!: IsPageTargetCallback; + #defaultContext: CdpBrowserContext; + #contexts = new Map<string, CdpBrowserContext>(); + #targetManager: TargetManager; + + constructor( + product: 'chrome' | 'firefox' | undefined, + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport | null, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback, + targetFilterCallback?: TargetFilterCallback, + isPageTargetCallback?: IsPageTargetCallback, + waitForInitiallyDiscoveredTargets = true + ) { + super(); + product = product || 'chrome'; + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#defaultViewport = defaultViewport; + this.#process = process; + this.#connection = connection; + this.#closeCallback = closeCallback || function (): void {}; + this.#targetFilterCallback = + targetFilterCallback || + ((): boolean => { + return true; + }); + this.#setIsPageTargetCallback(isPageTargetCallback); + if (product === 'firefox') { + this.#targetManager = new FirefoxTargetManager( + connection, + this.#createTarget, + this.#targetFilterCallback + ); + } else { + this.#targetManager = new ChromeTargetManager( + connection, + this.#createTarget, + this.#targetFilterCallback, + waitForInitiallyDiscoveredTargets + ); + } + this.#defaultContext = new CdpBrowserContext(this.#connection, this); + for (const contextId of contextIds) { + this.#contexts.set( + contextId, + new CdpBrowserContext(this.#connection, this, contextId) + ); + } + } + + #emitDisconnected = () => { + this.emit(BrowserEvent.Disconnected, undefined); + }; + + async _attach(): Promise<void> { + this.#connection.on(CDPSessionEvent.Disconnected, this.#emitDisconnected); + this.#targetManager.on( + TargetManagerEvent.TargetAvailable, + this.#onAttachedToTarget + ); + this.#targetManager.on( + TargetManagerEvent.TargetGone, + this.#onDetachedFromTarget + ); + this.#targetManager.on( + TargetManagerEvent.TargetChanged, + this.#onTargetChanged + ); + this.#targetManager.on( + TargetManagerEvent.TargetDiscovered, + this.#onTargetDiscovered + ); + await this.#targetManager.initialize(); + } + + _detach(): void { + this.#connection.off(CDPSessionEvent.Disconnected, this.#emitDisconnected); + this.#targetManager.off( + TargetManagerEvent.TargetAvailable, + this.#onAttachedToTarget + ); + this.#targetManager.off( + TargetManagerEvent.TargetGone, + this.#onDetachedFromTarget + ); + this.#targetManager.off( + TargetManagerEvent.TargetChanged, + this.#onTargetChanged + ); + this.#targetManager.off( + TargetManagerEvent.TargetDiscovered, + this.#onTargetDiscovered + ); + } + + override process(): ChildProcess | null { + return this.#process ?? null; + } + + _targetManager(): TargetManager { + return this.#targetManager; + } + + #setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void { + this.#isPageTargetCallback = + isPageTargetCallback || + ((target: Target): boolean => { + return ( + target.type() === 'page' || + target.type() === 'background_page' || + target.type() === 'webview' + ); + }); + } + + _getIsPageTargetCallback(): IsPageTargetCallback | undefined { + return this.#isPageTargetCallback; + } + + override async createIncognitoBrowserContext( + options: BrowserContextOptions = {} + ): Promise<CdpBrowserContext> { + const {proxyServer, proxyBypassList} = options; + + const {browserContextId} = await this.#connection.send( + 'Target.createBrowserContext', + { + proxyServer, + proxyBypassList: proxyBypassList && proxyBypassList.join(','), + } + ); + const context = new CdpBrowserContext( + this.#connection, + this, + browserContextId + ); + this.#contexts.set(browserContextId, context); + return context; + } + + override browserContexts(): CdpBrowserContext[] { + return [this.#defaultContext, ...Array.from(this.#contexts.values())]; + } + + override defaultBrowserContext(): CdpBrowserContext { + return this.#defaultContext; + } + + async _disposeContext(contextId?: string): Promise<void> { + if (!contextId) { + return; + } + await this.#connection.send('Target.disposeBrowserContext', { + browserContextId: contextId, + }); + this.#contexts.delete(contextId); + } + + #createTarget = ( + targetInfo: Protocol.Target.TargetInfo, + session?: CDPSession + ) => { + const {browserContextId} = targetInfo; + const context = + browserContextId && this.#contexts.has(browserContextId) + ? this.#contexts.get(browserContextId) + : this.#defaultContext; + + if (!context) { + throw new Error('Missing browser context'); + } + + const createSession = (isAutoAttachEmulated: boolean) => { + return this.#connection._createSession(targetInfo, isAutoAttachEmulated); + }; + const otherTarget = new OtherTarget( + targetInfo, + session, + context, + this.#targetManager, + createSession + ); + if (targetInfo.url?.startsWith('devtools://')) { + return new DevToolsTarget( + targetInfo, + session, + context, + this.#targetManager, + createSession, + this.#ignoreHTTPSErrors, + this.#defaultViewport ?? null + ); + } + if (this.#isPageTargetCallback(otherTarget)) { + return new PageTarget( + targetInfo, + session, + context, + this.#targetManager, + createSession, + this.#ignoreHTTPSErrors, + this.#defaultViewport ?? null + ); + } + if ( + targetInfo.type === 'service_worker' || + targetInfo.type === 'shared_worker' + ) { + return new WorkerTarget( + targetInfo, + session, + context, + this.#targetManager, + createSession + ); + } + return otherTarget; + }; + + #onAttachedToTarget = async (target: CdpTarget) => { + if ( + target._isTargetExposed() && + (await target._initializedDeferred.valueOrThrow()) === + InitializationStatus.SUCCESS + ) { + this.emit(BrowserEvent.TargetCreated, target); + target.browserContext().emit(BrowserContextEvent.TargetCreated, target); + } + }; + + #onDetachedFromTarget = async (target: CdpTarget): Promise<void> => { + target._initializedDeferred.resolve(InitializationStatus.ABORTED); + target._isClosedDeferred.resolve(); + if ( + target._isTargetExposed() && + (await target._initializedDeferred.valueOrThrow()) === + InitializationStatus.SUCCESS + ) { + this.emit(BrowserEvent.TargetDestroyed, target); + target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target); + } + }; + + #onTargetChanged = ({target}: {target: CdpTarget}): void => { + this.emit(BrowserEvent.TargetChanged, target); + target.browserContext().emit(BrowserContextEvent.TargetChanged, target); + }; + + #onTargetDiscovered = (targetInfo: Protocol.Target.TargetInfo): void => { + this.emit(BrowserEvent.TargetDiscovered, targetInfo); + }; + + override wsEndpoint(): string { + return this.#connection.url(); + } + + override async newPage(): Promise<Page> { + return await this.#defaultContext.newPage(); + } + + async _createPageInContext(contextId?: string): Promise<Page> { + const {targetId} = await this.#connection.send('Target.createTarget', { + url: 'about:blank', + browserContextId: contextId || undefined, + }); + const target = (await this.waitForTarget(t => { + return (t as CdpTarget)._targetId === targetId; + })) as CdpTarget; + if (!target) { + throw new Error(`Missing target for page (id = ${targetId})`); + } + const initialized = + (await target._initializedDeferred.valueOrThrow()) === + InitializationStatus.SUCCESS; + if (!initialized) { + throw new Error(`Failed to create target for page (id = ${targetId})`); + } + const page = await target.page(); + if (!page) { + throw new Error( + `Failed to create a page for context (id = ${contextId})` + ); + } + return page; + } + + override targets(): CdpTarget[] { + return Array.from( + this.#targetManager.getAvailableTargets().values() + ).filter(target => { + return ( + target._isTargetExposed() && + target._initializedDeferred.value() === InitializationStatus.SUCCESS + ); + }); + } + + override target(): CdpTarget { + const browserTarget = this.targets().find(target => { + return target.type() === 'browser'; + }); + if (!browserTarget) { + throw new Error('Browser target is not found'); + } + return browserTarget; + } + + override async version(): Promise<string> { + const version = await this.#getVersion(); + return version.product; + } + + override async userAgent(): Promise<string> { + const version = await this.#getVersion(); + return version.userAgent; + } + + override async close(): Promise<void> { + await this.#closeCallback.call(null); + await this.disconnect(); + } + + override disconnect(): Promise<void> { + this.#targetManager.dispose(); + this.#connection.dispose(); + this._detach(); + return Promise.resolve(); + } + + override get connected(): boolean { + return !this.#connection._closed; + } + + #getVersion(): Promise<Protocol.Browser.GetVersionResponse> { + return this.#connection.send('Browser.getVersion'); + } + + override get debugInfo(): DebugInfo { + return { + pendingProtocolErrors: this.#connection.getPendingProtocolErrors(), + }; + } +} + +/** + * @internal + */ +export class CdpBrowserContext extends BrowserContext { + #connection: Connection; + #browser: CdpBrowser; + #id?: string; + + constructor(connection: Connection, browser: CdpBrowser, contextId?: string) { + super(); + this.#connection = connection; + this.#browser = browser; + this.#id = contextId; + } + + override get id(): string | undefined { + return this.#id; + } + + override targets(): CdpTarget[] { + return this.#browser.targets().filter(target => { + return target.browserContext() === this; + }); + } + + override waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options: WaitForTargetOptions = {} + ): Promise<Target> { + return this.#browser.waitForTarget(target => { + return target.browserContext() === this && predicate(target); + }, options); + } + + override async pages(): Promise<Page[]> { + const pages = await Promise.all( + this.targets() + .filter(target => { + return ( + target.type() === 'page' || + (target.type() === 'other' && + this.#browser._getIsPageTargetCallback()?.(target)) + ); + }) + .map(target => { + return target.page(); + }) + ); + return pages.filter((page): page is Page => { + return !!page; + }); + } + + override isIncognito(): boolean { + return !!this.#id; + } + + override async overridePermissions( + origin: string, + permissions: Permission[] + ): Promise<void> { + const protocolPermissions = permissions.map(permission => { + const protocolPermission = + WEB_PERMISSION_TO_PROTOCOL_PERMISSION.get(permission); + if (!protocolPermission) { + throw new Error('Unknown permission: ' + permission); + } + return protocolPermission; + }); + await this.#connection.send('Browser.grantPermissions', { + origin, + browserContextId: this.#id || undefined, + permissions: protocolPermissions, + }); + } + + override async clearPermissionOverrides(): Promise<void> { + await this.#connection.send('Browser.resetPermissions', { + browserContextId: this.#id || undefined, + }); + } + + override newPage(): Promise<Page> { + return this.#browser._createPageInContext(this.#id); + } + + override browser(): CdpBrowser { + return this.#browser; + } + + override async close(): Promise<void> { + assert(this.#id, 'Non-incognito profiles cannot be closed!'); + await this.#browser._disposeContext(this.#id); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts new file mode 100644 index 0000000000..ef4aebe747 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import type { + BrowserConnectOptions, + ConnectOptions, +} from '../common/ConnectOptions.js'; +import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; + +import {CdpBrowser} from './Browser.js'; +import {Connection} from './Connection.js'; + +/** + * Users should never call this directly; it's called when calling + * `puppeteer.connect` with `protocol: 'cdp'`. + * + * @internal + */ +export async function _connectToCdpBrowser( + connectionTransport: ConnectionTransport, + url: string, + options: BrowserConnectOptions & ConnectOptions +): Promise<CdpBrowser> { + const { + ignoreHTTPSErrors = false, + defaultViewport = DEFAULT_VIEWPORT, + targetFilter, + _isPageTarget: isPageTarget, + slowMo = 0, + protocolTimeout, + } = options; + + const connection = new Connection( + url, + connectionTransport, + slowMo, + protocolTimeout + ); + + const version = await connection.send('Browser.getVersion'); + const product = version.product.toLowerCase().includes('firefox') + ? 'firefox' + : 'chrome'; + + const {browserContextIds} = await connection.send( + 'Target.getBrowserContexts' + ); + const browser = await CdpBrowser._create( + product || 'chrome', + connection, + browserContextIds, + ignoreHTTPSErrors, + defaultViewport, + undefined, + () => { + return connection.send('Browser.close').catch(debugError); + }, + targetFilter, + isPageTarget + ); + return browser; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts new file mode 100644 index 0000000000..fe5faa5647 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import { + type CDPEvents, + CDPSession, + CDPSessionEvent, + type CommandOptions, +} from '../api/CDPSession.js'; +import {CallbackRegistry} from '../common/CallbackRegistry.js'; +import {TargetCloseError} from '../common/Errors.js'; +import {assert} from '../util/assert.js'; +import {createProtocolErrorMessage} from '../util/ErrorLike.js'; + +import type {Connection} from './Connection.js'; +import type {CdpTarget} from './Target.js'; + +/** + * @internal + */ + +export class CdpCDPSession extends CDPSession { + #sessionId: string; + #targetType: string; + #callbacks = new CallbackRegistry(); + #connection?: Connection; + #parentSessionId?: string; + #target?: CdpTarget; + + /** + * @internal + */ + constructor( + connection: Connection, + targetType: string, + sessionId: string, + parentSessionId: string | undefined + ) { + super(); + this.#connection = connection; + this.#targetType = targetType; + this.#sessionId = sessionId; + this.#parentSessionId = parentSessionId; + } + + /** + * Sets the {@link CdpTarget} associated with the session instance. + * + * @internal + */ + _setTarget(target: CdpTarget): void { + this.#target = target; + } + + /** + * Gets the {@link CdpTarget} associated with the session instance. + * + * @internal + */ + _target(): CdpTarget { + assert(this.#target, 'Target must exist'); + return this.#target; + } + + override connection(): Connection | undefined { + return this.#connection; + } + + override parentSession(): CDPSession | undefined { + if (!this.#parentSessionId) { + // To make it work in Firefox that does not have parent (tab) sessions. + return this; + } + const parent = this.#connection?.session(this.#parentSessionId); + return parent ?? undefined; + } + + override send<T extends keyof ProtocolMapping.Commands>( + method: T, + params?: ProtocolMapping.Commands[T]['paramsType'][0], + options?: CommandOptions + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (!this.#connection) { + return Promise.reject( + new TargetCloseError( + `Protocol error (${method}): Session closed. Most likely the ${this.#targetType} has been closed.` + ) + ); + } + return this.#connection._rawSend( + this.#callbacks, + method, + params, + this.#sessionId, + options + ); + } + + /** + * @internal + */ + _onMessage(object: { + id?: number; + method: keyof CDPEvents; + params: CDPEvents[keyof CDPEvents]; + error: {message: string; data: any; code: number}; + result?: any; + }): void { + if (object.id) { + if (object.error) { + this.#callbacks.reject( + object.id, + createProtocolErrorMessage(object), + object.error.message + ); + } else { + this.#callbacks.resolve(object.id, object.result); + } + } else { + assert(!object.id); + this.emit(object.method, object.params); + } + } + + /** + * Detaches the cdpSession from the target. Once detached, the cdpSession object + * won't emit any events and can't be used to send messages. + */ + override async detach(): Promise<void> { + if (!this.#connection) { + throw new Error( + `Session already detached. Most likely the ${this.#targetType} has been closed.` + ); + } + await this.#connection.send('Target.detachFromTarget', { + sessionId: this.#sessionId, + }); + } + + /** + * @internal + */ + _onClosed(): void { + this.#callbacks.clear(); + this.#connection = undefined; + this.emit(CDPSessionEvent.Disconnected, undefined); + } + + /** + * Returns the session's id. + */ + override id(): string { + return this.#sessionId; + } + + /** + * @internal + */ + getPendingProtocolErrors(): Error[] { + return this.#callbacks.getPendingProtocolErrors(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts new file mode 100644 index 0000000000..e87d71fff9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts @@ -0,0 +1,417 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {TargetFilterCallback} from '../api/Browser.js'; +import {CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {CdpCDPSession} from './CDPSession.js'; +import type {Connection} from './Connection.js'; +import {CdpTarget, InitializationStatus} from './Target.js'; +import { + type TargetFactory, + type TargetManager, + TargetManagerEvent, + type TargetManagerEvents, +} from './TargetManager.js'; + +function isPageTargetBecomingPrimary( + target: CdpTarget, + newTargetInfo: Protocol.Target.TargetInfo +): boolean { + return Boolean(target._subtype()) && !newTargetInfo.subtype; +} + +/** + * ChromeTargetManager uses the CDP's auto-attach mechanism to intercept + * new targets and allow the rest of Puppeteer to configure listeners while + * the target is paused. + * + * @internal + */ +export class ChromeTargetManager + extends EventEmitter<TargetManagerEvents> + implements TargetManager +{ + #connection: Connection; + /** + * Keeps track of the following events: 'Target.targetCreated', + * 'Target.targetDestroyed', 'Target.targetInfoChanged'. + * + * A target becomes discovered when 'Target.targetCreated' is received. + * A target is removed from this map once 'Target.targetDestroyed' is + * received. + * + * `targetFilterCallback` has no effect on this map. + */ + #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>(); + /** + * A target is added to this map once ChromeTargetManager has created + * a Target and attached at least once to it. + */ + #attachedTargetsByTargetId = new Map<string, CdpTarget>(); + /** + * Tracks which sessions attach to which target. + */ + #attachedTargetsBySessionId = new Map<string, CdpTarget>(); + /** + * If a target was filtered out by `targetFilterCallback`, we still receive + * events about it from CDP, but we don't forward them to the rest of Puppeteer. + */ + #ignoredTargets = new Set<string>(); + #targetFilterCallback: TargetFilterCallback | undefined; + #targetFactory: TargetFactory; + + #attachedToTargetListenersBySession = new WeakMap< + CDPSession | Connection, + (event: Protocol.Target.AttachedToTargetEvent) => void + >(); + #detachedFromTargetListenersBySession = new WeakMap< + CDPSession | Connection, + (event: Protocol.Target.DetachedFromTargetEvent) => void + >(); + + #initializeDeferred = Deferred.create<void>(); + #targetsIdsForInit = new Set<string>(); + #waitForInitiallyDiscoveredTargets = true; + + #discoveryFilter: Protocol.Target.FilterEntry[] = [{}]; + + constructor( + connection: Connection, + targetFactory: TargetFactory, + targetFilterCallback?: TargetFilterCallback, + waitForInitiallyDiscoveredTargets = true + ) { + super(); + this.#connection = connection; + this.#targetFilterCallback = targetFilterCallback; + this.#targetFactory = targetFactory; + this.#waitForInitiallyDiscoveredTargets = waitForInitiallyDiscoveredTargets; + + this.#connection.on('Target.targetCreated', this.#onTargetCreated); + this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged); + this.#connection.on( + CDPSessionEvent.SessionDetached, + this.#onSessionDetached + ); + this.#setupAttachmentListeners(this.#connection); + } + + #storeExistingTargetsForInit = () => { + if (!this.#waitForInitiallyDiscoveredTargets) { + return; + } + for (const [ + targetId, + targetInfo, + ] of this.#discoveredTargetsByTargetId.entries()) { + const targetForFilter = new CdpTarget( + targetInfo, + undefined, + undefined, + this, + undefined + ); + if ( + (!this.#targetFilterCallback || + this.#targetFilterCallback(targetForFilter)) && + targetInfo.type !== 'browser' + ) { + this.#targetsIdsForInit.add(targetId); + } + } + }; + + async initialize(): Promise<void> { + await this.#connection.send('Target.setDiscoverTargets', { + discover: true, + filter: this.#discoveryFilter, + }); + + this.#storeExistingTargetsForInit(); + + await this.#connection.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + filter: [ + { + type: 'page', + exclude: true, + }, + ...this.#discoveryFilter, + ], + }); + this.#finishInitializationIfReady(); + await this.#initializeDeferred.valueOrThrow(); + } + + dispose(): void { + this.#connection.off('Target.targetCreated', this.#onTargetCreated); + this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged); + this.#connection.off( + CDPSessionEvent.SessionDetached, + this.#onSessionDetached + ); + + this.#removeAttachmentListeners(this.#connection); + } + + getAvailableTargets(): ReadonlyMap<string, CdpTarget> { + return this.#attachedTargetsByTargetId; + } + + #setupAttachmentListeners(session: CDPSession | Connection): void { + const listener = (event: Protocol.Target.AttachedToTargetEvent) => { + void this.#onAttachedToTarget(session, event); + }; + assert(!this.#attachedToTargetListenersBySession.has(session)); + this.#attachedToTargetListenersBySession.set(session, listener); + session.on('Target.attachedToTarget', listener); + + const detachedListener = ( + event: Protocol.Target.DetachedFromTargetEvent + ) => { + return this.#onDetachedFromTarget(session, event); + }; + assert(!this.#detachedFromTargetListenersBySession.has(session)); + this.#detachedFromTargetListenersBySession.set(session, detachedListener); + session.on('Target.detachedFromTarget', detachedListener); + } + + #removeAttachmentListeners(session: CDPSession | Connection): void { + const listener = this.#attachedToTargetListenersBySession.get(session); + if (listener) { + session.off('Target.attachedToTarget', listener); + this.#attachedToTargetListenersBySession.delete(session); + } + + if (this.#detachedFromTargetListenersBySession.has(session)) { + session.off( + 'Target.detachedFromTarget', + this.#detachedFromTargetListenersBySession.get(session)! + ); + this.#detachedFromTargetListenersBySession.delete(session); + } + } + + #onSessionDetached = (session: CDPSession) => { + this.#removeAttachmentListeners(session); + }; + + #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => { + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + this.emit(TargetManagerEvent.TargetDiscovered, event.targetInfo); + + // The connection is already attached to the browser target implicitly, + // therefore, no new CDPSession is created and we have special handling + // here. + if (event.targetInfo.type === 'browser' && event.targetInfo.attached) { + if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) { + return; + } + const target = this.#targetFactory(event.targetInfo, undefined); + target._initialize(); + this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target); + } + }; + + #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => { + const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId); + this.#discoveredTargetsByTargetId.delete(event.targetId); + this.#finishInitializationIfReady(event.targetId); + if ( + targetInfo?.type === 'service_worker' && + this.#attachedTargetsByTargetId.has(event.targetId) + ) { + // Special case for service workers: report TargetGone event when + // the worker is destroyed. + const target = this.#attachedTargetsByTargetId.get(event.targetId); + if (target) { + this.emit(TargetManagerEvent.TargetGone, target); + this.#attachedTargetsByTargetId.delete(event.targetId); + } + } + }; + + #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => { + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + if ( + this.#ignoredTargets.has(event.targetInfo.targetId) || + !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) || + !event.targetInfo.attached + ) { + return; + } + + const target = this.#attachedTargetsByTargetId.get( + event.targetInfo.targetId + ); + if (!target) { + return; + } + const previousURL = target.url(); + const wasInitialized = + target._initializedDeferred.value() === InitializationStatus.SUCCESS; + + if (isPageTargetBecomingPrimary(target, event.targetInfo)) { + const session = target?._session(); + assert( + session, + 'Target that is being activated is missing a CDPSession.' + ); + session.parentSession()?.emit(CDPSessionEvent.Swapped, session); + } + + target._targetInfoChanged(event.targetInfo); + + if (wasInitialized && previousURL !== target.url()) { + this.emit(TargetManagerEvent.TargetChanged, { + target, + wasInitialized, + previousURL, + }); + } + }; + + #onAttachedToTarget = async ( + parentSession: Connection | CDPSession, + event: Protocol.Target.AttachedToTargetEvent + ) => { + const targetInfo = event.targetInfo; + const session = this.#connection.session(event.sessionId); + if (!session) { + throw new Error(`Session ${event.sessionId} was not created.`); + } + + const silentDetach = async () => { + await session.send('Runtime.runIfWaitingForDebugger').catch(debugError); + // We don't use `session.detach()` because that dispatches all commands on + // the connection instead of the parent session. + await parentSession + .send('Target.detachFromTarget', { + sessionId: session.id(), + }) + .catch(debugError); + }; + + if (!this.#connection.isAutoAttached(targetInfo.targetId)) { + return; + } + + // Special case for service workers: being attached to service workers will + // prevent them from ever being destroyed. Therefore, we silently detach + // from service workers unless the connection was manually created via + // `page.worker()`. To determine this, we use + // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we + // should determine if a target is auto-attached or not with the help of + // CDP. + if (targetInfo.type === 'service_worker') { + this.#finishInitializationIfReady(targetInfo.targetId); + await silentDetach(); + if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) { + return; + } + const target = this.#targetFactory(targetInfo); + target._initialize(); + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.emit(TargetManagerEvent.TargetAvailable, target); + return; + } + + const isExistingTarget = this.#attachedTargetsByTargetId.has( + targetInfo.targetId + ); + + const target = isExistingTarget + ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + : this.#targetFactory( + targetInfo, + session, + parentSession instanceof CDPSession ? parentSession : undefined + ); + + if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) { + this.#ignoredTargets.add(targetInfo.targetId); + this.#finishInitializationIfReady(targetInfo.targetId); + await silentDetach(); + return; + } + + this.#setupAttachmentListeners(session); + + if (isExistingTarget) { + (session as CdpCDPSession)._setTarget(target); + this.#attachedTargetsBySessionId.set( + session.id(), + this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + ); + } else { + target._initialize(); + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.#attachedTargetsBySessionId.set(session.id(), target); + } + + parentSession.emit(CDPSessionEvent.Ready, session); + + this.#targetsIdsForInit.delete(target._targetId); + if (!isExistingTarget) { + this.emit(TargetManagerEvent.TargetAvailable, target); + } + this.#finishInitializationIfReady(); + + // TODO: the browser might be shutting down here. What do we do with the + // error? + await Promise.all([ + session.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + filter: this.#discoveryFilter, + }), + session.send('Runtime.runIfWaitingForDebugger'), + ]).catch(debugError); + }; + + #finishInitializationIfReady(targetId?: string): void { + targetId !== undefined && this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeDeferred.resolve(); + } + } + + #onDetachedFromTarget = ( + _parentSession: Connection | CDPSession, + event: Protocol.Target.DetachedFromTargetEvent + ) => { + const target = this.#attachedTargetsBySessionId.get(event.sessionId); + + this.#attachedTargetsBySessionId.delete(event.sessionId); + + if (!target) { + return; + } + + this.#attachedTargetsByTargetId.delete(target._targetId); + this.emit(TargetManagerEvent.TargetGone, target); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts new file mode 100644 index 0000000000..3c565341b3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import type {CommandOptions} from '../api/CDPSession.js'; +import { + CDPSessionEvent, + type CDPSession, + type CDPSessionEvents, +} from '../api/CDPSession.js'; +import {CallbackRegistry} from '../common/CallbackRegistry.js'; +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {debug} from '../common/Debug.js'; +import {TargetCloseError} from '../common/Errors.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {createProtocolErrorMessage} from '../util/ErrorLike.js'; + +import {CdpCDPSession} from './CDPSession.js'; + +const debugProtocolSend = debug('puppeteer:protocol:SEND ►'); +const debugProtocolReceive = debug('puppeteer:protocol:RECV ◀'); + +/** + * @public + */ +export type {ConnectionTransport, ProtocolMapping}; + +/** + * @public + */ +export class Connection extends EventEmitter<CDPSessionEvents> { + #url: string; + #transport: ConnectionTransport; + #delay: number; + #timeout: number; + #sessions = new Map<string, CdpCDPSession>(); + #closed = false; + #manuallyAttached = new Set<string>(); + #callbacks = new CallbackRegistry(); + + constructor( + url: string, + transport: ConnectionTransport, + delay = 0, + timeout?: number + ) { + super(); + this.#url = url; + this.#delay = delay; + this.#timeout = timeout ?? 180_000; + + this.#transport = transport; + this.#transport.onmessage = this.onMessage.bind(this); + this.#transport.onclose = this.#onClose.bind(this); + } + + static fromSession(session: CDPSession): Connection | undefined { + return session.connection(); + } + + get timeout(): number { + return this.#timeout; + } + + /** + * @internal + */ + get _closed(): boolean { + return this.#closed; + } + + /** + * @internal + */ + get _sessions(): Map<string, CDPSession> { + return this.#sessions; + } + + /** + * @param sessionId - The session id + * @returns The current CDP session if it exists + */ + session(sessionId: string): CDPSession | null { + return this.#sessions.get(sessionId) || null; + } + + url(): string { + return this.#url; + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + params?: ProtocolMapping.Commands[T]['paramsType'][0], + options?: CommandOptions + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + // There is only ever 1 param arg passed, but the Protocol defines it as an + // array of 0 or 1 items See this comment: + // https://github.com/ChromeDevTools/devtools-protocol/pull/113#issuecomment-412603285 + // which explains why the protocol defines the params this way for better + // type-inference. + // So now we check if there are any params or not and deal with them accordingly. + return this._rawSend(this.#callbacks, method, params, undefined, options); + } + + /** + * @internal + */ + _rawSend<T extends keyof ProtocolMapping.Commands>( + callbacks: CallbackRegistry, + method: T, + params: ProtocolMapping.Commands[T]['paramsType'][0], + sessionId?: string, + options?: CommandOptions + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + return callbacks.create(method, options?.timeout ?? this.#timeout, id => { + const stringifiedMessage = JSON.stringify({ + method, + params, + id, + sessionId, + }); + debugProtocolSend(stringifiedMessage); + this.#transport.send(stringifiedMessage); + }) as Promise<ProtocolMapping.Commands[T]['returnType']>; + } + + /** + * @internal + */ + async closeBrowser(): Promise<void> { + await this.send('Browser.close'); + } + + /** + * @internal + */ + protected async onMessage(message: string): Promise<void> { + if (this.#delay) { + await new Promise(r => { + return setTimeout(r, this.#delay); + }); + } + debugProtocolReceive(message); + const object = JSON.parse(message); + if (object.method === 'Target.attachedToTarget') { + const sessionId = object.params.sessionId; + const session = new CdpCDPSession( + this, + object.params.targetInfo.type, + sessionId, + object.sessionId + ); + this.#sessions.set(sessionId, session); + this.emit(CDPSessionEvent.SessionAttached, session); + const parentSession = this.#sessions.get(object.sessionId); + if (parentSession) { + parentSession.emit(CDPSessionEvent.SessionAttached, session); + } + } else if (object.method === 'Target.detachedFromTarget') { + const session = this.#sessions.get(object.params.sessionId); + if (session) { + session._onClosed(); + this.#sessions.delete(object.params.sessionId); + this.emit(CDPSessionEvent.SessionDetached, session); + const parentSession = this.#sessions.get(object.sessionId); + if (parentSession) { + parentSession.emit(CDPSessionEvent.SessionDetached, session); + } + } + } + if (object.sessionId) { + const session = this.#sessions.get(object.sessionId); + if (session) { + session._onMessage(object); + } + } else if (object.id) { + if (object.error) { + this.#callbacks.reject( + object.id, + createProtocolErrorMessage(object), + object.error.message + ); + } else { + this.#callbacks.resolve(object.id, object.result); + } + } else { + this.emit(object.method, object.params); + } + } + + #onClose(): void { + if (this.#closed) { + return; + } + this.#closed = true; + this.#transport.onmessage = undefined; + this.#transport.onclose = undefined; + this.#callbacks.clear(); + for (const session of this.#sessions.values()) { + session._onClosed(); + } + this.#sessions.clear(); + this.emit(CDPSessionEvent.Disconnected, undefined); + } + + dispose(): void { + this.#onClose(); + this.#transport.close(); + } + + /** + * @internal + */ + isAutoAttached(targetId: string): boolean { + return !this.#manuallyAttached.has(targetId); + } + + /** + * @internal + */ + async _createSession( + targetInfo: Protocol.Target.TargetInfo, + isAutoAttachEmulated = true + ): Promise<CDPSession> { + if (!isAutoAttachEmulated) { + this.#manuallyAttached.add(targetInfo.targetId); + } + const {sessionId} = await this.send('Target.attachToTarget', { + targetId: targetInfo.targetId, + flatten: true, + }); + this.#manuallyAttached.delete(targetInfo.targetId); + const session = this.#sessions.get(sessionId); + if (!session) { + throw new Error('CDPSession creation failed.'); + } + return session; + } + + /** + * @param targetInfo - The target info + * @returns The CDP session that is created + */ + async createSession( + targetInfo: Protocol.Target.TargetInfo + ): Promise<CDPSession> { + return await this._createSession(targetInfo, false); + } + + /** + * @internal + */ + getPendingProtocolErrors(): Error[] { + const result: Error[] = []; + result.push(...this.#callbacks.getPendingProtocolErrors()); + for (const session of this.#sessions.values()) { + result.push(...session.getPendingProtocolErrors()); + } + return result; + } +} + +/** + * @internal + */ +export function isTargetClosedError(error: Error): boolean { + return error instanceof TargetCloseError; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts new file mode 100644 index 0000000000..db995fb45b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts @@ -0,0 +1,513 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {EventSubscription} from '../common/EventEmitter.js'; +import {debugError, PuppeteerURL} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {DisposableStack} from '../util/disposable.js'; + +/** + * The CoverageEntry class represents one entry of the coverage report. + * @public + */ +export interface CoverageEntry { + /** + * The URL of the style sheet or script. + */ + url: string; + /** + * The content of the style sheet or script. + */ + text: string; + /** + * The covered range as start and end positions. + */ + ranges: Array<{start: number; end: number}>; +} + +/** + * The CoverageEntry class for JavaScript + * @public + */ +export interface JSCoverageEntry extends CoverageEntry { + /** + * Raw V8 script coverage entry. + */ + rawScriptCoverage?: Protocol.Profiler.ScriptCoverage; +} + +/** + * Set of configurable options for JS coverage. + * @public + */ +export interface JSCoverageOptions { + /** + * Whether to reset coverage on every navigation. + */ + resetOnNavigation?: boolean; + /** + * Whether anonymous scripts generated by the page should be reported. + */ + reportAnonymousScripts?: boolean; + /** + * Whether the result includes raw V8 script coverage entries. + */ + includeRawScriptCoverage?: boolean; + /** + * Whether to collect coverage information at the block level. + * If true, coverage will be collected at the block level (this is the default). + * If false, coverage will be collected at the function level. + */ + useBlockCoverage?: boolean; +} + +/** + * Set of configurable options for CSS coverage. + * @public + */ +export interface CSSCoverageOptions { + /** + * Whether to reset coverage on every navigation. + */ + resetOnNavigation?: boolean; +} + +/** + * The Coverage class provides methods to gather information about parts of + * JavaScript and CSS that were used by the page. + * + * @remarks + * To output coverage in a form consumable by {@link https://github.com/istanbuljs | Istanbul}, + * see {@link https://github.com/istanbuljs/puppeteer-to-istanbul | puppeteer-to-istanbul}. + * + * @example + * An example of using JavaScript and CSS coverage to get percentage of initially + * executed code: + * + * ```ts + * // Enable both JavaScript and CSS coverage + * await Promise.all([ + * page.coverage.startJSCoverage(), + * page.coverage.startCSSCoverage(), + * ]); + * // Navigate to page + * await page.goto('https://example.com'); + * // Disable both JavaScript and CSS coverage + * const [jsCoverage, cssCoverage] = await Promise.all([ + * page.coverage.stopJSCoverage(), + * page.coverage.stopCSSCoverage(), + * ]); + * let totalBytes = 0; + * let usedBytes = 0; + * const coverage = [...jsCoverage, ...cssCoverage]; + * for (const entry of coverage) { + * totalBytes += entry.text.length; + * for (const range of entry.ranges) usedBytes += range.end - range.start - 1; + * } + * console.log(`Bytes used: ${(usedBytes / totalBytes) * 100}%`); + * ``` + * + * @public + */ +export class Coverage { + #jsCoverage: JSCoverage; + #cssCoverage: CSSCoverage; + + constructor(client: CDPSession) { + this.#jsCoverage = new JSCoverage(client); + this.#cssCoverage = new CSSCoverage(client); + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#jsCoverage.updateClient(client); + this.#cssCoverage.updateClient(client); + } + + /** + * @param options - Set of configurable options for coverage defaults to + * `resetOnNavigation : true, reportAnonymousScripts : false,` + * `includeRawScriptCoverage : false, useBlockCoverage : true` + * @returns Promise that resolves when coverage is started. + * + * @remarks + * Anonymous scripts are ones that don't have an associated url. These are + * scripts that are dynamically created on the page using `eval` or + * `new Function`. If `reportAnonymousScripts` is set to `true`, anonymous + * scripts URL will start with `debugger://VM` (unless a magic //# sourceURL + * comment is present, in which case that will the be URL). + */ + async startJSCoverage(options: JSCoverageOptions = {}): Promise<void> { + return await this.#jsCoverage.start(options); + } + + /** + * Promise that resolves to the array of coverage reports for + * all scripts. + * + * @remarks + * JavaScript Coverage doesn't include anonymous scripts by default. + * However, scripts with sourceURLs are reported. + */ + async stopJSCoverage(): Promise<JSCoverageEntry[]> { + return await this.#jsCoverage.stop(); + } + + /** + * @param options - Set of configurable options for coverage, defaults to + * `resetOnNavigation : true` + * @returns Promise that resolves when coverage is started. + */ + async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> { + return await this.#cssCoverage.start(options); + } + + /** + * Promise that resolves to the array of coverage reports + * for all stylesheets. + * + * @remarks + * CSS Coverage doesn't include dynamically injected style tags + * without sourceURLs. + */ + async stopCSSCoverage(): Promise<CoverageEntry[]> { + return await this.#cssCoverage.stop(); + } +} + +/** + * @public + */ +export class JSCoverage { + #client: CDPSession; + #enabled = false; + #scriptURLs = new Map<string, string>(); + #scriptSources = new Map<string, string>(); + #subscriptions?: DisposableStack; + #resetOnNavigation = false; + #reportAnonymousScripts = false; + #includeRawScriptCoverage = false; + + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + } + + async start( + options: { + resetOnNavigation?: boolean; + reportAnonymousScripts?: boolean; + includeRawScriptCoverage?: boolean; + useBlockCoverage?: boolean; + } = {} + ): Promise<void> { + assert(!this.#enabled, 'JSCoverage is already enabled'); + const { + resetOnNavigation = true, + reportAnonymousScripts = false, + includeRawScriptCoverage = false, + useBlockCoverage = true, + } = options; + this.#resetOnNavigation = resetOnNavigation; + this.#reportAnonymousScripts = reportAnonymousScripts; + this.#includeRawScriptCoverage = includeRawScriptCoverage; + this.#enabled = true; + this.#scriptURLs.clear(); + this.#scriptSources.clear(); + this.#subscriptions = new DisposableStack(); + this.#subscriptions.use( + new EventSubscription( + this.#client, + 'Debugger.scriptParsed', + this.#onScriptParsed.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + this.#client, + 'Runtime.executionContextsCleared', + this.#onExecutionContextsCleared.bind(this) + ) + ); + await Promise.all([ + this.#client.send('Profiler.enable'), + this.#client.send('Profiler.startPreciseCoverage', { + callCount: this.#includeRawScriptCoverage, + detailed: useBlockCoverage, + }), + this.#client.send('Debugger.enable'), + this.#client.send('Debugger.setSkipAllPauses', {skip: true}), + ]); + } + + #onExecutionContextsCleared(): void { + if (!this.#resetOnNavigation) { + return; + } + this.#scriptURLs.clear(); + this.#scriptSources.clear(); + } + + async #onScriptParsed( + event: Protocol.Debugger.ScriptParsedEvent + ): Promise<void> { + // Ignore puppeteer-injected scripts + if (PuppeteerURL.isPuppeteerURL(event.url)) { + return; + } + // Ignore other anonymous scripts unless the reportAnonymousScripts option is true. + if (!event.url && !this.#reportAnonymousScripts) { + return; + } + try { + const response = await this.#client.send('Debugger.getScriptSource', { + scriptId: event.scriptId, + }); + this.#scriptURLs.set(event.scriptId, event.url); + this.#scriptSources.set(event.scriptId, response.scriptSource); + } catch (error) { + // This might happen if the page has already navigated away. + debugError(error); + } + } + + async stop(): Promise<JSCoverageEntry[]> { + assert(this.#enabled, 'JSCoverage is not enabled'); + this.#enabled = false; + + const result = await Promise.all([ + this.#client.send('Profiler.takePreciseCoverage'), + this.#client.send('Profiler.stopPreciseCoverage'), + this.#client.send('Profiler.disable'), + this.#client.send('Debugger.disable'), + ]); + + this.#subscriptions?.dispose(); + + const coverage = []; + const profileResponse = result[0]; + + for (const entry of profileResponse.result) { + let url = this.#scriptURLs.get(entry.scriptId); + if (!url && this.#reportAnonymousScripts) { + url = 'debugger://VM' + entry.scriptId; + } + const text = this.#scriptSources.get(entry.scriptId); + if (text === undefined || url === undefined) { + continue; + } + const flattenRanges = []; + for (const func of entry.functions) { + flattenRanges.push(...func.ranges); + } + const ranges = convertToDisjointRanges(flattenRanges); + if (!this.#includeRawScriptCoverage) { + coverage.push({url, ranges, text}); + } else { + coverage.push({url, ranges, text, rawScriptCoverage: entry}); + } + } + return coverage; + } +} + +/** + * @public + */ +export class CSSCoverage { + #client: CDPSession; + #enabled = false; + #stylesheetURLs = new Map<string, string>(); + #stylesheetSources = new Map<string, string>(); + #eventListeners?: DisposableStack; + #resetOnNavigation = false; + + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + } + + async start(options: {resetOnNavigation?: boolean} = {}): Promise<void> { + assert(!this.#enabled, 'CSSCoverage is already enabled'); + const {resetOnNavigation = true} = options; + this.#resetOnNavigation = resetOnNavigation; + this.#enabled = true; + this.#stylesheetURLs.clear(); + this.#stylesheetSources.clear(); + this.#eventListeners = new DisposableStack(); + this.#eventListeners.use( + new EventSubscription( + this.#client, + 'CSS.styleSheetAdded', + this.#onStyleSheet.bind(this) + ) + ); + this.#eventListeners.use( + new EventSubscription( + this.#client, + 'Runtime.executionContextsCleared', + this.#onExecutionContextsCleared.bind(this) + ) + ); + await Promise.all([ + this.#client.send('DOM.enable'), + this.#client.send('CSS.enable'), + this.#client.send('CSS.startRuleUsageTracking'), + ]); + } + + #onExecutionContextsCleared(): void { + if (!this.#resetOnNavigation) { + return; + } + this.#stylesheetURLs.clear(); + this.#stylesheetSources.clear(); + } + + async #onStyleSheet(event: Protocol.CSS.StyleSheetAddedEvent): Promise<void> { + const header = event.header; + // Ignore anonymous scripts + if (!header.sourceURL) { + return; + } + try { + const response = await this.#client.send('CSS.getStyleSheetText', { + styleSheetId: header.styleSheetId, + }); + this.#stylesheetURLs.set(header.styleSheetId, header.sourceURL); + this.#stylesheetSources.set(header.styleSheetId, response.text); + } catch (error) { + // This might happen if the page has already navigated away. + debugError(error); + } + } + + async stop(): Promise<CoverageEntry[]> { + assert(this.#enabled, 'CSSCoverage is not enabled'); + this.#enabled = false; + const ruleTrackingResponse = await this.#client.send( + 'CSS.stopRuleUsageTracking' + ); + await Promise.all([ + this.#client.send('CSS.disable'), + this.#client.send('DOM.disable'), + ]); + this.#eventListeners?.dispose(); + + // aggregate by styleSheetId + const styleSheetIdToCoverage = new Map(); + for (const entry of ruleTrackingResponse.ruleUsage) { + let ranges = styleSheetIdToCoverage.get(entry.styleSheetId); + if (!ranges) { + ranges = []; + styleSheetIdToCoverage.set(entry.styleSheetId, ranges); + } + ranges.push({ + startOffset: entry.startOffset, + endOffset: entry.endOffset, + count: entry.used ? 1 : 0, + }); + } + + const coverage: CoverageEntry[] = []; + for (const styleSheetId of this.#stylesheetURLs.keys()) { + const url = this.#stylesheetURLs.get(styleSheetId); + assert( + typeof url !== 'undefined', + `Stylesheet URL is undefined (styleSheetId=${styleSheetId})` + ); + const text = this.#stylesheetSources.get(styleSheetId); + assert( + typeof text !== 'undefined', + `Stylesheet text is undefined (styleSheetId=${styleSheetId})` + ); + const ranges = convertToDisjointRanges( + styleSheetIdToCoverage.get(styleSheetId) || [] + ); + coverage.push({url, ranges, text}); + } + + return coverage; + } +} + +function convertToDisjointRanges( + nestedRanges: Array<{startOffset: number; endOffset: number; count: number}> +): Array<{start: number; end: number}> { + const points = []; + for (const range of nestedRanges) { + points.push({offset: range.startOffset, type: 0, range}); + points.push({offset: range.endOffset, type: 1, range}); + } + // Sort points to form a valid parenthesis sequence. + points.sort((a, b) => { + // Sort with increasing offsets. + if (a.offset !== b.offset) { + return a.offset - b.offset; + } + // All "end" points should go before "start" points. + if (a.type !== b.type) { + return b.type - a.type; + } + const aLength = a.range.endOffset - a.range.startOffset; + const bLength = b.range.endOffset - b.range.startOffset; + // For two "start" points, the one with longer range goes first. + if (a.type === 0) { + return bLength - aLength; + } + // For two "end" points, the one with shorter range goes first. + return aLength - bLength; + }); + + const hitCountStack = []; + const results: Array<{ + start: number; + end: number; + }> = []; + let lastOffset = 0; + // Run scanning line to intersect all ranges. + for (const point of points) { + if ( + hitCountStack.length && + lastOffset < point.offset && + hitCountStack[hitCountStack.length - 1]! > 0 + ) { + const lastResult = results[results.length - 1]; + if (lastResult && lastResult.end === lastOffset) { + lastResult.end = point.offset; + } else { + results.push({start: lastOffset, end: point.offset}); + } + } + lastOffset = point.offset; + if (point.type === 0) { + hitCountStack.push(point.range.count); + } else { + hitCountStack.pop(); + } + } + // Filter out empty ranges. + return results.filter(range => { + return range.end - range.start > 0; + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts new file mode 100644 index 0000000000..7d75e97eaf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts @@ -0,0 +1,471 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import type {CDPSessionEvents} from '../api/CDPSession.js'; +import {TimeoutError} from '../common/Errors.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {TimeoutSettings} from '../common/TimeoutSettings.js'; + +import { + DeviceRequestPrompt, + DeviceRequestPromptDevice, + DeviceRequestPromptManager, +} from './DeviceRequestPrompt.js'; + +class MockCDPSession extends EventEmitter<CDPSessionEvents> { + async send(): Promise<any> {} + connection() { + return undefined; + } + async detach() {} + id() { + return '1'; + } + parentSession() { + return undefined; + } +} + +describe('DeviceRequestPrompt', function () { + describe('waitForDevicePrompt', function () { + it('should return prompt', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt] = await Promise.all([ + manager.waitForDevicePrompt(), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt).toBeTruthy(); + }); + + it('should respect timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + await expect( + manager.waitForDevicePrompt({timeout: 1}) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should respect default timeout when there is no custom timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + timeoutSettings.setDefaultTimeout(1); + await expect(manager.waitForDevicePrompt()).rejects.toBeInstanceOf( + TimeoutError + ); + }); + + it('should prioritize exact timeout over default timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + timeoutSettings.setDefaultTimeout(0); + await expect( + manager.waitForDevicePrompt({timeout: 1}) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should work with no timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt] = await Promise.all([ + manager.waitForDevicePrompt({timeout: 0}), + (async () => { + await new Promise(resolve => { + setTimeout(resolve, 50); + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt).toBeTruthy(); + }); + + it('should return the same prompt when there are many watchdogs simultaneously', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt1, prompt2] = await Promise.all([ + manager.waitForDevicePrompt(), + manager.waitForDevicePrompt(), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt1 === prompt2).toBeTruthy(); + }); + + it('should listen and shortcut when there are no watchdogs', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(manager).toBeTruthy(); + }); + }); + + describe('DeviceRequestPrompt.devices', function () { + it('lists devices as they arrive', function () { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(prompt.devices).toHaveLength(0); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + expect(prompt.devices).toHaveLength(1); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + expect(prompt.devices).toHaveLength(2); + expect(prompt.devices[0]).toBeInstanceOf(DeviceRequestPromptDevice); + expect(prompt.devices[1]).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('does not list devices from events of another prompt', function () { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(prompt.devices).toHaveLength(0); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '88888888888888888888888888888888', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + expect(prompt.devices).toHaveLength(0); + }); + }); + + describe('DeviceRequestPrompt.waitForDevice', function () { + it('should return first matching device', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return first matching device from already known devices', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + + const device = await prompt.waitForDevice(({name}) => { + return name.includes('1'); + }); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return device in the devices list', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(prompt.devices).toContain(device); + }); + + it('should respect timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should respect default timeout when there is no custom timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + timeoutSettings.setDefaultTimeout(1); + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should prioritize exact timeout over default timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + timeoutSettings.setDefaultTimeout(0); + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should work with no timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice( + ({name}) => { + return name.includes('1'); + }, + {timeout: 0} + ), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return same device from multiple watchdogs', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device1, device2] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device1 === device2).toBeTruthy(); + }); + }); + + describe('DeviceRequestPrompt.select', function () { + it('should succeed with listed device', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + await prompt.select(device); + }); + + it('should error for device not listed in devices', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + await expect( + prompt.select(new DeviceRequestPromptDevice('11111111', 'Device 1')) + ).rejects.toThrowError('Cannot select unknown device!'); + }); + + it('should fail when selecting prompt twice', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + await prompt.select(device); + await expect(prompt.select(device)).rejects.toThrowError( + 'Cannot select DeviceRequestPrompt which is already handled!' + ); + }); + }); + + describe('DeviceRequestPrompt.cancel', function () { + it('should succeed on first call', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + await prompt.cancel(); + }); + + it('should fail when canceling prompt twice', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + await prompt.cancel(); + await expect(prompt.cancel()).rejects.toThrowError( + 'Cannot cancel DeviceRequestPrompt which is already handled!' + ); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts new file mode 100644 index 0000000000..f5bd73bf72 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts @@ -0,0 +1,280 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {WaitTimeoutOptions} from '../api/Page.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +/** + * Device in a request prompt. + * + * @public + */ +export class DeviceRequestPromptDevice { + /** + * Device id during a prompt. + */ + id: string; + + /** + * Device name as it appears in a prompt. + */ + name: string; + + /** + * @internal + */ + constructor(id: string, name: string) { + this.id = id; + this.name = name; + } +} + +/** + * Device request prompts let you respond to the page requesting for a device + * through an API like WebBluetooth. + * + * @remarks + * `DeviceRequestPrompt` instances are returned via the + * {@link Page.waitForDevicePrompt} method. + * + * @example + * + * ```ts + * const [deviceRequest] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + * + * @public + */ +export class DeviceRequestPrompt { + #client: CDPSession | null; + #timeoutSettings: TimeoutSettings; + #id: string; + #handled = false; + #updateDevicesHandle = this.#updateDevices.bind(this); + #waitForDevicePromises = new Set<{ + filter: (device: DeviceRequestPromptDevice) => boolean; + promise: Deferred<DeviceRequestPromptDevice>; + }>(); + + /** + * Current list of selectable devices. + */ + devices: DeviceRequestPromptDevice[] = []; + + /** + * @internal + */ + constructor( + client: CDPSession, + timeoutSettings: TimeoutSettings, + firstEvent: Protocol.DeviceAccess.DeviceRequestPromptedEvent + ) { + this.#client = client; + this.#timeoutSettings = timeoutSettings; + this.#id = firstEvent.id; + + this.#client.on( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#client.on('Target.detachedFromTarget', () => { + this.#client = null; + }); + + this.#updateDevices(firstEvent); + } + + #updateDevices(event: Protocol.DeviceAccess.DeviceRequestPromptedEvent) { + if (event.id !== this.#id) { + return; + } + + for (const rawDevice of event.devices) { + if ( + this.devices.some(device => { + return device.id === rawDevice.id; + }) + ) { + continue; + } + + const newDevice = new DeviceRequestPromptDevice( + rawDevice.id, + rawDevice.name + ); + this.devices.push(newDevice); + + for (const waitForDevicePromise of this.#waitForDevicePromises) { + if (waitForDevicePromise.filter(newDevice)) { + waitForDevicePromise.promise.resolve(newDevice); + } + } + } + } + + /** + * Resolve to the first device in the prompt matching a filter. + */ + async waitForDevice( + filter: (device: DeviceRequestPromptDevice) => boolean, + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPromptDevice> { + for (const device of this.devices) { + if (filter(device)) { + return device; + } + } + + const {timeout = this.#timeoutSettings.timeout()} = options; + const deferred = Deferred.create<DeviceRequestPromptDevice>({ + message: `Waiting for \`DeviceRequestPromptDevice\` failed: ${timeout}ms exceeded`, + timeout, + }); + const handle = {filter, promise: deferred}; + this.#waitForDevicePromises.add(handle); + try { + return await deferred.valueOrThrow(); + } finally { + this.#waitForDevicePromises.delete(handle); + } + } + + /** + * Select a device in the prompt's list. + */ + async select(device: DeviceRequestPromptDevice): Promise<void> { + assert( + this.#client !== null, + 'Cannot select device through detached session!' + ); + assert(this.devices.includes(device), 'Cannot select unknown device!'); + assert( + !this.#handled, + 'Cannot select DeviceRequestPrompt which is already handled!' + ); + this.#client.off( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#handled = true; + return await this.#client.send('DeviceAccess.selectPrompt', { + id: this.#id, + deviceId: device.id, + }); + } + + /** + * Cancel the prompt. + */ + async cancel(): Promise<void> { + assert( + this.#client !== null, + 'Cannot cancel prompt through detached session!' + ); + assert( + !this.#handled, + 'Cannot cancel DeviceRequestPrompt which is already handled!' + ); + this.#client.off( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#handled = true; + return await this.#client.send('DeviceAccess.cancelPrompt', {id: this.#id}); + } +} + +/** + * @internal + */ +export class DeviceRequestPromptManager { + #client: CDPSession | null; + #timeoutSettings: TimeoutSettings; + #deviceRequestPrompDeferreds = new Set<Deferred<DeviceRequestPrompt>>(); + + /** + * @internal + */ + constructor(client: CDPSession, timeoutSettings: TimeoutSettings) { + this.#client = client; + this.#timeoutSettings = timeoutSettings; + + this.#client.on('DeviceAccess.deviceRequestPrompted', event => { + this.#onDeviceRequestPrompted(event); + }); + this.#client.on('Target.detachedFromTarget', () => { + this.#client = null; + }); + } + + /** + * Wait for device prompt created by an action like calling WebBluetooth's + * requestDevice. + */ + async waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPrompt> { + assert( + this.#client !== null, + 'Cannot wait for device prompt through detached session!' + ); + const needsEnable = this.#deviceRequestPrompDeferreds.size === 0; + let enablePromise: Promise<void> | undefined; + if (needsEnable) { + enablePromise = this.#client.send('DeviceAccess.enable'); + } + + const {timeout = this.#timeoutSettings.timeout()} = options; + const deferred = Deferred.create<DeviceRequestPrompt>({ + message: `Waiting for \`DeviceRequestPrompt\` failed: ${timeout}ms exceeded`, + timeout, + }); + this.#deviceRequestPrompDeferreds.add(deferred); + + try { + const [result] = await Promise.all([ + deferred.valueOrThrow(), + enablePromise, + ]); + return result; + } finally { + this.#deviceRequestPrompDeferreds.delete(deferred); + } + } + + /** + * @internal + */ + #onDeviceRequestPrompted( + event: Protocol.DeviceAccess.DeviceRequestPromptedEvent + ) { + if (!this.#deviceRequestPrompDeferreds.size) { + return; + } + + assert(this.#client !== null); + const devicePrompt = new DeviceRequestPrompt( + this.#client, + this.#timeoutSettings, + event + ); + for (const promise of this.#deviceRequestPrompDeferreds) { + promise.resolve(devicePrompt); + } + this.#deviceRequestPrompDeferreds.clear(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts new file mode 100644 index 0000000000..fe8fffbcad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {Dialog} from '../api/Dialog.js'; + +/** + * @internal + */ +export class CdpDialog extends Dialog { + #client: CDPSession; + + constructor( + client: CDPSession, + type: Protocol.Page.DialogType, + message: string, + defaultValue = '' + ) { + super(type, message, defaultValue); + this.#client = client; + } + + override async handle(options: { + accept: boolean; + text?: string; + }): Promise<void> { + await this.#client.send('Page.handleJavaScriptDialog', { + accept: options.accept, + promptText: options.text, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts new file mode 100644 index 0000000000..a47d546a87 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Path from 'path'; + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {ElementHandle, type AutofillData} from '../api/ElementHandle.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {throwIfDisposed} from '../util/decorators.js'; + +import type {CdpFrame} from './Frame.js'; +import type {FrameManager} from './FrameManager.js'; +import type {IsolatedWorld} from './IsolatedWorld.js'; +import {CdpJSHandle} from './JSHandle.js'; + +/** + * The CdpElementHandle extends ElementHandle now to keep compatibility + * with `instanceof` because of that we need to have methods for + * CdpJSHandle to in this implementation as well. + * + * @internal + */ +export class CdpElementHandle< + ElementType extends Node = Element, +> extends ElementHandle<ElementType> { + protected declare readonly handle: CdpJSHandle<ElementType>; + + constructor( + world: IsolatedWorld, + remoteObject: Protocol.Runtime.RemoteObject + ) { + super(new CdpJSHandle(world, remoteObject)); + } + + override get realm(): IsolatedWorld { + return this.handle.realm; + } + + get client(): CDPSession { + return this.handle.client; + } + + override remoteObject(): Protocol.Runtime.RemoteObject { + return this.handle.remoteObject(); + } + + get #frameManager(): FrameManager { + return this.frame._frameManager; + } + + override get frame(): CdpFrame { + return this.realm.environment as CdpFrame; + } + + override async contentFrame( + this: ElementHandle<HTMLIFrameElement> + ): Promise<CdpFrame>; + + @throwIfDisposed() + override async contentFrame(): Promise<CdpFrame | null> { + const nodeInfo = await this.client.send('DOM.describeNode', { + objectId: this.id, + }); + if (typeof nodeInfo.node.frameId !== 'string') { + return null; + } + return this.#frameManager.frame(nodeInfo.node.frameId); + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async scrollIntoView( + this: CdpElementHandle<Element> + ): Promise<void> { + await this.assertConnectedElement(); + try { + await this.client.send('DOM.scrollIntoViewIfNeeded', { + objectId: this.id, + }); + } catch (error) { + debugError(error); + // Fallback to Element.scrollIntoView if DOM.scrollIntoViewIfNeeded is not supported + await super.scrollIntoView(); + } + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async uploadFile( + this: CdpElementHandle<HTMLInputElement>, + ...filePaths: string[] + ): Promise<void> { + const isMultiple = await this.evaluate(element => { + return element.multiple; + }); + assert( + filePaths.length <= 1 || isMultiple, + 'Multiple file uploads only work with <input type=file multiple>' + ); + + // Locate all files and confirm that they exist. + let path: typeof Path; + try { + path = await import('path'); + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + `JSHandle#uploadFile can only be used in Node-like environments.` + ); + } + throw error; + } + const files = filePaths.map(filePath => { + if (path.win32.isAbsolute(filePath) || path.posix.isAbsolute(filePath)) { + return filePath; + } else { + return path.resolve(filePath); + } + }); + + /** + * The zero-length array is a special case, it seems that + * DOM.setFileInputFiles does not actually update the files in that case, so + * the solution is to eval the element value to a new FileList directly. + */ + if (files.length === 0) { + // XXX: These events should converted to trusted events. Perhaps do this + // in `DOM.setFileInputFiles`? + await this.evaluate(element => { + element.files = new DataTransfer().files; + + // Dispatch events for this case because it should behave akin to a user action. + element.dispatchEvent( + new Event('input', {bubbles: true, composed: true}) + ); + element.dispatchEvent(new Event('change', {bubbles: true})); + }); + return; + } + + const { + node: {backendNodeId}, + } = await this.client.send('DOM.describeNode', { + objectId: this.id, + }); + await this.client.send('DOM.setFileInputFiles', { + objectId: this.id, + files, + backendNodeId, + }); + } + + @throwIfDisposed() + override async autofill(data: AutofillData): Promise<void> { + const nodeInfo = await this.client.send('DOM.describeNode', { + objectId: this.handle.id, + }); + const fieldId = nodeInfo.node.backendNodeId; + const frameId = this.frame._id; + await this.client.send('Autofill.trigger', { + fieldId, + frameId, + card: data.creditCard, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts new file mode 100644 index 0000000000..8598967fe7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts @@ -0,0 +1,554 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; +import type {GeolocationOptions, MediaFeature} from '../api/Page.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import {assert} from '../util/assert.js'; +import {invokeAtMostOnceForArguments} from '../util/decorators.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +interface ViewportState { + viewport?: Viewport; + active: boolean; +} + +interface IdleOverridesState { + overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }; + active: boolean; +} + +interface TimezoneState { + timezoneId?: string; + active: boolean; +} + +interface VisionDeficiencyState { + visionDeficiency?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']; + active: boolean; +} + +interface CpuThrottlingState { + factor?: number; + active: boolean; +} + +interface MediaFeaturesState { + mediaFeatures?: MediaFeature[]; + active: boolean; +} + +interface MediaTypeState { + type?: string; + active: boolean; +} + +interface GeoLocationState { + geoLocation?: GeolocationOptions; + active: boolean; +} + +interface DefaultBackgroundColorState { + color?: Protocol.DOM.RGBA; + active: boolean; +} + +interface JavascriptEnabledState { + javaScriptEnabled: boolean; + active: boolean; +} + +/** + * @internal + */ +export interface ClientProvider { + clients(): CDPSession[]; + registerState(state: EmulatedState<any>): void; +} + +/** + * @internal + */ +export class EmulatedState<T extends {active: boolean}> { + #state: T; + #clientProvider: ClientProvider; + #updater: (client: CDPSession, state: T) => Promise<void>; + + constructor( + initialState: T, + clientProvider: ClientProvider, + updater: (client: CDPSession, state: T) => Promise<void> + ) { + this.#state = initialState; + this.#clientProvider = clientProvider; + this.#updater = updater; + this.#clientProvider.registerState(this); + } + + async setState(state: T): Promise<void> { + this.#state = state; + await this.sync(); + } + + get state(): T { + return this.#state; + } + + async sync(): Promise<void> { + await Promise.all( + this.#clientProvider.clients().map(client => { + return this.#updater(client, this.#state); + }) + ); + } +} + +/** + * @internal + */ +export class EmulationManager { + #client: CDPSession; + + #emulatingMobile = false; + #hasTouch = false; + + #states: Array<EmulatedState<any>> = []; + + #viewportState = new EmulatedState<ViewportState>( + { + active: false, + }, + this, + this.#applyViewport + ); + #idleOverridesState = new EmulatedState<IdleOverridesState>( + { + active: false, + }, + this, + this.#emulateIdleState + ); + #timezoneState = new EmulatedState<TimezoneState>( + { + active: false, + }, + this, + this.#emulateTimezone + ); + #visionDeficiencyState = new EmulatedState<VisionDeficiencyState>( + { + active: false, + }, + this, + this.#emulateVisionDeficiency + ); + #cpuThrottlingState = new EmulatedState<CpuThrottlingState>( + { + active: false, + }, + this, + this.#emulateCpuThrottling + ); + #mediaFeaturesState = new EmulatedState<MediaFeaturesState>( + { + active: false, + }, + this, + this.#emulateMediaFeatures + ); + #mediaTypeState = new EmulatedState<MediaTypeState>( + { + active: false, + }, + this, + this.#emulateMediaType + ); + #geoLocationState = new EmulatedState<GeoLocationState>( + { + active: false, + }, + this, + this.#setGeolocation + ); + #defaultBackgroundColorState = new EmulatedState<DefaultBackgroundColorState>( + { + active: false, + }, + this, + this.#setDefaultBackgroundColor + ); + #javascriptEnabledState = new EmulatedState<JavascriptEnabledState>( + { + javaScriptEnabled: true, + active: false, + }, + this, + this.#setJavaScriptEnabled + ); + + #secondaryClients = new Set<CDPSession>(); + + constructor(client: CDPSession) { + this.#client = client; + } + + updateClient(client: CDPSession): void { + this.#client = client; + this.#secondaryClients.delete(client); + } + + registerState(state: EmulatedState<any>): void { + this.#states.push(state); + } + + clients(): CDPSession[] { + return [this.#client, ...Array.from(this.#secondaryClients)]; + } + + async registerSpeculativeSession(client: CDPSession): Promise<void> { + this.#secondaryClients.add(client); + client.once(CDPSessionEvent.Disconnected, () => { + this.#secondaryClients.delete(client); + }); + // We don't await here because we want to register all state changes before + // the target is unpaused. + void Promise.all( + this.#states.map(s => { + return s.sync().catch(debugError); + }) + ); + } + + get javascriptEnabled(): boolean { + return this.#javascriptEnabledState.state.javaScriptEnabled; + } + + async emulateViewport(viewport: Viewport): Promise<boolean> { + await this.#viewportState.setState({ + viewport, + active: true, + }); + + const mobile = viewport.isMobile || false; + const hasTouch = viewport.hasTouch || false; + const reloadNeeded = + this.#emulatingMobile !== mobile || this.#hasTouch !== hasTouch; + this.#emulatingMobile = mobile; + this.#hasTouch = hasTouch; + + return reloadNeeded; + } + + @invokeAtMostOnceForArguments + async #applyViewport( + client: CDPSession, + viewportState: ViewportState + ): Promise<void> { + if (!viewportState.viewport) { + return; + } + const {viewport} = viewportState; + const mobile = viewport.isMobile || false; + const width = viewport.width; + const height = viewport.height; + const deviceScaleFactor = viewport.deviceScaleFactor ?? 1; + const screenOrientation: Protocol.Emulation.ScreenOrientation = + viewport.isLandscape + ? {angle: 90, type: 'landscapePrimary'} + : {angle: 0, type: 'portraitPrimary'}; + const hasTouch = viewport.hasTouch || false; + + await Promise.all([ + client.send('Emulation.setDeviceMetricsOverride', { + mobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }), + client.send('Emulation.setTouchEmulationEnabled', { + enabled: hasTouch, + }), + ]); + } + + async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + await this.#idleOverridesState.setState({ + active: true, + overrides, + }); + } + + @invokeAtMostOnceForArguments + async #emulateIdleState( + client: CDPSession, + idleStateState: IdleOverridesState + ): Promise<void> { + if (!idleStateState.active) { + return; + } + if (idleStateState.overrides) { + await client.send('Emulation.setIdleOverride', { + isUserActive: idleStateState.overrides.isUserActive, + isScreenUnlocked: idleStateState.overrides.isScreenUnlocked, + }); + } else { + await client.send('Emulation.clearIdleOverride'); + } + } + + @invokeAtMostOnceForArguments + async #emulateTimezone( + client: CDPSession, + timezoneState: TimezoneState + ): Promise<void> { + if (!timezoneState.active) { + return; + } + try { + await client.send('Emulation.setTimezoneOverride', { + timezoneId: timezoneState.timezoneId || '', + }); + } catch (error) { + if (isErrorLike(error) && error.message.includes('Invalid timezone')) { + throw new Error(`Invalid timezone ID: ${timezoneState.timezoneId}`); + } + throw error; + } + } + + async emulateTimezone(timezoneId?: string): Promise<void> { + await this.#timezoneState.setState({ + timezoneId, + active: true, + }); + } + + @invokeAtMostOnceForArguments + async #emulateVisionDeficiency( + client: CDPSession, + visionDeficiency: VisionDeficiencyState + ): Promise<void> { + if (!visionDeficiency.active) { + return; + } + await client.send('Emulation.setEmulatedVisionDeficiency', { + type: visionDeficiency.visionDeficiency || 'none', + }); + } + + async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + const visionDeficiencies = new Set< + Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + >([ + 'none', + 'achromatopsia', + 'blurredVision', + 'deuteranopia', + 'protanopia', + 'tritanopia', + ]); + assert( + !type || visionDeficiencies.has(type), + `Unsupported vision deficiency: ${type}` + ); + await this.#visionDeficiencyState.setState({ + active: true, + visionDeficiency: type, + }); + } + + @invokeAtMostOnceForArguments + async #emulateCpuThrottling( + client: CDPSession, + state: CpuThrottlingState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setCPUThrottlingRate', { + rate: state.factor ?? 1, + }); + } + + async emulateCPUThrottling(factor: number | null): Promise<void> { + assert( + factor === null || factor >= 1, + 'Throttling rate should be greater or equal to 1' + ); + await this.#cpuThrottlingState.setState({ + active: true, + factor: factor ?? undefined, + }); + } + + @invokeAtMostOnceForArguments + async #emulateMediaFeatures( + client: CDPSession, + state: MediaFeaturesState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setEmulatedMedia', { + features: state.mediaFeatures, + }); + } + + async emulateMediaFeatures(features?: MediaFeature[]): Promise<void> { + if (Array.isArray(features)) { + for (const mediaFeature of features) { + const name = mediaFeature.name; + assert( + /^(?:prefers-(?:color-scheme|reduced-motion)|color-gamut)$/.test( + name + ), + 'Unsupported media feature: ' + name + ); + } + } + await this.#mediaFeaturesState.setState({ + active: true, + mediaFeatures: features, + }); + } + + @invokeAtMostOnceForArguments + async #emulateMediaType( + client: CDPSession, + state: MediaTypeState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setEmulatedMedia', { + media: state.type || '', + }); + } + + async emulateMediaType(type?: string): Promise<void> { + assert( + type === 'screen' || + type === 'print' || + (type ?? undefined) === undefined, + 'Unsupported media type: ' + type + ); + await this.#mediaTypeState.setState({ + type, + active: true, + }); + } + + @invokeAtMostOnceForArguments + async #setGeolocation( + client: CDPSession, + state: GeoLocationState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send( + 'Emulation.setGeolocationOverride', + state.geoLocation + ? { + longitude: state.geoLocation.longitude, + latitude: state.geoLocation.latitude, + accuracy: state.geoLocation.accuracy, + } + : undefined + ); + } + + async setGeolocation(options: GeolocationOptions): Promise<void> { + const {longitude, latitude, accuracy = 0} = options; + if (longitude < -180 || longitude > 180) { + throw new Error( + `Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.` + ); + } + if (latitude < -90 || latitude > 90) { + throw new Error( + `Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.` + ); + } + if (accuracy < 0) { + throw new Error( + `Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.` + ); + } + await this.#geoLocationState.setState({ + active: true, + geoLocation: { + longitude, + latitude, + accuracy, + }, + }); + } + + @invokeAtMostOnceForArguments + async #setDefaultBackgroundColor( + client: CDPSession, + state: DefaultBackgroundColorState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setDefaultBackgroundColorOverride', { + color: state.color, + }); + } + + /** + * Resets default white background + */ + async resetDefaultBackgroundColor(): Promise<void> { + await this.#defaultBackgroundColorState.setState({ + active: true, + color: undefined, + }); + } + + /** + * Hides default white background + */ + async setTransparentBackgroundColor(): Promise<void> { + await this.#defaultBackgroundColorState.setState({ + active: true, + color: {r: 0, g: 0, b: 0, a: 0}, + }); + } + + @invokeAtMostOnceForArguments + async #setJavaScriptEnabled( + client: CDPSession, + state: JavascriptEnabledState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setScriptExecutionDisabled', { + value: !state.javaScriptEnabled, + }); + } + + async setJavaScriptEnabled(enabled: boolean): Promise<void> { + await this.#javascriptEnabledState.setState({ + active: true, + javaScriptEnabled: enabled, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts new file mode 100644 index 0000000000..6efdf8ac76 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts @@ -0,0 +1,392 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import {LazyArg} from '../common/LazyArg.js'; +import {scriptInjector} from '../common/ScriptInjector.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import { + PuppeteerURL, + SOURCE_URL_REGEX, + getSourcePuppeteerURLIfAvailable, + getSourceUrlComment, + isString, +} from '../common/util.js'; +import type PuppeteerUtil from '../injected/injected.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; +import {stringifyFunction} from '../util/Function.js'; + +import {ARIAQueryHandler} from './AriaQueryHandler.js'; +import {Binding} from './Binding.js'; +import {CdpElementHandle} from './ElementHandle.js'; +import type {IsolatedWorld} from './IsolatedWorld.js'; +import {CdpJSHandle} from './JSHandle.js'; +import {createEvaluationError, valueFromRemoteObject} from './utils.js'; + +/** + * @internal + */ +export class ExecutionContext { + _client: CDPSession; + _world: IsolatedWorld; + _contextId: number; + _contextName?: string; + + constructor( + client: CDPSession, + contextPayload: Protocol.Runtime.ExecutionContextDescription, + world: IsolatedWorld + ) { + this._client = client; + this._world = world; + this._contextId = contextPayload.id; + if (contextPayload.name) { + this._contextName = contextPayload.name; + } + } + + #bindingsInstalled = false; + #puppeteerUtil?: Promise<JSHandle<PuppeteerUtil>>; + get puppeteerUtil(): Promise<JSHandle<PuppeteerUtil>> { + let promise = Promise.resolve() as Promise<unknown>; + if (!this.#bindingsInstalled) { + promise = Promise.all([ + this.#installGlobalBinding( + new Binding( + '__ariaQuerySelector', + ARIAQueryHandler.queryOne as (...args: unknown[]) => unknown + ) + ), + this.#installGlobalBinding( + new Binding('__ariaQuerySelectorAll', (async ( + element: ElementHandle<Node>, + selector: string + ): Promise<JSHandle<Node[]>> => { + const results = ARIAQueryHandler.queryAll(element, selector); + return await element.realm.evaluateHandle( + (...elements) => { + return elements; + }, + ...(await AsyncIterableUtil.collect(results)) + ); + }) as (...args: unknown[]) => unknown) + ), + ]); + this.#bindingsInstalled = true; + } + scriptInjector.inject(script => { + if (this.#puppeteerUtil) { + void this.#puppeteerUtil.then(handle => { + void handle.dispose(); + }); + } + this.#puppeteerUtil = promise.then(() => { + return this.evaluateHandle(script) as Promise<JSHandle<PuppeteerUtil>>; + }); + }, !this.#puppeteerUtil); + return this.#puppeteerUtil as Promise<JSHandle<PuppeteerUtil>>; + } + + async #installGlobalBinding(binding: Binding) { + try { + if (this._world) { + this._world._bindings.set(binding.name, binding); + await this._world._addBindingToContext(this, binding.name); + } + } catch { + // If the binding cannot be added, then either the browser doesn't support + // bindings (e.g. Firefox) or the context is broken. Either breakage is + // okay, so we ignore the error. + } + } + + /** + * Evaluates the given function. + * + * @example + * + * ```ts + * const executionContext = await page.mainFrame().executionContext(); + * const result = await executionContext.evaluate(() => Promise.resolve(8 * 7))* ; + * console.log(result); // prints "56" + * ``` + * + * @example + * A string can also be passed in instead of a function: + * + * ```ts + * console.log(await executionContext.evaluate('1 + 2')); // prints "3" + * ``` + * + * @example + * Handles can also be passed as `args`. They resolve to their referenced object: + * + * ```ts + * const oneHandle = await executionContext.evaluateHandle(() => 1); + * const twoHandle = await executionContext.evaluateHandle(() => 2); + * const result = await executionContext.evaluate( + * (a, b) => a + b, + * oneHandle, + * twoHandle + * ); + * await oneHandle.dispose(); + * await twoHandle.dispose(); + * console.log(result); // prints '3'. + * ``` + * + * @param pageFunction - The function to evaluate. + * @param args - Additional arguments to pass into the function. + * @returns The result of evaluating the function. If the result is an object, + * a vanilla object containing the serializable properties of the result is + * returned. + */ + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return await this.#evaluate(true, pageFunction, ...args); + } + + /** + * Evaluates the given function. + * + * Unlike {@link ExecutionContext.evaluate | evaluate}, this method returns a + * handle to the result of the function. + * + * This method may be better suited if the object cannot be serialized (e.g. + * `Map`) and requires further manipulation. + * + * @example + * + * ```ts + * const context = await page.mainFrame().executionContext(); + * const handle: JSHandle<typeof globalThis> = await context.evaluateHandle( + * () => Promise.resolve(self) + * ); + * ``` + * + * @example + * A string can also be passed in instead of a function. + * + * ```ts + * const handle: JSHandle<number> = await context.evaluateHandle('1 + 2'); + * ``` + * + * @example + * Handles can also be passed as `args`. They resolve to their referenced object: + * + * ```ts + * const bodyHandle: ElementHandle<HTMLBodyElement> = + * await context.evaluateHandle(() => { + * return document.body; + * }); + * const stringHandle: JSHandle<string> = await context.evaluateHandle( + * body => body.innerHTML, + * body + * ); + * console.log(await stringHandle.jsonValue()); // prints body's innerHTML + * // Always dispose your garbage! :) + * await bodyHandle.dispose(); + * await stringHandle.dispose(); + * ``` + * + * @param pageFunction - The function to evaluate. + * @param args - Additional arguments to pass into the function. + * @returns A {@link JSHandle | handle} to the result of evaluating the + * function. If the result is a `Node`, then this will return an + * {@link ElementHandle | element handle}. + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return await this.#evaluate(false, pageFunction, ...args); + } + + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: true, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: false, + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: boolean, + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> { + const sourceUrlComment = getSourceUrlComment( + getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ?? + PuppeteerURL.INTERNAL_URL + ); + + if (isString(pageFunction)) { + const contextId = this._contextId; + const expression = pageFunction; + const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) + ? expression + : `${expression}\n${sourceUrlComment}\n`; + + const {exceptionDetails, result: remoteObject} = await this._client + .send('Runtime.evaluate', { + expression: expressionWithSourceUrl, + contextId, + returnByValue, + awaitPromise: true, + userGesture: true, + }) + .catch(rewriteError); + + if (exceptionDetails) { + throw createEvaluationError(exceptionDetails); + } + + return returnByValue + ? valueFromRemoteObject(remoteObject) + : createCdpHandle(this._world, remoteObject); + } + + const functionDeclaration = stringifyFunction(pageFunction); + const functionDeclarationWithSourceUrl = SOURCE_URL_REGEX.test( + functionDeclaration + ) + ? functionDeclaration + : `${functionDeclaration}\n${sourceUrlComment}\n`; + let callFunctionOnPromise; + try { + callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', { + functionDeclaration: functionDeclarationWithSourceUrl, + executionContextId: this._contextId, + arguments: args.length + ? await Promise.all(args.map(convertArgument.bind(this))) + : [], + returnByValue, + awaitPromise: true, + userGesture: true, + }); + } catch (error) { + if ( + error instanceof TypeError && + error.message.startsWith('Converting circular structure to JSON') + ) { + error.message += ' Recursive objects are not allowed.'; + } + throw error; + } + const {exceptionDetails, result: remoteObject} = + await callFunctionOnPromise.catch(rewriteError); + if (exceptionDetails) { + throw createEvaluationError(exceptionDetails); + } + return returnByValue + ? valueFromRemoteObject(remoteObject) + : createCdpHandle(this._world, remoteObject); + + async function convertArgument( + this: ExecutionContext, + arg: unknown + ): Promise<Protocol.Runtime.CallArgument> { + if (arg instanceof LazyArg) { + arg = await arg.get(this); + } + if (typeof arg === 'bigint') { + // eslint-disable-line valid-typeof + return {unserializableValue: `${arg.toString()}n`}; + } + if (Object.is(arg, -0)) { + return {unserializableValue: '-0'}; + } + if (Object.is(arg, Infinity)) { + return {unserializableValue: 'Infinity'}; + } + if (Object.is(arg, -Infinity)) { + return {unserializableValue: '-Infinity'}; + } + if (Object.is(arg, NaN)) { + return {unserializableValue: 'NaN'}; + } + const objectHandle = + arg && (arg instanceof CdpJSHandle || arg instanceof CdpElementHandle) + ? arg + : null; + if (objectHandle) { + if (objectHandle.realm !== this._world) { + throw new Error( + 'JSHandles can be evaluated only in the context they were created!' + ); + } + if (objectHandle.disposed) { + throw new Error('JSHandle is disposed!'); + } + if (objectHandle.remoteObject().unserializableValue) { + return { + unserializableValue: + objectHandle.remoteObject().unserializableValue, + }; + } + if (!objectHandle.remoteObject().objectId) { + return {value: objectHandle.remoteObject().value}; + } + return {objectId: objectHandle.remoteObject().objectId}; + } + return {value: arg}; + } + } +} + +const rewriteError = (error: Error): Protocol.Runtime.EvaluateResponse => { + if (error.message.includes('Object reference chain is too long')) { + return {result: {type: 'undefined'}}; + } + if (error.message.includes("Object couldn't be returned by value")) { + return {result: {type: 'undefined'}}; + } + + if ( + error.message.endsWith('Cannot find context with specified id') || + error.message.endsWith('Inspected target navigated or closed') + ) { + throw new Error( + 'Execution context was destroyed, most likely because of a navigation.' + ); + } + throw error; +}; + +/** + * @internal + */ +export function createCdpHandle( + realm: IsolatedWorld, + remoteObject: Protocol.Runtime.RemoteObject +): JSHandle | ElementHandle<Node> { + if (remoteObject.subtype === 'node') { + return new CdpElementHandle(realm, remoteObject); + } + return new CdpJSHandle(realm, remoteObject); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts new file mode 100644 index 0000000000..0ef09a0093 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {TargetFilterCallback} from '../api/Browser.js'; +import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {CdpCDPSession} from './CDPSession.js'; +import type {Connection} from './Connection.js'; +import type {CdpTarget} from './Target.js'; +import { + type TargetFactory, + TargetManagerEvent, + type TargetManager, + type TargetManagerEvents, +} from './TargetManager.js'; + +/** + * FirefoxTargetManager implements target management using + * `Target.setDiscoverTargets` without using auto-attach. It, therefore, creates + * targets that lazily establish their CDP sessions. + * + * Although the approach is potentially flaky, there is no other way for Firefox + * because Firefox's CDP implementation does not support auto-attach. + * + * Firefox does not support targetInfoChanged and detachedFromTarget events: + * + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1610855 + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1636979 + * @internal + */ +export class FirefoxTargetManager + extends EventEmitter<TargetManagerEvents> + implements TargetManager +{ + #connection: Connection; + /** + * Keeps track of the following events: 'Target.targetCreated', + * 'Target.targetDestroyed'. + * + * A target becomes discovered when 'Target.targetCreated' is received. + * A target is removed from this map once 'Target.targetDestroyed' is + * received. + * + * `targetFilterCallback` has no effect on this map. + */ + #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>(); + /** + * Keeps track of targets that were created via 'Target.targetCreated' + * and which one are not filtered out by `targetFilterCallback`. + * + * The target is removed from here once it's been destroyed. + */ + #availableTargetsByTargetId = new Map<string, CdpTarget>(); + /** + * Tracks which sessions attach to which target. + */ + #availableTargetsBySessionId = new Map<string, CdpTarget>(); + #targetFilterCallback: TargetFilterCallback | undefined; + #targetFactory: TargetFactory; + + #attachedToTargetListenersBySession = new WeakMap< + CDPSession | Connection, + (event: Protocol.Target.AttachedToTargetEvent) => Promise<void> + >(); + + #initializeDeferred = Deferred.create<void>(); + #targetsIdsForInit = new Set<string>(); + + constructor( + connection: Connection, + targetFactory: TargetFactory, + targetFilterCallback?: TargetFilterCallback + ) { + super(); + this.#connection = connection; + this.#targetFilterCallback = targetFilterCallback; + this.#targetFactory = targetFactory; + + this.#connection.on('Target.targetCreated', this.#onTargetCreated); + this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.on( + CDPSessionEvent.SessionDetached, + this.#onSessionDetached + ); + this.setupAttachmentListeners(this.#connection); + } + + setupAttachmentListeners(session: CDPSession | Connection): void { + const listener = (event: Protocol.Target.AttachedToTargetEvent) => { + return this.#onAttachedToTarget(session, event); + }; + assert(!this.#attachedToTargetListenersBySession.has(session)); + this.#attachedToTargetListenersBySession.set(session, listener); + session.on('Target.attachedToTarget', listener); + } + + #onSessionDetached = (session: CDPSession) => { + this.removeSessionListeners(session); + this.#availableTargetsBySessionId.delete(session.id()); + }; + + removeSessionListeners(session: CDPSession): void { + if (this.#attachedToTargetListenersBySession.has(session)) { + session.off( + 'Target.attachedToTarget', + this.#attachedToTargetListenersBySession.get(session)! + ); + this.#attachedToTargetListenersBySession.delete(session); + } + } + + getAvailableTargets(): ReadonlyMap<string, CdpTarget> { + return this.#availableTargetsByTargetId; + } + + dispose(): void { + this.#connection.off('Target.targetCreated', this.#onTargetCreated); + this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); + } + + async initialize(): Promise<void> { + await this.#connection.send('Target.setDiscoverTargets', { + discover: true, + filter: [{}], + }); + this.#targetsIdsForInit = new Set(this.#discoveredTargetsByTargetId.keys()); + await this.#initializeDeferred.valueOrThrow(); + } + + #onTargetCreated = async ( + event: Protocol.Target.TargetCreatedEvent + ): Promise<void> => { + if (this.#discoveredTargetsByTargetId.has(event.targetInfo.targetId)) { + return; + } + + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + if (event.targetInfo.type === 'browser' && event.targetInfo.attached) { + const target = this.#targetFactory(event.targetInfo, undefined); + target._initialize(); + this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target); + this.#finishInitializationIfReady(target._targetId); + return; + } + + const target = this.#targetFactory(event.targetInfo, undefined); + if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) { + this.#finishInitializationIfReady(event.targetInfo.targetId); + return; + } + target._initialize(); + this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target); + this.emit(TargetManagerEvent.TargetAvailable, target); + this.#finishInitializationIfReady(target._targetId); + }; + + #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent): void => { + this.#discoveredTargetsByTargetId.delete(event.targetId); + this.#finishInitializationIfReady(event.targetId); + const target = this.#availableTargetsByTargetId.get(event.targetId); + if (target) { + this.emit(TargetManagerEvent.TargetGone, target); + this.#availableTargetsByTargetId.delete(event.targetId); + } + }; + + #onAttachedToTarget = async ( + parentSession: Connection | CDPSession, + event: Protocol.Target.AttachedToTargetEvent + ) => { + const targetInfo = event.targetInfo; + const session = this.#connection.session(event.sessionId); + if (!session) { + throw new Error(`Session ${event.sessionId} was not created.`); + } + + const target = this.#availableTargetsByTargetId.get(targetInfo.targetId); + + assert(target, `Target ${targetInfo.targetId} is missing`); + + (session as CdpCDPSession)._setTarget(target); + this.setupAttachmentListeners(session); + + this.#availableTargetsBySessionId.set( + session.id(), + this.#availableTargetsByTargetId.get(targetInfo.targetId)! + ); + + parentSession.emit(CDPSessionEvent.Ready, session); + }; + + #finishInitializationIfReady(targetId: string): void { + this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeDeferred.resolve(); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts new file mode 100644 index 0000000000..844120d7ff --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {Frame, FrameEvent, throwIfDetached} from '../api/Frame.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {WaitTimeoutOptions} from '../api/Page.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import type { + DeviceRequestPrompt, + DeviceRequestPromptManager, +} from './DeviceRequestPrompt.js'; +import type {FrameManager} from './FrameManager.js'; +import {IsolatedWorld} from './IsolatedWorld.js'; +import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import { + LifecycleWatcher, + type PuppeteerLifeCycleEvent, +} from './LifecycleWatcher.js'; +import type {CdpPage} from './Page.js'; + +/** + * @internal + */ +export class CdpFrame extends Frame { + #url = ''; + #detached = false; + #client!: CDPSession; + + _frameManager: FrameManager; + override _id: string; + _loaderId = ''; + _lifecycleEvents = new Set<string>(); + override _parentId?: string; + + constructor( + frameManager: FrameManager, + frameId: string, + parentFrameId: string | undefined, + client: CDPSession + ) { + super(); + this._frameManager = frameManager; + this.#url = ''; + this._id = frameId; + this._parentId = parentFrameId; + this.#detached = false; + + this._loaderId = ''; + + this.updateClient(client); + + this.on(FrameEvent.FrameSwappedByActivation, () => { + // Emulate loading process for swapped frames. + this._onLoadingStarted(); + this._onLoadingStopped(); + }); + } + + /** + * This is used internally in DevTools. + * + * @internal + */ + _client(): CDPSession { + return this.#client; + } + + /** + * Updates the frame ID with the new ID. This happens when the main frame is + * replaced by a different frame. + */ + updateId(id: string): void { + this._id = id; + } + + updateClient(client: CDPSession, keepWorlds = false): void { + this.#client = client; + if (!keepWorlds) { + // Clear the current contexts on previous world instances. + if (this.worlds) { + this.worlds[MAIN_WORLD].clearContext(); + this.worlds[PUPPETEER_WORLD].clearContext(); + } + this.worlds = { + [MAIN_WORLD]: new IsolatedWorld( + this, + this._frameManager.timeoutSettings + ), + [PUPPETEER_WORLD]: new IsolatedWorld( + this, + this._frameManager.timeoutSettings + ), + }; + } else { + this.worlds[MAIN_WORLD].frameUpdated(); + this.worlds[PUPPETEER_WORLD].frameUpdated(); + } + } + + override page(): CdpPage { + return this._frameManager.page(); + } + + override isOOPFrame(): boolean { + return this.#client !== this._frameManager.client; + } + + @throwIfDetached + override async goto( + url: string, + options: { + referer?: string; + referrerPolicy?: string; + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + const { + referer = this._frameManager.networkManager.extraHTTPHeaders()['referer'], + referrerPolicy = this._frameManager.networkManager.extraHTTPHeaders()[ + 'referer-policy' + ], + waitUntil = ['load'], + timeout = this._frameManager.timeoutSettings.navigationTimeout(), + } = options; + + let ensureNewDocumentNavigation = false; + const watcher = new LifecycleWatcher( + this._frameManager.networkManager, + this, + waitUntil, + timeout + ); + let error = await Deferred.race([ + navigate( + this.#client, + url, + referer, + referrerPolicy as Protocol.Page.ReferrerPolicy, + this._id + ), + watcher.terminationPromise(), + ]); + if (!error) { + error = await Deferred.race([ + watcher.terminationPromise(), + ensureNewDocumentNavigation + ? watcher.newDocumentNavigationPromise() + : watcher.sameDocumentNavigationPromise(), + ]); + } + + try { + if (error) { + throw error; + } + return await watcher.navigationResponse(); + } finally { + watcher.dispose(); + } + + async function navigate( + client: CDPSession, + url: string, + referrer: string | undefined, + referrerPolicy: Protocol.Page.ReferrerPolicy | undefined, + frameId: string + ): Promise<Error | null> { + try { + const response = await client.send('Page.navigate', { + url, + referrer, + frameId, + referrerPolicy, + }); + ensureNewDocumentNavigation = !!response.loaderId; + if (response.errorText === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE') { + return null; + } + return response.errorText + ? new Error(`${response.errorText} at ${url}`) + : null; + } catch (error) { + if (isErrorLike(error)) { + return error; + } + throw error; + } + } + } + + @throwIfDetached + override async waitForNavigation( + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + const { + waitUntil = ['load'], + timeout = this._frameManager.timeoutSettings.navigationTimeout(), + } = options; + const watcher = new LifecycleWatcher( + this._frameManager.networkManager, + this, + waitUntil, + timeout + ); + const error = await Deferred.race([ + watcher.terminationPromise(), + watcher.sameDocumentNavigationPromise(), + watcher.newDocumentNavigationPromise(), + ]); + try { + if (error) { + throw error; + } + return await watcher.navigationResponse(); + } finally { + watcher.dispose(); + } + } + + override get client(): CDPSession { + return this.#client; + } + + override mainRealm(): IsolatedWorld { + return this.worlds[MAIN_WORLD]; + } + + override isolatedRealm(): IsolatedWorld { + return this.worlds[PUPPETEER_WORLD]; + } + + @throwIfDetached + override async setContent( + html: string, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<void> { + const { + waitUntil = ['load'], + timeout = this._frameManager.timeoutSettings.navigationTimeout(), + } = options; + + // We rely upon the fact that document.open() will reset frame lifecycle with "init" + // lifecycle event. @see https://crrev.com/608658 + await this.setFrameContent(html); + + const watcher = new LifecycleWatcher( + this._frameManager.networkManager, + this, + waitUntil, + timeout + ); + const error = await Deferred.race<void | Error | undefined>([ + watcher.terminationPromise(), + watcher.lifecyclePromise(), + ]); + watcher.dispose(); + if (error) { + throw error; + } + } + + override url(): string { + return this.#url; + } + + override parentFrame(): CdpFrame | null { + return this._frameManager._frameTree.parentFrame(this._id) || null; + } + + override childFrames(): CdpFrame[] { + return this._frameManager._frameTree.childFrames(this._id); + } + + #deviceRequestPromptManager(): DeviceRequestPromptManager { + const rootFrame = this.page().mainFrame(); + if (this.isOOPFrame() || rootFrame === null) { + return this._frameManager._deviceRequestPromptManager(this.#client); + } else { + return rootFrame._frameManager._deviceRequestPromptManager(this.#client); + } + } + + @throwIfDetached + override async waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPrompt> { + return await this.#deviceRequestPromptManager().waitForDevicePrompt( + options + ); + } + + _navigated(framePayload: Protocol.Page.Frame): void { + this._name = framePayload.name; + this.#url = `${framePayload.url}${framePayload.urlFragment || ''}`; + } + + _navigatedWithinDocument(url: string): void { + this.#url = url; + } + + _onLifecycleEvent(loaderId: string, name: string): void { + if (name === 'init') { + this._loaderId = loaderId; + this._lifecycleEvents.clear(); + } + this._lifecycleEvents.add(name); + } + + _onLoadingStopped(): void { + this._lifecycleEvents.add('DOMContentLoaded'); + this._lifecycleEvents.add('load'); + } + + _onLoadingStarted(): void { + this._hasStartedLoading = true; + } + + override get detached(): boolean { + return this.#detached; + } + + [disposeSymbol](): void { + if (this.#detached) { + return; + } + this.#detached = true; + this.worlds[MAIN_WORLD][disposeSymbol](); + this.worlds[PUPPETEER_WORLD][disposeSymbol](); + } + + exposeFunction(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts new file mode 100644 index 0000000000..48ed9ac2f5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts @@ -0,0 +1,551 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; +import {FrameEvent} from '../api/Frame.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import {debugError, PuppeteerURL, UTILITY_WORLD_NAME} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {CdpCDPSession} from './CDPSession.js'; +import {isTargetClosedError} from './Connection.js'; +import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {CdpFrame} from './Frame.js'; +import type {FrameManagerEvents} from './FrameManagerEvents.js'; +import {FrameManagerEvent} from './FrameManagerEvents.js'; +import {FrameTree} from './FrameTree.js'; +import type {IsolatedWorld} from './IsolatedWorld.js'; +import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {NetworkManager} from './NetworkManager.js'; +import type {CdpPage} from './Page.js'; +import type {CdpTarget} from './Target.js'; + +const TIME_FOR_WAITING_FOR_SWAP = 100; // ms. + +/** + * A frame manager manages the frames for a given {@link Page | page}. + * + * @internal + */ +export class FrameManager extends EventEmitter<FrameManagerEvents> { + #page: CdpPage; + #networkManager: NetworkManager; + #timeoutSettings: TimeoutSettings; + #contextIdToContext = new Map<string, ExecutionContext>(); + #isolatedWorlds = new Set<string>(); + #client: CDPSession; + + _frameTree = new FrameTree<CdpFrame>(); + + /** + * Set of frame IDs stored to indicate if a frame has received a + * frameNavigated event so that frame tree responses could be ignored as the + * frameNavigated event usually contains the latest information. + */ + #frameNavigatedReceived = new Set<string>(); + + #deviceRequestPromptManagerMap = new WeakMap< + CDPSession, + DeviceRequestPromptManager + >(); + + #frameTreeHandled?: Deferred<void>; + + get timeoutSettings(): TimeoutSettings { + return this.#timeoutSettings; + } + + get networkManager(): NetworkManager { + return this.#networkManager; + } + + get client(): CDPSession { + return this.#client; + } + + constructor( + client: CDPSession, + page: CdpPage, + ignoreHTTPSErrors: boolean, + timeoutSettings: TimeoutSettings + ) { + super(); + this.#client = client; + this.#page = page; + this.#networkManager = new NetworkManager(ignoreHTTPSErrors, this); + this.#timeoutSettings = timeoutSettings; + this.setupEventListeners(this.#client); + client.once(CDPSessionEvent.Disconnected, () => { + this.#onClientDisconnect().catch(debugError); + }); + } + + /** + * Called when the frame's client is disconnected. We don't know if the + * disconnect means that the frame is removed or if it will be replaced by a + * new frame. Therefore, we wait for a swap event. + */ + async #onClientDisconnect() { + const mainFrame = this._frameTree.getMainFrame(); + if (!mainFrame) { + return; + } + for (const child of mainFrame.childFrames()) { + this.#removeFramesRecursively(child); + } + const swapped = Deferred.create<void>({ + timeout: TIME_FOR_WAITING_FOR_SWAP, + message: 'Frame was not swapped', + }); + mainFrame.once(FrameEvent.FrameSwappedByActivation, () => { + swapped.resolve(); + }); + try { + await swapped.valueOrThrow(); + } catch (err) { + this.#removeFramesRecursively(mainFrame); + } + } + + /** + * When the main frame is replaced by another main frame, + * we maintain the main frame object identity while updating + * its frame tree and ID. + */ + async swapFrameTree(client: CDPSession): Promise<void> { + this.#onExecutionContextsCleared(this.#client); + + this.#client = client; + assert( + this.#client instanceof CdpCDPSession, + 'CDPSession is not an instance of CDPSessionImpl.' + ); + const frame = this._frameTree.getMainFrame(); + if (frame) { + this.#frameNavigatedReceived.add(this.#client._target()._targetId); + this._frameTree.removeFrame(frame); + frame.updateId(this.#client._target()._targetId); + frame.mainRealm().clearContext(); + frame.isolatedRealm().clearContext(); + this._frameTree.addFrame(frame); + frame.updateClient(client, true); + } + this.setupEventListeners(client); + client.once(CDPSessionEvent.Disconnected, () => { + this.#onClientDisconnect().catch(debugError); + }); + await this.initialize(client); + await this.#networkManager.addClient(client); + if (frame) { + frame.emit(FrameEvent.FrameSwappedByActivation, undefined); + } + } + + async registerSpeculativeSession(client: CdpCDPSession): Promise<void> { + await this.#networkManager.addClient(client); + } + + private setupEventListeners(session: CDPSession) { + session.on('Page.frameAttached', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameAttached(session, event.frameId, event.parentFrameId); + }); + session.on('Page.frameNavigated', async event => { + this.#frameNavigatedReceived.add(event.frame.id); + await this.#frameTreeHandled?.valueOrThrow(); + void this.#onFrameNavigated(event.frame, event.type); + }); + session.on('Page.navigatedWithinDocument', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameNavigatedWithinDocument(event.frameId, event.url); + }); + session.on( + 'Page.frameDetached', + async (event: Protocol.Page.FrameDetachedEvent) => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameDetached( + event.frameId, + event.reason as Protocol.Page.FrameDetachedEventReason + ); + } + ); + session.on('Page.frameStartedLoading', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameStartedLoading(event.frameId); + }); + session.on('Page.frameStoppedLoading', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameStoppedLoading(event.frameId); + }); + session.on('Runtime.executionContextCreated', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onExecutionContextCreated(event.context, session); + }); + session.on('Runtime.executionContextDestroyed', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onExecutionContextDestroyed(event.executionContextId, session); + }); + session.on('Runtime.executionContextsCleared', async () => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onExecutionContextsCleared(session); + }); + session.on('Page.lifecycleEvent', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onLifecycleEvent(event); + }); + } + + async initialize(client: CDPSession): Promise<void> { + try { + this.#frameTreeHandled?.resolve(); + this.#frameTreeHandled = Deferred.create(); + // We need to schedule all these commands while the target is paused, + // therefore, it needs to happen synchroniously. At the same time we + // should not start processing execution context and frame events before + // we received the initial information about the frame tree. + await Promise.all([ + this.#networkManager.addClient(client), + client.send('Page.enable'), + client.send('Page.getFrameTree').then(({frameTree}) => { + this.#handleFrameTree(client, frameTree); + this.#frameTreeHandled?.resolve(); + }), + client.send('Page.setLifecycleEventsEnabled', {enabled: true}), + client.send('Runtime.enable').then(() => { + return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME); + }), + ]); + } catch (error) { + this.#frameTreeHandled?.resolve(); + // The target might have been closed before the initialization finished. + if (isErrorLike(error) && isTargetClosedError(error)) { + return; + } + + throw error; + } + } + + executionContextById( + contextId: number, + session: CDPSession = this.#client + ): ExecutionContext { + const context = this.getExecutionContextById(contextId, session); + assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId); + return context; + } + + getExecutionContextById( + contextId: number, + session: CDPSession = this.#client + ): ExecutionContext | undefined { + return this.#contextIdToContext.get(`${session.id()}:${contextId}`); + } + + page(): CdpPage { + return this.#page; + } + + mainFrame(): CdpFrame { + const mainFrame = this._frameTree.getMainFrame(); + assert(mainFrame, 'Requesting main frame too early!'); + return mainFrame; + } + + frames(): CdpFrame[] { + return Array.from(this._frameTree.frames()); + } + + frame(frameId: string): CdpFrame | null { + return this._frameTree.getById(frameId) || null; + } + + onAttachedToTarget(target: CdpTarget): void { + if (target._getTargetInfo().type !== 'iframe') { + return; + } + + const frame = this.frame(target._getTargetInfo().targetId); + if (frame) { + frame.updateClient(target._session()!); + } + this.setupEventListeners(target._session()!); + void this.initialize(target._session()!); + } + + _deviceRequestPromptManager(client: CDPSession): DeviceRequestPromptManager { + let manager = this.#deviceRequestPromptManagerMap.get(client); + if (manager === undefined) { + manager = new DeviceRequestPromptManager(client, this.#timeoutSettings); + this.#deviceRequestPromptManagerMap.set(client, manager); + } + return manager; + } + + #onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void { + const frame = this.frame(event.frameId); + if (!frame) { + return; + } + frame._onLifecycleEvent(event.loaderId, event.name); + this.emit(FrameManagerEvent.LifecycleEvent, frame); + frame.emit(FrameEvent.LifecycleEvent, undefined); + } + + #onFrameStartedLoading(frameId: string): void { + const frame = this.frame(frameId); + if (!frame) { + return; + } + frame._onLoadingStarted(); + } + + #onFrameStoppedLoading(frameId: string): void { + const frame = this.frame(frameId); + if (!frame) { + return; + } + frame._onLoadingStopped(); + this.emit(FrameManagerEvent.LifecycleEvent, frame); + frame.emit(FrameEvent.LifecycleEvent, undefined); + } + + #handleFrameTree( + session: CDPSession, + frameTree: Protocol.Page.FrameTree + ): void { + if (frameTree.frame.parentId) { + this.#onFrameAttached( + session, + frameTree.frame.id, + frameTree.frame.parentId + ); + } + if (!this.#frameNavigatedReceived.has(frameTree.frame.id)) { + void this.#onFrameNavigated(frameTree.frame, 'Navigation'); + } else { + this.#frameNavigatedReceived.delete(frameTree.frame.id); + } + + if (!frameTree.childFrames) { + return; + } + + for (const child of frameTree.childFrames) { + this.#handleFrameTree(session, child); + } + } + + #onFrameAttached( + session: CDPSession, + frameId: string, + parentFrameId: string + ): void { + let frame = this.frame(frameId); + if (frame) { + if (session && frame.isOOPFrame()) { + // If an OOP iframes becomes a normal iframe again + // it is first attached to the parent page before + // the target is removed. + frame.updateClient(session); + } + return; + } + + frame = new CdpFrame(this, frameId, parentFrameId, session); + this._frameTree.addFrame(frame); + this.emit(FrameManagerEvent.FrameAttached, frame); + } + + async #onFrameNavigated( + framePayload: Protocol.Page.Frame, + navigationType: Protocol.Page.NavigationType + ): Promise<void> { + const frameId = framePayload.id; + const isMainFrame = !framePayload.parentId; + + let frame = this._frameTree.getById(frameId); + + // Detach all child frames first. + if (frame) { + for (const child of frame.childFrames()) { + this.#removeFramesRecursively(child); + } + } + + // Update or create main frame. + if (isMainFrame) { + if (frame) { + // Update frame id to retain frame identity on cross-process navigation. + this._frameTree.removeFrame(frame); + frame._id = frameId; + } else { + // Initial main frame navigation. + frame = new CdpFrame(this, frameId, undefined, this.#client); + } + this._frameTree.addFrame(frame); + } + + frame = await this._frameTree.waitForFrame(frameId); + frame._navigated(framePayload); + this.emit(FrameManagerEvent.FrameNavigated, frame); + frame.emit(FrameEvent.FrameNavigated, navigationType); + } + + async #createIsolatedWorld(session: CDPSession, name: string): Promise<void> { + const key = `${session.id()}:${name}`; + + if (this.#isolatedWorlds.has(key)) { + return; + } + + await session.send('Page.addScriptToEvaluateOnNewDocument', { + source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`, + worldName: name, + }); + + await Promise.all( + this.frames() + .filter(frame => { + return frame.client === session; + }) + .map(frame => { + // Frames might be removed before we send this, so we don't want to + // throw an error. + return session + .send('Page.createIsolatedWorld', { + frameId: frame._id, + worldName: name, + grantUniveralAccess: true, + }) + .catch(debugError); + }) + ); + + this.#isolatedWorlds.add(key); + } + + #onFrameNavigatedWithinDocument(frameId: string, url: string): void { + const frame = this.frame(frameId); + if (!frame) { + return; + } + frame._navigatedWithinDocument(url); + this.emit(FrameManagerEvent.FrameNavigatedWithinDocument, frame); + frame.emit(FrameEvent.FrameNavigatedWithinDocument, undefined); + this.emit(FrameManagerEvent.FrameNavigated, frame); + frame.emit(FrameEvent.FrameNavigated, 'Navigation'); + } + + #onFrameDetached( + frameId: string, + reason: Protocol.Page.FrameDetachedEventReason + ): void { + const frame = this.frame(frameId); + if (!frame) { + return; + } + switch (reason) { + case 'remove': + // Only remove the frame if the reason for the detached event is + // an actual removement of the frame. + // For frames that become OOP iframes, the reason would be 'swap'. + this.#removeFramesRecursively(frame); + break; + case 'swap': + this.emit(FrameManagerEvent.FrameSwapped, frame); + frame.emit(FrameEvent.FrameSwapped, undefined); + break; + } + } + + #onExecutionContextCreated( + contextPayload: Protocol.Runtime.ExecutionContextDescription, + session: CDPSession + ): void { + const auxData = contextPayload.auxData as {frameId?: string} | undefined; + const frameId = auxData && auxData.frameId; + const frame = typeof frameId === 'string' ? this.frame(frameId) : undefined; + let world: IsolatedWorld | undefined; + if (frame) { + // Only care about execution contexts created for the current session. + if (frame.client !== session) { + return; + } + if (contextPayload.auxData && contextPayload.auxData['isDefault']) { + world = frame.worlds[MAIN_WORLD]; + } else if ( + contextPayload.name === UTILITY_WORLD_NAME && + !frame.worlds[PUPPETEER_WORLD].hasContext() + ) { + // In case of multiple sessions to the same target, there's a race between + // connections so we might end up creating multiple isolated worlds. + // We can use either. + world = frame.worlds[PUPPETEER_WORLD]; + } + } + // If there is no world, the context is not meant to be handled by us. + if (!world) { + return; + } + const context = new ExecutionContext( + frame?.client || this.#client, + contextPayload, + world + ); + if (world) { + world.setContext(context); + } + const key = `${session.id()}:${contextPayload.id}`; + this.#contextIdToContext.set(key, context); + } + + #onExecutionContextDestroyed( + executionContextId: number, + session: CDPSession + ): void { + const key = `${session.id()}:${executionContextId}`; + const context = this.#contextIdToContext.get(key); + if (!context) { + return; + } + this.#contextIdToContext.delete(key); + if (context._world) { + context._world.clearContext(); + } + } + + #onExecutionContextsCleared(session: CDPSession): void { + for (const [key, context] of this.#contextIdToContext.entries()) { + // Make sure to only clear execution contexts that belong + // to the current session. + if (context._client !== session) { + continue; + } + if (context._world) { + context._world.clearContext(); + } + this.#contextIdToContext.delete(key); + } + } + + #removeFramesRecursively(frame: CdpFrame): void { + for (const child of frame.childFrames()) { + this.#removeFramesRecursively(child); + } + frame[disposeSymbol](); + this._frameTree.removeFrame(frame); + this.emit(FrameManagerEvent.FrameDetached, frame); + frame.emit(FrameEvent.FrameDetached, frame); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts new file mode 100644 index 0000000000..645dd86d71 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {EventType} from '../common/EventEmitter.js'; + +import type {CdpFrame} from './Frame.js'; + +/** + * We use symbols to prevent external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace FrameManagerEvent { + export const FrameAttached = Symbol('FrameManager.FrameAttached'); + export const FrameNavigated = Symbol('FrameManager.FrameNavigated'); + export const FrameDetached = Symbol('FrameManager.FrameDetached'); + export const FrameSwapped = Symbol('FrameManager.FrameSwapped'); + export const LifecycleEvent = Symbol('FrameManager.LifecycleEvent'); + export const FrameNavigatedWithinDocument = Symbol( + 'FrameManager.FrameNavigatedWithinDocument' + ); +} + +/** + * @internal + */ +export interface FrameManagerEvents extends Record<EventType, unknown> { + [FrameManagerEvent.FrameAttached]: CdpFrame; + [FrameManagerEvent.FrameNavigated]: CdpFrame; + [FrameManagerEvent.FrameDetached]: CdpFrame; + [FrameManagerEvent.FrameSwapped]: CdpFrame; + [FrameManagerEvent.LifecycleEvent]: CdpFrame; + [FrameManagerEvent.FrameNavigatedWithinDocument]: CdpFrame; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts new file mode 100644 index 0000000000..7ee1b86b5f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Frame} from '../api/Frame.js'; +import {Deferred} from '../util/Deferred.js'; + +/** + * Keeps track of the page frame tree and it's is managed by + * {@link FrameManager}. FrameTree uses frame IDs to reference frame and it + * means that referenced frames might not be in the tree anymore. Thus, the tree + * structure is eventually consistent. + * @internal + */ +export class FrameTree<FrameType extends Frame> { + #frames = new Map<string, FrameType>(); + // frameID -> parentFrameID + #parentIds = new Map<string, string>(); + // frameID -> childFrameIDs + #childIds = new Map<string, Set<string>>(); + #mainFrame?: FrameType; + #waitRequests = new Map<string, Set<Deferred<FrameType>>>(); + + getMainFrame(): FrameType | undefined { + return this.#mainFrame; + } + + getById(frameId: string): FrameType | undefined { + return this.#frames.get(frameId); + } + + /** + * Returns a promise that is resolved once the frame with + * the given ID is added to the tree. + */ + waitForFrame(frameId: string): Promise<FrameType> { + const frame = this.getById(frameId); + if (frame) { + return Promise.resolve(frame); + } + const deferred = Deferred.create<FrameType>(); + const callbacks = + this.#waitRequests.get(frameId) || new Set<Deferred<FrameType>>(); + callbacks.add(deferred); + return deferred.valueOrThrow(); + } + + frames(): FrameType[] { + return Array.from(this.#frames.values()); + } + + addFrame(frame: FrameType): void { + this.#frames.set(frame._id, frame); + if (frame._parentId) { + this.#parentIds.set(frame._id, frame._parentId); + if (!this.#childIds.has(frame._parentId)) { + this.#childIds.set(frame._parentId, new Set()); + } + this.#childIds.get(frame._parentId)!.add(frame._id); + } else if (!this.#mainFrame) { + this.#mainFrame = frame; + } + this.#waitRequests.get(frame._id)?.forEach(request => { + return request.resolve(frame); + }); + } + + removeFrame(frame: FrameType): void { + this.#frames.delete(frame._id); + this.#parentIds.delete(frame._id); + if (frame._parentId) { + this.#childIds.get(frame._parentId)?.delete(frame._id); + } else { + this.#mainFrame = undefined; + } + } + + childFrames(frameId: string): FrameType[] { + const childIds = this.#childIds.get(frameId); + if (!childIds) { + return []; + } + return Array.from(childIds) + .map(id => { + return this.getById(id); + }) + .filter((frame): frame is FrameType => { + return frame !== undefined; + }); + } + + parentFrame(frameId: string): FrameType | undefined { + const parentId = this.#parentIds.get(frameId); + return parentId ? this.getById(parentId) : undefined; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts new file mode 100644 index 0000000000..029e77470b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts @@ -0,0 +1,449 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Frame} from '../api/Frame.js'; +import { + type ContinueRequestOverrides, + type ErrorCode, + headersArray, + HTTPRequest, + InterceptResolutionAction, + type InterceptResolutionState, + type ResourceType, + type ResponseForRequest, + STATUS_TEXTS, +} from '../api/HTTPRequest.js'; +import type {ProtocolError} from '../common/Errors.js'; +import {debugError, isString} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import type {CdpHTTPResponse} from './HTTPResponse.js'; + +/** + * @internal + */ +export class CdpHTTPRequest extends HTTPRequest { + declare _redirectChain: CdpHTTPRequest[]; + declare _response: CdpHTTPResponse | null; + + #client: CDPSession; + #isNavigationRequest: boolean; + #allowInterception: boolean; + #interceptionHandled = false; + #url: string; + #resourceType: ResourceType; + + #method: string; + #hasPostData = false; + #postData?: string; + #headers: Record<string, string> = {}; + #frame: Frame | null; + #continueRequestOverrides: ContinueRequestOverrides; + #responseForRequest: Partial<ResponseForRequest> | null = null; + #abortErrorReason: Protocol.Network.ErrorReason | null = null; + #interceptResolutionState: InterceptResolutionState = { + action: InterceptResolutionAction.None, + }; + #interceptHandlers: Array<() => void | PromiseLike<any>>; + #initiator?: Protocol.Network.Initiator; + + override get client(): CDPSession { + return this.#client; + } + + constructor( + client: CDPSession, + frame: Frame | null, + interceptionId: string | undefined, + allowInterception: boolean, + data: { + /** + * Request identifier. + */ + requestId: Protocol.Network.RequestId; + /** + * Loader identifier. Empty string if the request is fetched from worker. + */ + loaderId?: Protocol.Network.LoaderId; + /** + * URL of the document this request is loaded for. + */ + documentURL?: string; + /** + * Request data. + */ + request: Protocol.Network.Request; + /** + * Request initiator. + */ + initiator?: Protocol.Network.Initiator; + /** + * Type of this resource. + */ + type?: Protocol.Network.ResourceType; + }, + redirectChain: CdpHTTPRequest[] + ) { + super(); + this.#client = client; + this._requestId = data.requestId; + this.#isNavigationRequest = + data.requestId === data.loaderId && data.type === 'Document'; + this._interceptionId = interceptionId; + this.#allowInterception = allowInterception; + this.#url = data.request.url; + this.#resourceType = (data.type || 'other').toLowerCase() as ResourceType; + this.#method = data.request.method; + this.#postData = data.request.postData; + this.#hasPostData = data.request.hasPostData ?? false; + this.#frame = frame; + this._redirectChain = redirectChain; + this.#continueRequestOverrides = {}; + this.#interceptHandlers = []; + this.#initiator = data.initiator; + + for (const [key, value] of Object.entries(data.request.headers)) { + this.#headers[key.toLowerCase()] = value; + } + } + + override url(): string { + return this.#url; + } + + override continueRequestOverrides(): ContinueRequestOverrides { + assert(this.#allowInterception, 'Request Interception is not enabled!'); + return this.#continueRequestOverrides; + } + + override responseForRequest(): Partial<ResponseForRequest> | null { + assert(this.#allowInterception, 'Request Interception is not enabled!'); + return this.#responseForRequest; + } + + override abortErrorReason(): Protocol.Network.ErrorReason | null { + assert(this.#allowInterception, 'Request Interception is not enabled!'); + return this.#abortErrorReason; + } + + override interceptResolutionState(): InterceptResolutionState { + if (!this.#allowInterception) { + return {action: InterceptResolutionAction.Disabled}; + } + if (this.#interceptionHandled) { + return {action: InterceptResolutionAction.AlreadyHandled}; + } + return {...this.#interceptResolutionState}; + } + + override isInterceptResolutionHandled(): boolean { + return this.#interceptionHandled; + } + + enqueueInterceptAction( + pendingHandler: () => void | PromiseLike<unknown> + ): void { + this.#interceptHandlers.push(pendingHandler); + } + + override async finalizeInterceptions(): Promise<void> { + await this.#interceptHandlers.reduce((promiseChain, interceptAction) => { + return promiseChain.then(interceptAction); + }, Promise.resolve()); + const {action} = this.interceptResolutionState(); + switch (action) { + case 'abort': + return await this.#abort(this.#abortErrorReason); + case 'respond': + if (this.#responseForRequest === null) { + throw new Error('Response is missing for the interception'); + } + return await this.#respond(this.#responseForRequest); + case 'continue': + return await this.#continue(this.#continueRequestOverrides); + } + } + + override resourceType(): ResourceType { + return this.#resourceType; + } + + override method(): string { + return this.#method; + } + + override postData(): string | undefined { + return this.#postData; + } + + override hasPostData(): boolean { + return this.#hasPostData; + } + + override async fetchPostData(): Promise<string | undefined> { + try { + const result = await this.#client.send('Network.getRequestPostData', { + requestId: this._requestId, + }); + return result.postData; + } catch (err) { + debugError(err); + return; + } + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override response(): CdpHTTPResponse | null { + return this._response; + } + + override frame(): Frame | null { + return this.#frame; + } + + override isNavigationRequest(): boolean { + return this.#isNavigationRequest; + } + + override initiator(): Protocol.Network.Initiator | undefined { + return this.#initiator; + } + + override redirectChain(): CdpHTTPRequest[] { + return this._redirectChain.slice(); + } + + override failure(): {errorText: string} | null { + if (!this._failureText) { + return null; + } + return { + errorText: this._failureText, + }; + } + + override async continue( + overrides: ContinueRequestOverrides = {}, + priority?: number + ): Promise<void> { + // Request interception is not supported for data: urls. + if (this.#url.startsWith('data:')) { + return; + } + assert(this.#allowInterception, 'Request Interception is not enabled!'); + assert(!this.#interceptionHandled, 'Request is already handled!'); + if (priority === undefined) { + return await this.#continue(overrides); + } + this.#continueRequestOverrides = overrides; + if ( + this.#interceptResolutionState.priority === undefined || + priority > this.#interceptResolutionState.priority + ) { + this.#interceptResolutionState = { + action: InterceptResolutionAction.Continue, + priority, + }; + return; + } + if (priority === this.#interceptResolutionState.priority) { + if ( + this.#interceptResolutionState.action === 'abort' || + this.#interceptResolutionState.action === 'respond' + ) { + return; + } + this.#interceptResolutionState.action = + InterceptResolutionAction.Continue; + } + return; + } + + async #continue(overrides: ContinueRequestOverrides = {}): Promise<void> { + const {url, method, postData, headers} = overrides; + this.#interceptionHandled = true; + + const postDataBinaryBase64 = postData + ? Buffer.from(postData).toString('base64') + : undefined; + + if (this._interceptionId === undefined) { + throw new Error( + 'HTTPRequest is missing _interceptionId needed for Fetch.continueRequest' + ); + } + await this.#client + .send('Fetch.continueRequest', { + requestId: this._interceptionId, + url, + method, + postData: postDataBinaryBase64, + headers: headers ? headersArray(headers) : undefined, + }) + .catch(error => { + this.#interceptionHandled = false; + return handleError(error); + }); + } + + override async respond( + response: Partial<ResponseForRequest>, + priority?: number + ): Promise<void> { + // Mocking responses for dataURL requests is not currently supported. + if (this.#url.startsWith('data:')) { + return; + } + assert(this.#allowInterception, 'Request Interception is not enabled!'); + assert(!this.#interceptionHandled, 'Request is already handled!'); + if (priority === undefined) { + return await this.#respond(response); + } + this.#responseForRequest = response; + if ( + this.#interceptResolutionState.priority === undefined || + priority > this.#interceptResolutionState.priority + ) { + this.#interceptResolutionState = { + action: InterceptResolutionAction.Respond, + priority, + }; + return; + } + if (priority === this.#interceptResolutionState.priority) { + if (this.#interceptResolutionState.action === 'abort') { + return; + } + this.#interceptResolutionState.action = InterceptResolutionAction.Respond; + } + } + + async #respond(response: Partial<ResponseForRequest>): Promise<void> { + this.#interceptionHandled = true; + + const responseBody: Buffer | null = + response.body && isString(response.body) + ? Buffer.from(response.body) + : (response.body as Buffer) || null; + + const responseHeaders: Record<string, string | string[]> = {}; + if (response.headers) { + for (const header of Object.keys(response.headers)) { + const value = response.headers[header]; + + responseHeaders[header.toLowerCase()] = Array.isArray(value) + ? value.map(item => { + return String(item); + }) + : String(value); + } + } + if (response.contentType) { + responseHeaders['content-type'] = response.contentType; + } + if (responseBody && !('content-length' in responseHeaders)) { + responseHeaders['content-length'] = String( + Buffer.byteLength(responseBody) + ); + } + + const status = response.status || 200; + if (this._interceptionId === undefined) { + throw new Error( + 'HTTPRequest is missing _interceptionId needed for Fetch.fulfillRequest' + ); + } + await this.#client + .send('Fetch.fulfillRequest', { + requestId: this._interceptionId, + responseCode: status, + responsePhrase: STATUS_TEXTS[status], + responseHeaders: headersArray(responseHeaders), + body: responseBody ? responseBody.toString('base64') : undefined, + }) + .catch(error => { + this.#interceptionHandled = false; + return handleError(error); + }); + } + + override async abort( + errorCode: ErrorCode = 'failed', + priority?: number + ): Promise<void> { + // Request interception is not supported for data: urls. + if (this.#url.startsWith('data:')) { + return; + } + const errorReason = errorReasons[errorCode]; + assert(errorReason, 'Unknown error code: ' + errorCode); + assert(this.#allowInterception, 'Request Interception is not enabled!'); + assert(!this.#interceptionHandled, 'Request is already handled!'); + if (priority === undefined) { + return await this.#abort(errorReason); + } + this.#abortErrorReason = errorReason; + if ( + this.#interceptResolutionState.priority === undefined || + priority >= this.#interceptResolutionState.priority + ) { + this.#interceptResolutionState = { + action: InterceptResolutionAction.Abort, + priority, + }; + return; + } + } + + async #abort( + errorReason: Protocol.Network.ErrorReason | null + ): Promise<void> { + this.#interceptionHandled = true; + if (this._interceptionId === undefined) { + throw new Error( + 'HTTPRequest is missing _interceptionId needed for Fetch.failRequest' + ); + } + await this.#client + .send('Fetch.failRequest', { + requestId: this._interceptionId, + errorReason: errorReason || 'Failed', + }) + .catch(handleError); + } +} + +const errorReasons: Record<ErrorCode, Protocol.Network.ErrorReason> = { + aborted: 'Aborted', + accessdenied: 'AccessDenied', + addressunreachable: 'AddressUnreachable', + blockedbyclient: 'BlockedByClient', + blockedbyresponse: 'BlockedByResponse', + connectionaborted: 'ConnectionAborted', + connectionclosed: 'ConnectionClosed', + connectionfailed: 'ConnectionFailed', + connectionrefused: 'ConnectionRefused', + connectionreset: 'ConnectionReset', + internetdisconnected: 'InternetDisconnected', + namenotresolved: 'NameNotResolved', + timedout: 'TimedOut', + failed: 'Failed', +} as const; + +async function handleError(error: ProtocolError) { + if (['Invalid header'].includes(error.originalMessage)) { + throw error; + } + // In certain cases, protocol will return error if the request was + // already canceled or the page was closed. We should tolerate these + // errors. + debugError(error); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts new file mode 100644 index 0000000000..2b2264ffd4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Frame} from '../api/Frame.js'; +import {HTTPResponse, type RemoteAddress} from '../api/HTTPResponse.js'; +import {ProtocolError} from '../common/Errors.js'; +import {SecurityDetails} from '../common/SecurityDetails.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {CdpHTTPRequest} from './HTTPRequest.js'; + +/** + * @internal + */ +export class CdpHTTPResponse extends HTTPResponse { + #client: CDPSession; + #request: CdpHTTPRequest; + #contentPromise: Promise<Buffer> | null = null; + #bodyLoadedDeferred = Deferred.create<void, Error>(); + #remoteAddress: RemoteAddress; + #status: number; + #statusText: string; + #url: string; + #fromDiskCache: boolean; + #fromServiceWorker: boolean; + #headers: Record<string, string> = {}; + #securityDetails: SecurityDetails | null; + #timing: Protocol.Network.ResourceTiming | null; + + constructor( + client: CDPSession, + request: CdpHTTPRequest, + responsePayload: Protocol.Network.Response, + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ) { + super(); + this.#client = client; + this.#request = request; + + this.#remoteAddress = { + ip: responsePayload.remoteIPAddress, + port: responsePayload.remotePort, + }; + this.#statusText = + this.#parseStatusTextFromExtraInfo(extraInfo) || + responsePayload.statusText; + this.#url = request.url(); + this.#fromDiskCache = !!responsePayload.fromDiskCache; + this.#fromServiceWorker = !!responsePayload.fromServiceWorker; + + this.#status = extraInfo ? extraInfo.statusCode : responsePayload.status; + const headers = extraInfo ? extraInfo.headers : responsePayload.headers; + for (const [key, value] of Object.entries(headers)) { + this.#headers[key.toLowerCase()] = value; + } + + this.#securityDetails = responsePayload.securityDetails + ? new SecurityDetails(responsePayload.securityDetails) + : null; + this.#timing = responsePayload.timing || null; + } + + #parseStatusTextFromExtraInfo( + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ): string | undefined { + if (!extraInfo || !extraInfo.headersText) { + return; + } + const firstLine = extraInfo.headersText.split('\r', 1)[0]; + if (!firstLine) { + return; + } + const match = firstLine.match(/[^ ]* [^ ]* (.*)/); + if (!match) { + return; + } + const statusText = match[1]; + if (!statusText) { + return; + } + return statusText; + } + + _resolveBody(err?: Error): void { + if (err) { + return this.#bodyLoadedDeferred.reject(err); + } + return this.#bodyLoadedDeferred.resolve(); + } + + override remoteAddress(): RemoteAddress { + return this.#remoteAddress; + } + + override url(): string { + return this.#url; + } + + override status(): number { + return this.#status; + } + + override statusText(): string { + return this.#statusText; + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override securityDetails(): SecurityDetails | null { + return this.#securityDetails; + } + + override timing(): Protocol.Network.ResourceTiming | null { + return this.#timing; + } + + override buffer(): Promise<Buffer> { + if (!this.#contentPromise) { + this.#contentPromise = this.#bodyLoadedDeferred + .valueOrThrow() + .then(async () => { + try { + const response = await this.#client.send( + 'Network.getResponseBody', + { + requestId: this.#request._requestId, + } + ); + return Buffer.from( + response.body, + response.base64Encoded ? 'base64' : 'utf8' + ); + } catch (error) { + if ( + error instanceof ProtocolError && + error.originalMessage === + 'No resource with given identifier found' + ) { + throw new ProtocolError( + 'Could not load body for this request. This might happen if the request is a preflight request.' + ); + } + + throw error; + } + }); + } + return this.#contentPromise; + } + + override request(): CdpHTTPRequest { + return this.#request; + } + + override fromCache(): boolean { + return this.#fromDiskCache || this.#request._fromMemoryCache; + } + + override fromServiceWorker(): boolean { + return this.#fromServiceWorker; + } + + override frame(): Frame | null { + return this.#request.frame(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts new file mode 100644 index 0000000000..9bfafddcf3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts @@ -0,0 +1,604 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Point} from '../api/ElementHandle.js'; +import { + Keyboard, + type KeyDownOptions, + type KeyPressOptions, + Mouse, + MouseButton, + type MouseClickOptions, + type MouseMoveOptions, + type MouseOptions, + type MouseWheelOptions, + Touchscreen, + type KeyboardTypeOptions, +} from '../api/Input.js'; +import { + _keyDefinitions, + type KeyDefinition, + type KeyInput, +} from '../common/USKeyboardLayout.js'; +import {assert} from '../util/assert.js'; + +type KeyDescription = Required< + Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'> +>; + +/** + * @internal + */ +export class CdpKeyboard extends Keyboard { + #client: CDPSession; + #pressedKeys = new Set<string>(); + + _modifiers = 0; + + constructor(client: CDPSession) { + super(); + this.#client = client; + } + + updateClient(client: CDPSession): void { + this.#client = client; + } + + override async down( + key: KeyInput, + options: Readonly<KeyDownOptions> = { + text: undefined, + commands: [], + } + ): Promise<void> { + const description = this.#keyDescriptionForString(key); + + const autoRepeat = this.#pressedKeys.has(description.code); + this.#pressedKeys.add(description.code); + this._modifiers |= this.#modifierBit(description.key); + + const text = options.text === undefined ? description.text : options.text; + await this.#client.send('Input.dispatchKeyEvent', { + type: text ? 'keyDown' : 'rawKeyDown', + modifiers: this._modifiers, + windowsVirtualKeyCode: description.keyCode, + code: description.code, + key: description.key, + text: text, + unmodifiedText: text, + autoRepeat, + location: description.location, + isKeypad: description.location === 3, + commands: options.commands, + }); + } + + #modifierBit(key: string): number { + if (key === 'Alt') { + return 1; + } + if (key === 'Control') { + return 2; + } + if (key === 'Meta') { + return 4; + } + if (key === 'Shift') { + return 8; + } + return 0; + } + + #keyDescriptionForString(keyString: KeyInput): KeyDescription { + const shift = this._modifiers & 8; + const description = { + key: '', + keyCode: 0, + code: '', + text: '', + location: 0, + }; + + const definition = _keyDefinitions[keyString]; + assert(definition, `Unknown key: "${keyString}"`); + + if (definition.key) { + description.key = definition.key; + } + if (shift && definition.shiftKey) { + description.key = definition.shiftKey; + } + + if (definition.keyCode) { + description.keyCode = definition.keyCode; + } + if (shift && definition.shiftKeyCode) { + description.keyCode = definition.shiftKeyCode; + } + + if (definition.code) { + description.code = definition.code; + } + + if (definition.location) { + description.location = definition.location; + } + + if (description.key.length === 1) { + description.text = description.key; + } + + if (definition.text) { + description.text = definition.text; + } + if (shift && definition.shiftText) { + description.text = definition.shiftText; + } + + // if any modifiers besides shift are pressed, no text should be sent + if (this._modifiers & ~8) { + description.text = ''; + } + + return description; + } + + override async up(key: KeyInput): Promise<void> { + const description = this.#keyDescriptionForString(key); + + this._modifiers &= ~this.#modifierBit(description.key); + this.#pressedKeys.delete(description.code); + await this.#client.send('Input.dispatchKeyEvent', { + type: 'keyUp', + modifiers: this._modifiers, + key: description.key, + windowsVirtualKeyCode: description.keyCode, + code: description.code, + location: description.location, + }); + } + + override async sendCharacter(char: string): Promise<void> { + await this.#client.send('Input.insertText', {text: char}); + } + + private charIsKey(char: string): char is KeyInput { + return !!_keyDefinitions[char as KeyInput]; + } + + override async type( + text: string, + options: Readonly<KeyboardTypeOptions> = {} + ): Promise<void> { + const delay = options.delay || undefined; + for (const char of text) { + if (this.charIsKey(char)) { + await this.press(char, {delay}); + } else { + if (delay) { + await new Promise(f => { + return setTimeout(f, delay); + }); + } + await this.sendCharacter(char); + } + } + } + + override async press( + key: KeyInput, + options: Readonly<KeyPressOptions> = {} + ): Promise<void> { + const {delay = null} = options; + await this.down(key, options); + if (delay) { + await new Promise(f => { + return setTimeout(f, options.delay); + }); + } + await this.up(key); + } +} + +/** + * This must follow {@link Protocol.Input.DispatchMouseEventRequest.buttons}. + */ +const enum MouseButtonFlag { + None = 0, + Left = 1, + Right = 1 << 1, + Middle = 1 << 2, + Back = 1 << 3, + Forward = 1 << 4, +} + +const getFlag = (button: MouseButton): MouseButtonFlag => { + switch (button) { + case MouseButton.Left: + return MouseButtonFlag.Left; + case MouseButton.Right: + return MouseButtonFlag.Right; + case MouseButton.Middle: + return MouseButtonFlag.Middle; + case MouseButton.Back: + return MouseButtonFlag.Back; + case MouseButton.Forward: + return MouseButtonFlag.Forward; + } +}; + +/** + * This should match + * https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:content/browser/renderer_host/input/web_input_event_builders_mac.mm;drc=a61b95c63b0b75c1cfe872d9c8cdf927c226046e;bpv=1;bpt=1;l=221. + */ +const getButtonFromPressedButtons = ( + buttons: number +): Protocol.Input.MouseButton => { + if (buttons & MouseButtonFlag.Left) { + return MouseButton.Left; + } else if (buttons & MouseButtonFlag.Right) { + return MouseButton.Right; + } else if (buttons & MouseButtonFlag.Middle) { + return MouseButton.Middle; + } else if (buttons & MouseButtonFlag.Back) { + return MouseButton.Back; + } else if (buttons & MouseButtonFlag.Forward) { + return MouseButton.Forward; + } + return 'none'; +}; + +interface MouseState { + /** + * The current position of the mouse. + */ + position: Point; + /** + * The buttons that are currently being pressed. + */ + buttons: number; +} + +/** + * @internal + */ +export class CdpMouse extends Mouse { + #client: CDPSession; + #keyboard: CdpKeyboard; + + constructor(client: CDPSession, keyboard: CdpKeyboard) { + super(); + this.#client = client; + this.#keyboard = keyboard; + } + + updateClient(client: CDPSession): void { + this.#client = client; + } + + #_state: Readonly<MouseState> = { + position: {x: 0, y: 0}, + buttons: MouseButtonFlag.None, + }; + get #state(): MouseState { + return Object.assign({...this.#_state}, ...this.#transactions); + } + + // Transactions can run in parallel, so we store each of thme in this array. + #transactions: Array<Partial<MouseState>> = []; + #createTransaction(): { + update: (updates: Partial<MouseState>) => void; + commit: () => void; + rollback: () => void; + } { + const transaction: Partial<MouseState> = {}; + this.#transactions.push(transaction); + const popTransaction = () => { + this.#transactions.splice(this.#transactions.indexOf(transaction), 1); + }; + return { + update: (updates: Partial<MouseState>) => { + Object.assign(transaction, updates); + }, + commit: () => { + this.#_state = {...this.#_state, ...transaction}; + popTransaction(); + }, + rollback: popTransaction, + }; + } + + /** + * This is a shortcut for a typical update, commit/rollback lifecycle based on + * the error of the action. + */ + async #withTransaction( + action: (update: (updates: Partial<MouseState>) => void) => Promise<unknown> + ): Promise<void> { + const {update, commit, rollback} = this.#createTransaction(); + try { + await action(update); + commit(); + } catch (error) { + rollback(); + throw error; + } + } + + override async reset(): Promise<void> { + const actions = []; + for (const [flag, button] of [ + [MouseButtonFlag.Left, MouseButton.Left], + [MouseButtonFlag.Middle, MouseButton.Middle], + [MouseButtonFlag.Right, MouseButton.Right], + [MouseButtonFlag.Forward, MouseButton.Forward], + [MouseButtonFlag.Back, MouseButton.Back], + ] as const) { + if (this.#state.buttons & flag) { + actions.push(this.up({button: button})); + } + } + if (this.#state.position.x !== 0 || this.#state.position.y !== 0) { + actions.push(this.move(0, 0)); + } + await Promise.all(actions); + } + + override async move( + x: number, + y: number, + options: Readonly<MouseMoveOptions> = {} + ): Promise<void> { + const {steps = 1} = options; + const from = this.#state.position; + const to = {x, y}; + for (let i = 1; i <= steps; i++) { + await this.#withTransaction(updateState => { + updateState({ + position: { + x: from.x + (to.x - from.x) * (i / steps), + y: from.y + (to.y - from.y) * (i / steps), + }, + }); + const {buttons, position} = this.#state; + return this.#client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + modifiers: this.#keyboard._modifiers, + buttons, + button: getButtonFromPressedButtons(buttons), + ...position, + }); + }); + } + } + + override async down(options: Readonly<MouseOptions> = {}): Promise<void> { + const {button = MouseButton.Left, clickCount = 1} = options; + const flag = getFlag(button); + if (!flag) { + throw new Error(`Unsupported mouse button: ${button}`); + } + if (this.#state.buttons & flag) { + throw new Error(`'${button}' is already pressed.`); + } + await this.#withTransaction(updateState => { + updateState({ + buttons: this.#state.buttons | flag, + }); + const {buttons, position} = this.#state; + return this.#client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + modifiers: this.#keyboard._modifiers, + clickCount, + buttons, + button, + ...position, + }); + }); + } + + override async up(options: Readonly<MouseOptions> = {}): Promise<void> { + const {button = MouseButton.Left, clickCount = 1} = options; + const flag = getFlag(button); + if (!flag) { + throw new Error(`Unsupported mouse button: ${button}`); + } + if (!(this.#state.buttons & flag)) { + throw new Error(`'${button}' is not pressed.`); + } + await this.#withTransaction(updateState => { + updateState({ + buttons: this.#state.buttons & ~flag, + }); + const {buttons, position} = this.#state; + return this.#client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + modifiers: this.#keyboard._modifiers, + clickCount, + buttons, + button, + ...position, + }); + }); + } + + override async click( + x: number, + y: number, + options: Readonly<MouseClickOptions> = {} + ): Promise<void> { + const {delay, count = 1, clickCount = count} = options; + if (count < 1) { + throw new Error('Click must occur a positive number of times.'); + } + const actions: Array<Promise<void>> = [this.move(x, y)]; + if (clickCount === count) { + for (let i = 1; i < count; ++i) { + actions.push( + this.down({...options, clickCount: i}), + this.up({...options, clickCount: i}) + ); + } + } + actions.push(this.down({...options, clickCount})); + if (typeof delay === 'number') { + await Promise.all(actions); + actions.length = 0; + await new Promise(resolve => { + setTimeout(resolve, delay); + }); + } + actions.push(this.up({...options, clickCount})); + await Promise.all(actions); + } + + override async wheel( + options: Readonly<MouseWheelOptions> = {} + ): Promise<void> { + const {deltaX = 0, deltaY = 0} = options; + const {position, buttons} = this.#state; + await this.#client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + pointerType: 'mouse', + modifiers: this.#keyboard._modifiers, + deltaY, + deltaX, + buttons, + ...position, + }); + } + + override async drag( + start: Point, + target: Point + ): Promise<Protocol.Input.DragData> { + const promise = new Promise<Protocol.Input.DragData>(resolve => { + this.#client.once('Input.dragIntercepted', event => { + return resolve(event.data); + }); + }); + await this.move(start.x, start.y); + await this.down(); + await this.move(target.x, target.y); + return await promise; + } + + override async dragEnter( + target: Point, + data: Protocol.Input.DragData + ): Promise<void> { + await this.#client.send('Input.dispatchDragEvent', { + type: 'dragEnter', + x: target.x, + y: target.y, + modifiers: this.#keyboard._modifiers, + data, + }); + } + + override async dragOver( + target: Point, + data: Protocol.Input.DragData + ): Promise<void> { + await this.#client.send('Input.dispatchDragEvent', { + type: 'dragOver', + x: target.x, + y: target.y, + modifiers: this.#keyboard._modifiers, + data, + }); + } + + override async drop( + target: Point, + data: Protocol.Input.DragData + ): Promise<void> { + await this.#client.send('Input.dispatchDragEvent', { + type: 'drop', + x: target.x, + y: target.y, + modifiers: this.#keyboard._modifiers, + data, + }); + } + + override async dragAndDrop( + start: Point, + target: Point, + options: {delay?: number} = {} + ): Promise<void> { + const {delay = null} = options; + const data = await this.drag(start, target); + await this.dragEnter(target, data); + await this.dragOver(target, data); + if (delay) { + await new Promise(resolve => { + return setTimeout(resolve, delay); + }); + } + await this.drop(target, data); + await this.up(); + } +} + +/** + * @internal + */ +export class CdpTouchscreen extends Touchscreen { + #client: CDPSession; + #keyboard: CdpKeyboard; + + constructor(client: CDPSession, keyboard: CdpKeyboard) { + super(); + this.#client = client; + this.#keyboard = keyboard; + } + + updateClient(client: CDPSession): void { + this.#client = client; + } + + override async touchStart(x: number, y: number): Promise<void> { + await this.#client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [ + { + x: Math.round(x), + y: Math.round(y), + radiusX: 0.5, + radiusY: 0.5, + }, + ], + modifiers: this.#keyboard._modifiers, + }); + } + + override async touchMove(x: number, y: number): Promise<void> { + await this.#client.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [ + { + x: Math.round(x), + y: Math.round(y), + radiusX: 0.5, + radiusY: 0.5, + }, + ], + modifiers: this.#keyboard._modifiers, + }); + } + + override async touchEnd(): Promise<void> { + await this.#client.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + modifiers: this.#keyboard._modifiers, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts new file mode 100644 index 0000000000..5846ef3652 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import {Realm} from '../api/Realm.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type {BindingPayload, EvaluateFunc, HandleFor} from '../common/types.js'; +import {debugError, withSourcePuppeteerURLIfNone} from '../common/util.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {Mutex} from '../util/Mutex.js'; + +import type {Binding} from './Binding.js'; +import {ExecutionContext, createCdpHandle} from './ExecutionContext.js'; +import type {CdpFrame} from './Frame.js'; +import type {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {addPageBinding} from './utils.js'; +import type {CdpWebWorker} from './WebWorker.js'; + +/** + * @internal + */ +export interface PageBinding { + name: string; + pptrFunction: Function; +} + +/** + * @internal + */ +export interface IsolatedWorldChart { + [key: string]: IsolatedWorld; + [MAIN_WORLD]: IsolatedWorld; + [PUPPETEER_WORLD]: IsolatedWorld; +} + +/** + * @internal + */ +export class IsolatedWorld extends Realm { + #context = Deferred.create<ExecutionContext>(); + + // Set of bindings that have been registered in the current context. + #contextBindings = new Set<string>(); + + // Contains mapping from functions that should be bound to Puppeteer functions. + #bindings = new Map<string, Binding>(); + + get _bindings(): Map<string, Binding> { + return this.#bindings; + } + + readonly #frameOrWorker: CdpFrame | CdpWebWorker; + + constructor( + frameOrWorker: CdpFrame | CdpWebWorker, + timeoutSettings: TimeoutSettings + ) { + super(timeoutSettings); + this.#frameOrWorker = frameOrWorker; + this.frameUpdated(); + } + + get environment(): CdpFrame | CdpWebWorker { + return this.#frameOrWorker; + } + + frameUpdated(): void { + this.client.on('Runtime.bindingCalled', this.#onBindingCalled); + } + + get client(): CDPSession { + return this.#frameOrWorker.client; + } + + clearContext(): void { + // The message has to match the CDP message expected by the WaitTask class. + this.#context?.reject(new Error('Execution context was destroyed')); + this.#context = Deferred.create(); + if ('clearDocumentHandle' in this.#frameOrWorker) { + this.#frameOrWorker.clearDocumentHandle(); + } + } + + setContext(context: ExecutionContext): void { + this.#contextBindings.clear(); + this.#context.resolve(context); + void this.taskManager.rerunAll(); + } + + hasContext(): boolean { + return this.#context.resolved(); + } + + #executionContext(): Promise<ExecutionContext> { + if (this.disposed) { + throw new Error( + `Execution context is not available in detached frame "${this.environment.url()}" (are you trying to evaluate?)` + ); + } + if (this.#context === null) { + throw new Error(`Execution content promise is missing`); + } + return this.#context.valueOrThrow(); + } + + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + const context = await this.#executionContext(); + return await context.evaluateHandle(pageFunction, ...args); + } + + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + let context = this.#context.value(); + if (!context || !(context instanceof ExecutionContext)) { + context = await this.#executionContext(); + } + return await context.evaluate(pageFunction, ...args); + } + + // If multiple waitFor are set up asynchronously, we need to wait for the + // first one to set up the binding in the page before running the others. + #mutex = new Mutex(); + async _addBindingToContext( + context: ExecutionContext, + name: string + ): Promise<void> { + if (this.#contextBindings.has(name)) { + return; + } + + using _ = await this.#mutex.acquire(); + try { + await context._client.send( + 'Runtime.addBinding', + context._contextName + ? { + name, + executionContextName: context._contextName, + } + : { + name, + executionContextId: context._contextId, + } + ); + + await context.evaluate(addPageBinding, 'internal', name); + + this.#contextBindings.add(name); + } catch (error) { + // We could have tried to evaluate in a context which was already + // destroyed. This happens, for example, if the page is navigated while + // we are trying to add the binding + if (error instanceof Error) { + // Destroyed context. + if (error.message.includes('Execution context was destroyed')) { + return; + } + // Missing context. + if (error.message.includes('Cannot find context with specified id')) { + return; + } + } + + debugError(error); + } + } + + #onBindingCalled = async ( + event: Protocol.Runtime.BindingCalledEvent + ): Promise<void> => { + let payload: BindingPayload; + try { + payload = JSON.parse(event.payload); + } catch { + // The binding was either called by something in the page or it was + // called before our wrapper was initialized. + return; + } + const {type, name, seq, args, isTrivial} = payload; + if (type !== 'internal') { + return; + } + if (!this.#contextBindings.has(name)) { + return; + } + + try { + const context = await this.#context.valueOrThrow(); + if (event.executionContextId !== context._contextId) { + return; + } + + const binding = this._bindings.get(name); + await binding?.run(context, seq, args, isTrivial); + } catch (err) { + debugError(err); + } + }; + + override async adoptBackendNode( + backendNodeId?: Protocol.DOM.BackendNodeId + ): Promise<JSHandle<Node>> { + const executionContext = await this.#executionContext(); + const {object} = await this.client.send('DOM.resolveNode', { + backendNodeId: backendNodeId, + executionContextId: executionContext._contextId, + }); + return createCdpHandle(this, object) as JSHandle<Node>; + } + + async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { + if (handle.realm === this) { + // If the context has already adopted this handle, clone it so downstream + // disposal doesn't become an issue. + return (await handle.evaluateHandle(value => { + return value; + })) as unknown as T; + } + const nodeInfo = await this.client.send('DOM.describeNode', { + objectId: handle.id, + }); + return (await this.adoptBackendNode(nodeInfo.node.backendNodeId)) as T; + } + + async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { + if (handle.realm === this) { + return handle; + } + // Implies it's a primitive value, probably. + if (handle.remoteObject().objectId === undefined) { + return handle; + } + const info = await this.client.send('DOM.describeNode', { + objectId: handle.remoteObject().objectId, + }); + const newHandle = (await this.adoptBackendNode( + info.node.backendNodeId + )) as T; + await handle.dispose(); + return newHandle; + } + + [disposeSymbol](): void { + super[disposeSymbol](); + this.client.off('Runtime.bindingCalled', this.#onBindingCalled); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts new file mode 100644 index 0000000000..ddb6c2381d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A unique key for {@link IsolatedWorldChart} to denote the default world. + * Execution contexts are automatically created in the default world. + * + * @internal + */ +export const MAIN_WORLD = Symbol('mainWorld'); +/** + * A unique key for {@link IsolatedWorldChart} to denote the puppeteer world. + * This world contains all puppeteer-internal bindings/code. + * + * @internal + */ +export const PUPPETEER_WORLD = Symbol('puppeteerWorld'); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts new file mode 100644 index 0000000000..bba5f96b5d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {JSHandle} from '../api/JSHandle.js'; +import {debugError} from '../common/util.js'; + +import type {CdpElementHandle} from './ElementHandle.js'; +import type {IsolatedWorld} from './IsolatedWorld.js'; +import {valueFromRemoteObject} from './utils.js'; + +/** + * @internal + */ +export class CdpJSHandle<T = unknown> extends JSHandle<T> { + #disposed = false; + readonly #remoteObject: Protocol.Runtime.RemoteObject; + readonly #world: IsolatedWorld; + + constructor( + world: IsolatedWorld, + remoteObject: Protocol.Runtime.RemoteObject + ) { + super(); + this.#world = world; + this.#remoteObject = remoteObject; + } + + override get disposed(): boolean { + return this.#disposed; + } + + override get realm(): IsolatedWorld { + return this.#world; + } + + get client(): CDPSession { + return this.realm.environment.client; + } + + override async jsonValue(): Promise<T> { + if (!this.#remoteObject.objectId) { + return valueFromRemoteObject(this.#remoteObject); + } + const value = await this.evaluate(object => { + return object; + }); + if (value === undefined) { + throw new Error('Could not serialize referenced object'); + } + return value; + } + + /** + * Either `null` or the handle itself if the handle is an + * instance of {@link ElementHandle}. + */ + override asElement(): CdpElementHandle<Node> | null { + return null; + } + + override async dispose(): Promise<void> { + if (this.#disposed) { + return; + } + this.#disposed = true; + await releaseObject(this.client, this.#remoteObject); + } + + override toString(): string { + if (!this.#remoteObject.objectId) { + return 'JSHandle:' + valueFromRemoteObject(this.#remoteObject); + } + const type = this.#remoteObject.subtype || this.#remoteObject.type; + return 'JSHandle@' + type; + } + + override get id(): string | undefined { + return this.#remoteObject.objectId; + } + + override remoteObject(): Protocol.Runtime.RemoteObject { + return this.#remoteObject; + } +} + +/** + * @internal + */ +export async function releaseObject( + client: CDPSession, + remoteObject: Protocol.Runtime.RemoteObject +): Promise<void> { + if (!remoteObject.objectId) { + return; + } + await client + .send('Runtime.releaseObject', {objectId: remoteObject.objectId}) + .catch(error => { + // Exceptions might happen in case of a page been navigated or closed. + // Swallow these since they are harmless and we don't leak anything in this case. + debugError(error); + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts new file mode 100644 index 0000000000..a4f5aaa468 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import {type Frame, FrameEvent} from '../api/Frame.js'; +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {TimeoutError} from '../common/Errors.js'; +import {EventSubscription} from '../common/EventEmitter.js'; +import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {DisposableStack} from '../util/disposable.js'; + +import type {CdpFrame} from './Frame.js'; +import {FrameManagerEvent} from './FrameManagerEvents.js'; +import type {NetworkManager} from './NetworkManager.js'; + +/** + * @public + */ +export type PuppeteerLifeCycleEvent = + /** + * Waits for the 'load' event. + */ + | 'load' + /** + * Waits for the 'DOMContentLoaded' event. + */ + | 'domcontentloaded' + /** + * Waits till there are no more than 0 network connections for at least `500` + * ms. + */ + | 'networkidle0' + /** + * Waits till there are no more than 2 network connections for at least `500` + * ms. + */ + | 'networkidle2'; + +/** + * @public + */ +export type ProtocolLifeCycleEvent = + | 'load' + | 'DOMContentLoaded' + | 'networkIdle' + | 'networkAlmostIdle'; + +const puppeteerToProtocolLifecycle = new Map< + PuppeteerLifeCycleEvent, + ProtocolLifeCycleEvent +>([ + ['load', 'load'], + ['domcontentloaded', 'DOMContentLoaded'], + ['networkidle0', 'networkIdle'], + ['networkidle2', 'networkAlmostIdle'], +]); + +/** + * @internal + */ +export class LifecycleWatcher { + #expectedLifecycle: ProtocolLifeCycleEvent[]; + #frame: CdpFrame; + #timeout: number; + #navigationRequest: HTTPRequest | null = null; + #subscriptions = new DisposableStack(); + #initialLoaderId: string; + + #terminationDeferred: Deferred<Error>; + #sameDocumentNavigationDeferred = Deferred.create<undefined>(); + #lifecycleDeferred = Deferred.create<void>(); + #newDocumentNavigationDeferred = Deferred.create<undefined>(); + + #hasSameDocumentNavigation?: boolean; + #swapped?: boolean; + + #navigationResponseReceived?: Deferred<void>; + + constructor( + networkManager: NetworkManager, + frame: CdpFrame, + waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[], + timeout: number + ) { + if (Array.isArray(waitUntil)) { + waitUntil = waitUntil.slice(); + } else if (typeof waitUntil === 'string') { + waitUntil = [waitUntil]; + } + this.#initialLoaderId = frame._loaderId; + this.#expectedLifecycle = waitUntil.map(value => { + const protocolEvent = puppeteerToProtocolLifecycle.get(value); + assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); + return protocolEvent as ProtocolLifeCycleEvent; + }); + + this.#frame = frame; + this.#timeout = timeout; + this.#subscriptions.use( + // Revert if TODO #1 is done + new EventSubscription( + frame._frameManager, + FrameManagerEvent.LifecycleEvent, + this.#checkLifecycleComplete.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameNavigatedWithinDocument, + this.#navigatedWithinDocument.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameNavigated, + this.#navigated.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameSwapped, + this.#frameSwapped.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameSwappedByActivation, + this.#frameSwapped.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameDetached, + this.#onFrameDetached.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + networkManager, + NetworkManagerEvent.Request, + this.#onRequest.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + networkManager, + NetworkManagerEvent.Response, + this.#onResponse.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + networkManager, + NetworkManagerEvent.RequestFailed, + this.#onRequestFailed.bind(this) + ) + ); + this.#terminationDeferred = Deferred.create<Error>({ + timeout: this.#timeout, + message: `Navigation timeout of ${this.#timeout} ms exceeded`, + }); + + this.#checkLifecycleComplete(); + } + + #onRequest(request: HTTPRequest): void { + if (request.frame() !== this.#frame || !request.isNavigationRequest()) { + return; + } + this.#navigationRequest = request; + // Resolve previous navigation response in case there are multiple + // navigation requests reported by the backend. This generally should not + // happen by it looks like it's possible. + this.#navigationResponseReceived?.resolve(); + this.#navigationResponseReceived = Deferred.create(); + if (request.response() !== null) { + this.#navigationResponseReceived?.resolve(); + } + } + + #onRequestFailed(request: HTTPRequest): void { + if (this.#navigationRequest?._requestId !== request._requestId) { + return; + } + this.#navigationResponseReceived?.resolve(); + } + + #onResponse(response: HTTPResponse): void { + if (this.#navigationRequest?._requestId !== response.request()._requestId) { + return; + } + this.#navigationResponseReceived?.resolve(); + } + + #onFrameDetached(frame: Frame): void { + if (this.#frame === frame) { + this.#terminationDeferred.resolve( + new Error('Navigating frame was detached') + ); + return; + } + this.#checkLifecycleComplete(); + } + + async navigationResponse(): Promise<HTTPResponse | null> { + // Continue with a possibly null response. + await this.#navigationResponseReceived?.valueOrThrow(); + return this.#navigationRequest ? this.#navigationRequest.response() : null; + } + + sameDocumentNavigationPromise(): Promise<Error | undefined> { + return this.#sameDocumentNavigationDeferred.valueOrThrow(); + } + + newDocumentNavigationPromise(): Promise<Error | undefined> { + return this.#newDocumentNavigationDeferred.valueOrThrow(); + } + + lifecyclePromise(): Promise<void> { + return this.#lifecycleDeferred.valueOrThrow(); + } + + terminationPromise(): Promise<Error | TimeoutError | undefined> { + return this.#terminationDeferred.valueOrThrow(); + } + + #navigatedWithinDocument(): void { + this.#hasSameDocumentNavigation = true; + this.#checkLifecycleComplete(); + } + + #navigated(navigationType: Protocol.Page.NavigationType): void { + if (navigationType === 'BackForwardCacheRestore') { + return this.#frameSwapped(); + } + this.#checkLifecycleComplete(); + } + + #frameSwapped(): void { + this.#swapped = true; + this.#checkLifecycleComplete(); + } + + #checkLifecycleComplete(): void { + // We expect navigation to commit. + if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) { + return; + } + this.#lifecycleDeferred.resolve(); + if (this.#hasSameDocumentNavigation) { + this.#sameDocumentNavigationDeferred.resolve(undefined); + } + if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) { + this.#newDocumentNavigationDeferred.resolve(undefined); + } + + function checkLifecycle( + frame: CdpFrame, + expectedLifecycle: ProtocolLifeCycleEvent[] + ): boolean { + for (const event of expectedLifecycle) { + if (!frame._lifecycleEvents.has(event)) { + return false; + } + } + // TODO(#1): Its possible we don't need this check + // CDP provided the correct order for Loading Events + // And NetworkIdle is a global state + // Consider removing + for (const child of frame.childFrames()) { + if ( + child._hasStartedLoading && + !checkLifecycle(child, expectedLifecycle) + ) { + return false; + } + } + return true; + } + } + + dispose(): void { + this.#subscriptions.dispose(); + this.#terminationDeferred.resolve(new Error('LifecycleWatcher disposed')); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts new file mode 100644 index 0000000000..2aadd21d25 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CdpHTTPRequest} from './HTTPRequest.js'; + +/** + * @internal + */ +export interface QueuedEventGroup { + responseReceivedEvent: Protocol.Network.ResponseReceivedEvent; + loadingFinishedEvent?: Protocol.Network.LoadingFinishedEvent; + loadingFailedEvent?: Protocol.Network.LoadingFailedEvent; +} + +/** + * @internal + */ +export type FetchRequestId = string; + +/** + * @internal + */ +export interface RedirectInfo { + event: Protocol.Network.RequestWillBeSentEvent; + fetchRequestId?: FetchRequestId; +} +type RedirectInfoList = RedirectInfo[]; + +/** + * @internal + */ +export type NetworkRequestId = string; + +/** + * Helper class to track network events by request ID + * + * @internal + */ +export class NetworkEventManager { + /** + * There are four possible orders of events: + * A. `_onRequestWillBeSent` + * B. `_onRequestWillBeSent`, `_onRequestPaused` + * C. `_onRequestPaused`, `_onRequestWillBeSent` + * D. `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`, + * `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused` + * (see crbug.com/1196004) + * + * For `_onRequest` we need the event from `_onRequestWillBeSent` and + * optionally the `interceptionId` from `_onRequestPaused`. + * + * If request interception is disabled, call `_onRequest` once per call to + * `_onRequestWillBeSent`. + * If request interception is enabled, call `_onRequest` once per call to + * `_onRequestPaused` (once per `interceptionId`). + * + * Events are stored to allow for subsequent events to call `_onRequest`. + * + * Note that (chains of) redirect requests have the same `requestId` (!) as + * the original request. We have to anticipate series of events like these: + * A. `_onRequestWillBeSent`, + * `_onRequestWillBeSent`, ... + * B. `_onRequestWillBeSent`, `_onRequestPaused`, + * `_onRequestWillBeSent`, `_onRequestPaused`, ... + * C. `_onRequestWillBeSent`, `_onRequestPaused`, + * `_onRequestPaused`, `_onRequestWillBeSent`, ... + * D. `_onRequestPaused`, `_onRequestWillBeSent`, + * `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`, + * `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused`, ... + * (see crbug.com/1196004) + */ + #requestWillBeSentMap = new Map< + NetworkRequestId, + Protocol.Network.RequestWillBeSentEvent + >(); + #requestPausedMap = new Map< + NetworkRequestId, + Protocol.Fetch.RequestPausedEvent + >(); + #httpRequestsMap = new Map<NetworkRequestId, CdpHTTPRequest>(); + + /* + * The below maps are used to reconcile Network.responseReceivedExtraInfo + * events with their corresponding request. Each response and redirect + * response gets an ExtraInfo event, and we don't know which will come first. + * This means that we have to store a Response or an ExtraInfo for each + * response, and emit the event when we get both of them. In addition, to + * handle redirects, we have to make them Arrays to represent the chain of + * events. + */ + #responseReceivedExtraInfoMap = new Map< + NetworkRequestId, + Protocol.Network.ResponseReceivedExtraInfoEvent[] + >(); + #queuedRedirectInfoMap = new Map<NetworkRequestId, RedirectInfoList>(); + #queuedEventGroupMap = new Map<NetworkRequestId, QueuedEventGroup>(); + + forget(networkRequestId: NetworkRequestId): void { + this.#requestWillBeSentMap.delete(networkRequestId); + this.#requestPausedMap.delete(networkRequestId); + this.#queuedEventGroupMap.delete(networkRequestId); + this.#queuedRedirectInfoMap.delete(networkRequestId); + this.#responseReceivedExtraInfoMap.delete(networkRequestId); + } + + responseExtraInfo( + networkRequestId: NetworkRequestId + ): Protocol.Network.ResponseReceivedExtraInfoEvent[] { + if (!this.#responseReceivedExtraInfoMap.has(networkRequestId)) { + this.#responseReceivedExtraInfoMap.set(networkRequestId, []); + } + return this.#responseReceivedExtraInfoMap.get( + networkRequestId + ) as Protocol.Network.ResponseReceivedExtraInfoEvent[]; + } + + private queuedRedirectInfo(fetchRequestId: FetchRequestId): RedirectInfoList { + if (!this.#queuedRedirectInfoMap.has(fetchRequestId)) { + this.#queuedRedirectInfoMap.set(fetchRequestId, []); + } + return this.#queuedRedirectInfoMap.get(fetchRequestId) as RedirectInfoList; + } + + queueRedirectInfo( + fetchRequestId: FetchRequestId, + redirectInfo: RedirectInfo + ): void { + this.queuedRedirectInfo(fetchRequestId).push(redirectInfo); + } + + takeQueuedRedirectInfo( + fetchRequestId: FetchRequestId + ): RedirectInfo | undefined { + return this.queuedRedirectInfo(fetchRequestId).shift(); + } + + inFlightRequestsCount(): number { + let inFlightRequestCounter = 0; + for (const request of this.#httpRequestsMap.values()) { + if (!request.response()) { + inFlightRequestCounter++; + } + } + return inFlightRequestCounter; + } + + storeRequestWillBeSent( + networkRequestId: NetworkRequestId, + event: Protocol.Network.RequestWillBeSentEvent + ): void { + this.#requestWillBeSentMap.set(networkRequestId, event); + } + + getRequestWillBeSent( + networkRequestId: NetworkRequestId + ): Protocol.Network.RequestWillBeSentEvent | undefined { + return this.#requestWillBeSentMap.get(networkRequestId); + } + + forgetRequestWillBeSent(networkRequestId: NetworkRequestId): void { + this.#requestWillBeSentMap.delete(networkRequestId); + } + + getRequestPaused( + networkRequestId: NetworkRequestId + ): Protocol.Fetch.RequestPausedEvent | undefined { + return this.#requestPausedMap.get(networkRequestId); + } + + forgetRequestPaused(networkRequestId: NetworkRequestId): void { + this.#requestPausedMap.delete(networkRequestId); + } + + storeRequestPaused( + networkRequestId: NetworkRequestId, + event: Protocol.Fetch.RequestPausedEvent + ): void { + this.#requestPausedMap.set(networkRequestId, event); + } + + getRequest(networkRequestId: NetworkRequestId): CdpHTTPRequest | undefined { + return this.#httpRequestsMap.get(networkRequestId); + } + + storeRequest( + networkRequestId: NetworkRequestId, + request: CdpHTTPRequest + ): void { + this.#httpRequestsMap.set(networkRequestId, request); + } + + forgetRequest(networkRequestId: NetworkRequestId): void { + this.#httpRequestsMap.delete(networkRequestId); + } + + getQueuedEventGroup( + networkRequestId: NetworkRequestId + ): QueuedEventGroup | undefined { + return this.#queuedEventGroupMap.get(networkRequestId); + } + + queueEventGroup( + networkRequestId: NetworkRequestId, + event: QueuedEventGroup + ): void { + this.#queuedEventGroupMap.set(networkRequestId, event); + } + + forgetQueuedEventGroup(networkRequestId: NetworkRequestId): void { + this.#queuedEventGroupMap.delete(networkRequestId); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts new file mode 100644 index 0000000000..c3e9a8f609 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts @@ -0,0 +1,1531 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import type {CDPSessionEvents} from '../api/CDPSession.js'; +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; + +import type {CdpFrame} from './Frame.js'; +import {NetworkManager} from './NetworkManager.js'; + +// TODO: develop a helper to generate fake network events for attributes that +// are not relevant for the network manager to make tests shorter. + +class MockCDPSession extends EventEmitter<CDPSessionEvents> { + async send(): Promise<any> {} + connection() { + return undefined; + } + async detach() {} + id() { + return '1'; + } + parentSession() { + return undefined; + } +} + +describe('NetworkManager', () => { + it('should process extra info on multiple redirects', async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + documentURL: 'http://localhost:8907/redirect/1.html', + request: { + url: 'http://localhost:8907/redirect/1.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 2111.55635, + wallTime: 1637315638.473634, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '099A5216AF03AAFEC988F214B024DF08', + hasUserGesture: false, + }); + + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + associatedCookies: [], + headers: { + Host: 'localhost:8907', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Accept-Encoding': 'gzip, deflate, br', + }, + connectTiming: {requestTime: 2111.557593}, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + blockedCookies: [], + headers: { + location: '/redirect/2.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\r\nlocation: /redirect/2.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n', + }); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + documentURL: 'http://localhost:8907/redirect/2.html', + request: { + url: 'http://localhost:8907/redirect/2.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 2111.559124, + wallTime: 1637315638.47642, + initiator: {type: 'other'}, + redirectHasExtraInfo: true, + redirectResponse: { + url: 'http://localhost:8907/redirect/1.html', + status: 302, + statusText: 'Found', + headers: { + location: '/redirect/2.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + mimeType: '', + connectionReused: false, + connectionId: 322, + remoteIPAddress: '[::1]', + remotePort: 8907, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 162, + timing: { + receiveHeadersStart: 0, + requestTime: 2111.557593, + proxyStart: -1, + proxyEnd: -1, + dnsStart: 0.241, + dnsEnd: 0.251, + connectStart: 0.251, + connectEnd: 0.47, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.537, + sendEnd: 0.611, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.939, + }, + responseTime: 1.637315638475744e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + type: 'Document', + frameId: '099A5216AF03AAFEC988F214B024DF08', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + associatedCookies: [], + headers: { + Host: 'localhost:8907', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Accept-Encoding': 'gzip, deflate, br', + }, + connectTiming: {requestTime: 2111.559346}, + }); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + documentURL: 'http://localhost:8907/redirect/3.html', + request: { + url: 'http://localhost:8907/redirect/3.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 2111.560249, + wallTime: 1637315638.477543, + initiator: {type: 'other'}, + redirectHasExtraInfo: true, + redirectResponse: { + url: 'http://localhost:8907/redirect/2.html', + status: 302, + statusText: 'Found', + headers: { + location: '/redirect/3.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + mimeType: '', + connectionReused: true, + connectionId: 322, + remoteIPAddress: '[::1]', + remotePort: 8907, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 162, + timing: { + receiveHeadersStart: 0, + requestTime: 2111.559346, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.15, + sendEnd: 0.196, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.507, + }, + responseTime: 1.637315638477063e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + type: 'Document', + frameId: '099A5216AF03AAFEC988F214B024DF08', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + blockedCookies: [], + headers: { + location: '/redirect/3.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\r\nlocation: /redirect/3.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n', + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + associatedCookies: [], + headers: { + Host: 'localhost:8907', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Accept-Encoding': 'gzip, deflate, br', + }, + connectTiming: {requestTime: 2111.560482}, + }); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + documentURL: 'http://localhost:8907/empty.html', + request: { + url: 'http://localhost:8907/empty.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 2111.561542, + wallTime: 1637315638.478837, + initiator: {type: 'other'}, + redirectHasExtraInfo: true, + redirectResponse: { + url: 'http://localhost:8907/redirect/3.html', + status: 302, + statusText: 'Found', + headers: { + location: 'http://localhost:8907/empty.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + mimeType: '', + connectionReused: true, + connectionId: 322, + remoteIPAddress: '[::1]', + remotePort: 8907, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 178, + timing: { + receiveHeadersStart: 0, + requestTime: 2111.560482, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.149, + sendEnd: 0.198, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.478, + }, + responseTime: 1.637315638478184e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + type: 'Document', + frameId: '099A5216AF03AAFEC988F214B024DF08', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + blockedCookies: [], + headers: { + location: 'http://localhost:8907/empty.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\r\nlocation: http://localhost:8907/empty.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n', + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + associatedCookies: [], + headers: { + Host: 'localhost:8907', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Accept-Encoding': 'gzip, deflate, br', + }, + connectTiming: {requestTime: 2111.561759}, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + blockedCookies: [], + headers: { + 'Cache-Control': 'no-cache, no-store', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Content-Length': '0', + }, + resourceIPAddressSpace: 'Local', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n', + }); + mockCDPSession.emit('Network.responseReceived', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + timestamp: 2111.563565, + type: 'Document', + response: { + url: 'http://localhost:8907/empty.html', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'no-cache, no-store', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Content-Length': '0', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 322, + remoteIPAddress: '[::1]', + remotePort: 8907, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 2111.561759, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.148, + sendEnd: 0.19, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.925, + }, + responseTime: 1.637315638479928e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: '099A5216AF03AAFEC988F214B024DF08', + }); + }); + it(`should handle "double pause" (crbug.com/1196004) Fetch.requestPaused events for the same Network.requestWillBeSent event`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + await manager.setRequestInterception(true); + + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.Request, async (request: HTTPRequest) => { + requests.push(request); + await request.continue(); + }); + + /** + * This sequence was taken from an actual CDP session produced by the following + * test script: + * + * ```ts + * const browser = await puppeteer.launch({headless: false}); + * const page = await browser.newPage(); + * await page.setCacheEnabled(false); + * + * await page.setRequestInterception(true); + * page.on('request', interceptedRequest => { + * interceptedRequest.continue(); + * }); + * + * await page.goto('https://www.google.com'); + * await browser.close(); + * ``` + */ + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '11ACE9783588040D644B905E8B55285B', + loaderId: '11ACE9783588040D644B905E8B55285B', + documentURL: 'https://www.google.com/', + request: { + url: 'https://www.google.com/', + method: 'GET', + headers: {}, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 224604.980827, + wallTime: 1637955746.786191, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '84AC261A351B86932B775B76D1DD79F8', + hasUserGesture: false, + }); + mockCDPSession.emit('Fetch.requestPaused', { + requestId: 'interception-job-1.0', + request: { + url: 'https://www.google.com/', + method: 'GET', + headers: {}, + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + }, + frameId: '84AC261A351B86932B775B76D1DD79F8', + resourceType: 'Document', + networkId: '11ACE9783588040D644B905E8B55285B', + }); + mockCDPSession.emit('Fetch.requestPaused', { + requestId: 'interception-job-2.0', + request: { + url: 'https://www.google.com/', + method: 'GET', + headers: {}, + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + }, + frameId: '84AC261A351B86932B775B76D1DD79F8', + resourceType: 'Document', + networkId: '11ACE9783588040D644B905E8B55285B', + }); + + expect(requests).toHaveLength(2); + }); + it(`should handle Network.responseReceivedExtraInfo event after Network.responseReceived event (github.com/puppeteer/puppeteer/issues/8234)`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.RequestFinished, (request: HTTPRequest) => { + requests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '1360.2', + loaderId: '9E86B0282CC98B77FB0ABD49156DDFDD', + documentURL: 'http://this.is.the.start.page.com/', + request: { + url: 'http://this.is.a.test.com:1080/test.js', + method: 'GET', + headers: { + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'http://this.is.the.start.page.com/', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'High', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: false, + }, + timestamp: 10959.020087, + wallTime: 1649712607.861365, + initiator: { + type: 'parser', + url: 'http://this.is.the.start.page.com/', + lineNumber: 9, + columnNumber: 80, + }, + redirectHasExtraInfo: false, + type: 'Script', + frameId: '60E6C35E7E519F28E646056820095498', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.responseReceived', { + requestId: '1360.2', + loaderId: '9E86B0282CC98B77FB0ABD49156DDFDD', + timestamp: 10959.042529, + type: 'Script', + response: { + url: 'http://this.is.a.test.com:1080', + status: 200, + statusText: 'OK', + headers: { + connection: 'keep-alive', + 'content-length': '85862', + }, + mimeType: 'text/plain', + connectionReused: false, + connectionId: 119, + remoteIPAddress: '127.0.0.1', + remotePort: 1080, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 66, + timing: { + receiveHeadersStart: 0, + requestTime: 10959.023904, + proxyStart: -1, + proxyEnd: -1, + dnsStart: 0.328, + dnsEnd: 2.183, + connectStart: 2.183, + connectEnd: 2.798, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 2.982, + sendEnd: 3.757, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 16.373, + }, + responseTime: 1649712607880.971, + protocol: 'http/1.1', + securityState: 'insecure', + }, + hasExtraInfo: true, + frameId: '60E6C35E7E519F28E646056820095498', + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '1360.2', + blockedCookies: [], + headers: { + connection: 'keep-alive', + 'content-length': '85862', + }, + resourceIPAddressSpace: 'Private', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\r\nconnection: keep-alive\r\ncontent-length: 85862\r\n\r\n', + }); + mockCDPSession.emit('Network.loadingFinished', { + requestId: '1360.2', + timestamp: 10959.060708, + encodedDataLength: 85928, + }); + + expect(requests).toHaveLength(1); + }); + + it(`should resolve the response once the late responseReceivedExtraInfo event arrives`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const finishedRequests: HTTPRequest[] = []; + const pendingRequests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.RequestFinished, (request: HTTPRequest) => { + finishedRequests.push(request); + }); + + manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => { + pendingRequests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: 'LOADERID', + loaderId: 'LOADERID', + documentURL: 'http://10.1.0.39:42915/empty.html', + request: { + url: 'http://10.1.0.39:42915/empty.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 671.229856, + wallTime: 1660121157.913774, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: 'FRAMEID', + hasUserGesture: false, + }); + + mockCDPSession.emit('Network.responseReceived', { + requestId: 'LOADERID', + loaderId: 'LOADERID', + timestamp: 671.236025, + type: 'Document', + response: { + url: 'http://10.1.0.39:42915/empty.html', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 08:45:57 GMT', + 'Keep-Alive': 'timeout=5', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 18, + remoteIPAddress: '10.1.0.39', + remotePort: 42915, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 671.232585, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.308, + sendEnd: 0.364, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 1.554, + }, + responseTime: 1.660121157917951e12, + protocol: 'http/1.1', + securityState: 'insecure', + }, + hasExtraInfo: true, + frameId: 'FRAMEID', + }); + + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: 'LOADERID', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Language': 'en-US,en;q=0.9', + Connection: 'keep-alive', + Host: '10.1.0.39:42915', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36', + }, + connectTiming: {requestTime: 671.232585}, + }); + + mockCDPSession.emit('Network.loadingFinished', { + requestId: 'LOADERID', + timestamp: 671.234448, + encodedDataLength: 197, + }); + + expect(pendingRequests).toHaveLength(1); + expect(finishedRequests).toHaveLength(0); + expect(pendingRequests[0]!.response()).toEqual(null); + + // The extra info might arrive late. + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: 'LOADERID', + blockedCookies: [], + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 09:04:39 GMT', + 'Keep-Alive': 'timeout=5', + }, + resourceIPAddressSpace: 'Private', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\\r\\nCache-Control: no-cache, no-store\\r\\nContent-Type: text/html; charset=utf-8\\r\\nDate: Wed, 10 Aug 2022 09:04:39 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nContent-Length: 0\\r\\n\\r\\n', + }); + + expect(pendingRequests).toHaveLength(1); + expect(finishedRequests).toHaveLength(1); + expect(pendingRequests[0]!.response()).not.toEqual(null); + }); + + it(`should send responses for iframe that don't receive loadingFinished event`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const responses: HTTPResponse[] = []; + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => { + responses.push(response); + }); + + manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => { + requests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '94051D839ACF29E53A3D1273FB20B4C4', + loaderId: '94051D839ACF29E53A3D1273FB20B4C4', + documentURL: 'http://127.0.0.1:54590/empty.html', + request: { + url: 'http://127.0.0.1:54590/empty.html', + method: 'GET', + headers: { + Referer: 'http://localhost:54590/', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: false, + }, + timestamp: 504903.99901, + wallTime: 1660125092.026021, + initiator: { + type: 'script', + stack: { + callFrames: [ + { + functionName: 'navigateFrame', + scriptId: '8', + url: 'pptr://__puppeteer_evaluation_script__', + lineNumber: 2, + columnNumber: 18, + }, + ], + }, + }, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '07D18B8630A8161C72B6079B74123D60', + hasUserGesture: true, + }); + + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '94051D839ACF29E53A3D1273FB20B4C4', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + Host: '127.0.0.1:54590', + Referer: 'http://localhost:54590/', + 'Sec-Fetch-Dest': 'iframe', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36', + }, + connectTiming: {requestTime: 504904.000422}, + clientSecurityState: { + initiatorIsSecureContext: true, + initiatorIPAddressSpace: 'Local', + privateNetworkRequestPolicy: 'Allow', + }, + }); + + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '94051D839ACF29E53A3D1273FB20B4C4', + blockedCookies: [], + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 09:51:32 GMT', + 'Keep-Alive': 'timeout=5', + }, + resourceIPAddressSpace: 'Local', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Wed, 10 Aug 2022 09:51:32 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n', + }); + + mockCDPSession.emit('Network.responseReceived', { + requestId: '94051D839ACF29E53A3D1273FB20B4C4', + loaderId: '94051D839ACF29E53A3D1273FB20B4C4', + timestamp: 504904.00338, + type: 'Document', + response: { + url: 'http://127.0.0.1:54590/empty.html', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 09:51:32 GMT', + 'Keep-Alive': 'timeout=5', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 13, + remoteIPAddress: '127.0.0.1', + remotePort: 54590, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 504904.000422, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.338, + sendEnd: 0.413, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 1.877, + }, + responseTime: 1.660125092029241e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: '07D18B8630A8161C72B6079B74123D60', + }); + + expect(requests).toHaveLength(1); + expect(responses).toHaveLength(1); + expect(requests[0]!.response()).not.toEqual(null); + }); + + it(`should send responses for iframe that don't receive loadingFinished event`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const responses: HTTPResponse[] = []; + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => { + responses.push(response); + }); + + manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => { + requests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + loaderId: 'E18BEB94B486CA8771F9AFA2030FEA37', + documentURL: 'http://localhost:56295/empty.html', + request: { + url: 'http://localhost:56295/empty.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 510294.105656, + wallTime: 1660130482.230591, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: 'F9C89A517341F1EFFE63310141630189', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.responseReceived', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + loaderId: 'E18BEB94B486CA8771F9AFA2030FEA37', + timestamp: 510294.119816, + type: 'Document', + response: { + url: 'http://localhost:56295/empty.html', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 11:21:22 GMT', + 'Keep-Alive': 'timeout=5', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 13, + remoteIPAddress: '[::1]', + remotePort: 56295, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 510294.106734, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 2.195, + sendEnd: 2.29, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 6.493, + }, + responseTime: 1.660130482238109e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: 'F9C89A517341F1EFFE63310141630189', + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + Host: 'localhost:56295', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36', + }, + connectTiming: {requestTime: 510294.106734}, + }); + mockCDPSession.emit('Network.loadingFinished', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + timestamp: 510294.113383, + encodedDataLength: 197, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + blockedCookies: [], + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 11:21:22 GMT', + 'Keep-Alive': 'timeout=5', + }, + resourceIPAddressSpace: 'Local', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Wed, 10 Aug 2022 11:21:22 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n', + }); + + expect(requests).toHaveLength(1); + expect(responses).toHaveLength(1); + expect(requests[0]!.response()).not.toEqual(null); + }); + + it(`should handle cached redirects`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const responses: HTTPResponse[] = []; + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => { + responses.push(response); + }); + + manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => { + requests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + loaderId: '6D76C8ACAECE880C722FA515AD380015', + documentURL: 'http://localhost:3000/', + request: { + url: 'http://localhost:3000/', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 31949.95878, + wallTime: 1680698353.570949, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8', + Connection: 'keep-alive', + Host: 'localhost:3000', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + 'sec-ch-ua-mobile': '?0', + }, + connectTiming: {requestTime: 31949.959838}, + siteHasCookieInOtherPartition: false, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + blockedCookies: [], + headers: { + 'Cache-Control': 'max-age=5', + Connection: 'keep-alive', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\\r\\nContent-Type: text/html; charset=utf-8\\r\\nCache-Control: max-age=5\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n', + cookiePartitionKey: 'http://localhost', + cookiePartitionKeyOpaque: false, + }); + + mockCDPSession.emit('Network.responseReceived', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + loaderId: '6D76C8ACAECE880C722FA515AD380015', + timestamp: 31949.965149, + type: 'Document', + response: { + url: 'http://localhost:3000/', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'max-age=5', + Connection: 'keep-alive', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 34, + remoteIPAddress: '127.0.0.1', + remotePort: 3000, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 31949.959838, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.613, + sendEnd: 0.665, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 3.619, + }, + responseTime: 1.680698353573552e12, + protocol: 'http/1.1', + alternateProtocolUsage: 'unspecifiedReason', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + }); + mockCDPSession.emit('Network.loadingFinished', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + timestamp: 31949.963861, + encodedDataLength: 847, + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + documentURL: 'http://localhost:3000/redirect', + request: { + url: 'http://localhost:3000/redirect', + method: 'GET', + headers: { + Referer: 'http://localhost:3000/', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + 'sec-ch-ua-mobile': '?0', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 31949.982895, + wallTime: 1680698353.595079, + initiator: { + type: 'script', + stack: { + callFrames: [ + { + functionName: '', + scriptId: '5', + url: 'http://localhost:3000/', + lineNumber: 8, + columnNumber: 32, + }, + ], + }, + }, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + hasUserGesture: false, + }); + + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8', + Connection: 'keep-alive', + Host: 'localhost:3000', + Referer: 'http://localhost:3000/', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'same-origin', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + 'sec-ch-ua-mobile': '?0', + }, + connectTiming: {requestTime: 31949.983605}, + siteHasCookieInOtherPartition: false, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + blockedCookies: [], + headers: { + Connection: 'keep-alive', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + Location: 'http://localhost:3000/#from-redirect', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\\r\\nLocation: http://localhost:3000/#from-redirect\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n', + cookiePartitionKey: 'http://localhost', + cookiePartitionKeyOpaque: false, + }); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + documentURL: 'http://localhost:3000/', + request: { + url: 'http://localhost:3000/', + urlFragment: '#from-redirect', + method: 'GET', + headers: { + Referer: 'http://localhost:3000/', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + 'sec-ch-ua-mobile': '?0', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 31949.988506, + wallTime: 1680698353.60069, + initiator: { + type: 'script', + stack: { + callFrames: [ + { + functionName: '', + scriptId: '5', + url: 'http://localhost:3000/', + lineNumber: 8, + columnNumber: 32, + }, + ], + }, + }, + redirectHasExtraInfo: true, + redirectResponse: { + url: 'http://localhost:3000/redirect', + status: 302, + statusText: 'Found', + headers: { + Connection: 'keep-alive', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + Location: 'http://localhost:3000/#from-redirect', + 'Transfer-Encoding': 'chunked', + }, + mimeType: '', + connectionReused: true, + connectionId: 34, + remoteIPAddress: '127.0.0.1', + remotePort: 3000, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 182, + timing: { + receiveHeadersStart: 0, + requestTime: 31949.983605, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.364, + sendEnd: 0.401, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 4.085, + }, + responseTime: 1.680698353596548e12, + protocol: 'http/1.1', + alternateProtocolUsage: 'unspecifiedReason', + securityState: 'secure', + }, + type: 'Document', + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + associatedCookies: [], + headers: {}, + connectTiming: {requestTime: 31949.988855}, + siteHasCookieInOtherPartition: false, + }); + + mockCDPSession.emit('Network.responseReceived', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + timestamp: 31949.991319, + type: 'Document', + response: { + url: 'http://localhost:3000/', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'max-age=5', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + }, + mimeType: 'text/html', + connectionReused: false, + connectionId: 0, + remoteIPAddress: '127.0.0.1', + remotePort: 3000, + fromDiskCache: true, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 0, + timing: { + receiveHeadersStart: 0, + requestTime: 31949.988855, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.069, + sendEnd: 0.069, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.321, + }, + responseTime: 1.680698353573552e12, + protocol: 'http/1.1', + alternateProtocolUsage: 'unspecifiedReason', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + blockedCookies: [], + headers: { + Connection: 'keep-alive', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + Location: 'http://localhost:3000/#from-redirect', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\\r\\nLocation: http://localhost:3000/#from-redirect\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n', + cookiePartitionKey: 'http://localhost', + cookiePartitionKeyOpaque: false, + }); + mockCDPSession.emit('Network.loadingFinished', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + timestamp: 31949.989412, + encodedDataLength: 0, + }); + expect( + responses.map(r => { + return r.status(); + }) + ).toEqual([200, 302, 200]); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts new file mode 100644 index 0000000000..8b24b9a748 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts @@ -0,0 +1,710 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; +import type {Frame} from '../api/Frame.js'; +import {EventEmitter, EventSubscription} from '../common/EventEmitter.js'; +import { + NetworkManagerEvent, + type NetworkManagerEvents, +} from '../common/NetworkManagerEvents.js'; +import {debugError, isString} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {DisposableStack} from '../util/disposable.js'; + +import {CdpHTTPRequest} from './HTTPRequest.js'; +import {CdpHTTPResponse} from './HTTPResponse.js'; +import { + NetworkEventManager, + type FetchRequestId, +} from './NetworkEventManager.js'; + +/** + * @public + */ +export interface Credentials { + username: string; + password: string; +} + +/** + * @public + */ +export interface NetworkConditions { + // Download speed (bytes/s) + download: number; + // Upload speed (bytes/s) + upload: number; + // Latency (ms) + latency: number; +} + +/** + * @public + */ +export interface InternalNetworkConditions extends NetworkConditions { + offline: boolean; +} + +/** + * @internal + */ +export interface FrameProvider { + frame(id: string): Frame | null; +} + +/** + * @internal + */ +export class NetworkManager extends EventEmitter<NetworkManagerEvents> { + #ignoreHTTPSErrors: boolean; + #frameManager: FrameProvider; + #networkEventManager = new NetworkEventManager(); + #extraHTTPHeaders?: Record<string, string>; + #credentials?: Credentials; + #attemptedAuthentications = new Set<string>(); + #userRequestInterceptionEnabled = false; + #protocolRequestInterceptionEnabled = false; + #userCacheDisabled?: boolean; + #emulatedNetworkConditions?: InternalNetworkConditions; + #userAgent?: string; + #userAgentMetadata?: Protocol.Emulation.UserAgentMetadata; + + readonly #handlers = [ + ['Fetch.requestPaused', this.#onRequestPaused], + ['Fetch.authRequired', this.#onAuthRequired], + ['Network.requestWillBeSent', this.#onRequestWillBeSent], + ['Network.requestServedFromCache', this.#onRequestServedFromCache], + ['Network.responseReceived', this.#onResponseReceived], + ['Network.loadingFinished', this.#onLoadingFinished], + ['Network.loadingFailed', this.#onLoadingFailed], + ['Network.responseReceivedExtraInfo', this.#onResponseReceivedExtraInfo], + [CDPSessionEvent.Disconnected, this.#removeClient], + ] as const; + + #clients = new Map<CDPSession, DisposableStack>(); + + constructor(ignoreHTTPSErrors: boolean, frameManager: FrameProvider) { + super(); + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#frameManager = frameManager; + } + + async addClient(client: CDPSession): Promise<void> { + if (this.#clients.has(client)) { + return; + } + const subscriptions = new DisposableStack(); + this.#clients.set(client, subscriptions); + for (const [event, handler] of this.#handlers) { + subscriptions.use( + // TODO: Remove any here. + new EventSubscription(client, event, (arg: any) => { + return handler.bind(this)(client, arg); + }) + ); + } + await Promise.all([ + this.#ignoreHTTPSErrors + ? client.send('Security.setIgnoreCertificateErrors', { + ignore: true, + }) + : null, + client.send('Network.enable'), + this.#applyExtraHTTPHeaders(client), + this.#applyNetworkConditions(client), + this.#applyProtocolCacheDisabled(client), + this.#applyProtocolRequestInterception(client), + this.#applyUserAgent(client), + ]); + } + + async #removeClient(client: CDPSession) { + this.#clients.get(client)?.dispose(); + this.#clients.delete(client); + } + + async authenticate(credentials?: Credentials): Promise<void> { + this.#credentials = credentials; + const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials; + if (enabled === this.#protocolRequestInterceptionEnabled) { + return; + } + this.#protocolRequestInterceptionEnabled = enabled; + await this.#applyToAllClients( + this.#applyProtocolRequestInterception.bind(this) + ); + } + + async setExtraHTTPHeaders( + extraHTTPHeaders: Record<string, string> + ): Promise<void> { + this.#extraHTTPHeaders = {}; + for (const key of Object.keys(extraHTTPHeaders)) { + const value = extraHTTPHeaders[key]; + assert( + isString(value), + `Expected value of header "${key}" to be String, but "${typeof value}" is found.` + ); + this.#extraHTTPHeaders[key.toLowerCase()] = value; + } + + await this.#applyToAllClients(this.#applyExtraHTTPHeaders.bind(this)); + } + + async #applyExtraHTTPHeaders(client: CDPSession) { + if (this.#extraHTTPHeaders === undefined) { + return; + } + await client.send('Network.setExtraHTTPHeaders', { + headers: this.#extraHTTPHeaders, + }); + } + + extraHTTPHeaders(): Record<string, string> { + return Object.assign({}, this.#extraHTTPHeaders); + } + + inFlightRequestsCount(): number { + return this.#networkEventManager.inFlightRequestsCount(); + } + + async setOfflineMode(value: boolean): Promise<void> { + if (!this.#emulatedNetworkConditions) { + this.#emulatedNetworkConditions = { + offline: false, + upload: -1, + download: -1, + latency: 0, + }; + } + this.#emulatedNetworkConditions.offline = value; + await this.#applyToAllClients(this.#applyNetworkConditions.bind(this)); + } + + async emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void> { + if (!this.#emulatedNetworkConditions) { + this.#emulatedNetworkConditions = { + offline: false, + upload: -1, + download: -1, + latency: 0, + }; + } + this.#emulatedNetworkConditions.upload = networkConditions + ? networkConditions.upload + : -1; + this.#emulatedNetworkConditions.download = networkConditions + ? networkConditions.download + : -1; + this.#emulatedNetworkConditions.latency = networkConditions + ? networkConditions.latency + : 0; + + await this.#applyToAllClients(this.#applyNetworkConditions.bind(this)); + } + + async #applyToAllClients(fn: (client: CDPSession) => Promise<unknown>) { + await Promise.all( + Array.from(this.#clients.keys()).map(client => { + return fn(client); + }) + ); + } + + async #applyNetworkConditions(client: CDPSession): Promise<void> { + if (this.#emulatedNetworkConditions === undefined) { + return; + } + await client.send('Network.emulateNetworkConditions', { + offline: this.#emulatedNetworkConditions.offline, + latency: this.#emulatedNetworkConditions.latency, + uploadThroughput: this.#emulatedNetworkConditions.upload, + downloadThroughput: this.#emulatedNetworkConditions.download, + }); + } + + async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata + ): Promise<void> { + this.#userAgent = userAgent; + this.#userAgentMetadata = userAgentMetadata; + await this.#applyToAllClients(this.#applyUserAgent.bind(this)); + } + + async #applyUserAgent(client: CDPSession) { + if (this.#userAgent === undefined) { + return; + } + await client.send('Network.setUserAgentOverride', { + userAgent: this.#userAgent, + userAgentMetadata: this.#userAgentMetadata, + }); + } + + async setCacheEnabled(enabled: boolean): Promise<void> { + this.#userCacheDisabled = !enabled; + await this.#applyToAllClients(this.#applyProtocolCacheDisabled.bind(this)); + } + + async setRequestInterception(value: boolean): Promise<void> { + this.#userRequestInterceptionEnabled = value; + const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials; + if (enabled === this.#protocolRequestInterceptionEnabled) { + return; + } + this.#protocolRequestInterceptionEnabled = enabled; + await this.#applyToAllClients( + this.#applyProtocolRequestInterception.bind(this) + ); + } + + async #applyProtocolRequestInterception(client: CDPSession): Promise<void> { + if (this.#userCacheDisabled === undefined) { + this.#userCacheDisabled = false; + } + if (this.#protocolRequestInterceptionEnabled) { + await Promise.all([ + this.#applyProtocolCacheDisabled(client), + client.send('Fetch.enable', { + handleAuthRequests: true, + patterns: [{urlPattern: '*'}], + }), + ]); + } else { + await Promise.all([ + this.#applyProtocolCacheDisabled(client), + client.send('Fetch.disable'), + ]); + } + } + + async #applyProtocolCacheDisabled(client: CDPSession): Promise<void> { + if (this.#userCacheDisabled === undefined) { + return; + } + await client.send('Network.setCacheDisabled', { + cacheDisabled: this.#userCacheDisabled, + }); + } + + #onRequestWillBeSent( + client: CDPSession, + event: Protocol.Network.RequestWillBeSentEvent + ): void { + // Request interception doesn't happen for data URLs with Network Service. + if ( + this.#userRequestInterceptionEnabled && + !event.request.url.startsWith('data:') + ) { + const {requestId: networkRequestId} = event; + + this.#networkEventManager.storeRequestWillBeSent(networkRequestId, event); + + /** + * CDP may have sent a Fetch.requestPaused event already. Check for it. + */ + const requestPausedEvent = + this.#networkEventManager.getRequestPaused(networkRequestId); + if (requestPausedEvent) { + const {requestId: fetchRequestId} = requestPausedEvent; + this.#patchRequestEventHeaders(event, requestPausedEvent); + this.#onRequest(client, event, fetchRequestId); + this.#networkEventManager.forgetRequestPaused(networkRequestId); + } + + return; + } + this.#onRequest(client, event, undefined); + } + + #onAuthRequired( + client: CDPSession, + event: Protocol.Fetch.AuthRequiredEvent + ): void { + let response: Protocol.Fetch.AuthChallengeResponse['response'] = 'Default'; + if (this.#attemptedAuthentications.has(event.requestId)) { + response = 'CancelAuth'; + } else if (this.#credentials) { + response = 'ProvideCredentials'; + this.#attemptedAuthentications.add(event.requestId); + } + const {username, password} = this.#credentials || { + username: undefined, + password: undefined, + }; + client + .send('Fetch.continueWithAuth', { + requestId: event.requestId, + authChallengeResponse: {response, username, password}, + }) + .catch(debugError); + } + + /** + * CDP may send a Fetch.requestPaused without or before a + * Network.requestWillBeSent + * + * CDP may send multiple Fetch.requestPaused + * for the same Network.requestWillBeSent. + */ + #onRequestPaused( + client: CDPSession, + event: Protocol.Fetch.RequestPausedEvent + ): void { + if ( + !this.#userRequestInterceptionEnabled && + this.#protocolRequestInterceptionEnabled + ) { + client + .send('Fetch.continueRequest', { + requestId: event.requestId, + }) + .catch(debugError); + } + + const {networkId: networkRequestId, requestId: fetchRequestId} = event; + + if (!networkRequestId) { + this.#onRequestWithoutNetworkInstrumentation(client, event); + return; + } + + const requestWillBeSentEvent = (() => { + const requestWillBeSentEvent = + this.#networkEventManager.getRequestWillBeSent(networkRequestId); + + // redirect requests have the same `requestId`, + if ( + requestWillBeSentEvent && + (requestWillBeSentEvent.request.url !== event.request.url || + requestWillBeSentEvent.request.method !== event.request.method) + ) { + this.#networkEventManager.forgetRequestWillBeSent(networkRequestId); + return; + } + return requestWillBeSentEvent; + })(); + + if (requestWillBeSentEvent) { + this.#patchRequestEventHeaders(requestWillBeSentEvent, event); + this.#onRequest(client, requestWillBeSentEvent, fetchRequestId); + } else { + this.#networkEventManager.storeRequestPaused(networkRequestId, event); + } + } + + #patchRequestEventHeaders( + requestWillBeSentEvent: Protocol.Network.RequestWillBeSentEvent, + requestPausedEvent: Protocol.Fetch.RequestPausedEvent + ): void { + requestWillBeSentEvent.request.headers = { + ...requestWillBeSentEvent.request.headers, + // includes extra headers, like: Accept, Origin + ...requestPausedEvent.request.headers, + }; + } + + #onRequestWithoutNetworkInstrumentation( + client: CDPSession, + event: Protocol.Fetch.RequestPausedEvent + ): void { + // If an event has no networkId it should not have any network events. We + // still want to dispatch it for the interception by the user. + const frame = event.frameId + ? this.#frameManager.frame(event.frameId) + : null; + + const request = new CdpHTTPRequest( + client, + frame, + event.requestId, + this.#userRequestInterceptionEnabled, + event, + [] + ); + this.emit(NetworkManagerEvent.Request, request); + void request.finalizeInterceptions(); + } + + #onRequest( + client: CDPSession, + event: Protocol.Network.RequestWillBeSentEvent, + fetchRequestId?: FetchRequestId + ): void { + let redirectChain: CdpHTTPRequest[] = []; + if (event.redirectResponse) { + // We want to emit a response and requestfinished for the + // redirectResponse, but we can't do so unless we have a + // responseExtraInfo ready to pair it up with. If we don't have any + // responseExtraInfos saved in our queue, they we have to wait until + // the next one to emit response and requestfinished, *and* we should + // also wait to emit this Request too because it should come after the + // response/requestfinished. + let redirectResponseExtraInfo = null; + if (event.redirectHasExtraInfo) { + redirectResponseExtraInfo = this.#networkEventManager + .responseExtraInfo(event.requestId) + .shift(); + if (!redirectResponseExtraInfo) { + this.#networkEventManager.queueRedirectInfo(event.requestId, { + event, + fetchRequestId, + }); + return; + } + } + + const request = this.#networkEventManager.getRequest(event.requestId); + // If we connect late to the target, we could have missed the + // requestWillBeSent event. + if (request) { + this.#handleRequestRedirect( + client, + request, + event.redirectResponse, + redirectResponseExtraInfo + ); + redirectChain = request._redirectChain; + } + } + const frame = event.frameId + ? this.#frameManager.frame(event.frameId) + : null; + + const request = new CdpHTTPRequest( + client, + frame, + fetchRequestId, + this.#userRequestInterceptionEnabled, + event, + redirectChain + ); + this.#networkEventManager.storeRequest(event.requestId, request); + this.emit(NetworkManagerEvent.Request, request); + void request.finalizeInterceptions(); + } + + #onRequestServedFromCache( + _client: CDPSession, + event: Protocol.Network.RequestServedFromCacheEvent + ): void { + const request = this.#networkEventManager.getRequest(event.requestId); + if (request) { + request._fromMemoryCache = true; + } + this.emit(NetworkManagerEvent.RequestServedFromCache, request); + } + + #handleRequestRedirect( + client: CDPSession, + request: CdpHTTPRequest, + responsePayload: Protocol.Network.Response, + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ): void { + const response = new CdpHTTPResponse( + client, + request, + responsePayload, + extraInfo + ); + request._response = response; + request._redirectChain.push(request); + response._resolveBody( + new Error('Response body is unavailable for redirect responses') + ); + this.#forgetRequest(request, false); + this.emit(NetworkManagerEvent.Response, response); + this.emit(NetworkManagerEvent.RequestFinished, request); + } + + #emitResponseEvent( + client: CDPSession, + responseReceived: Protocol.Network.ResponseReceivedEvent, + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ): void { + const request = this.#networkEventManager.getRequest( + responseReceived.requestId + ); + // FileUpload sends a response without a matching request. + if (!request) { + return; + } + + const extraInfos = this.#networkEventManager.responseExtraInfo( + responseReceived.requestId + ); + if (extraInfos.length) { + debugError( + new Error( + 'Unexpected extraInfo events for request ' + + responseReceived.requestId + ) + ); + } + + // Chromium sends wrong extraInfo events for responses served from cache. + // See https://github.com/puppeteer/puppeteer/issues/9965 and + // https://crbug.com/1340398. + if (responseReceived.response.fromDiskCache) { + extraInfo = null; + } + + const response = new CdpHTTPResponse( + client, + request, + responseReceived.response, + extraInfo + ); + request._response = response; + this.emit(NetworkManagerEvent.Response, response); + } + + #onResponseReceived( + client: CDPSession, + event: Protocol.Network.ResponseReceivedEvent + ): void { + const request = this.#networkEventManager.getRequest(event.requestId); + let extraInfo = null; + if (request && !request._fromMemoryCache && event.hasExtraInfo) { + extraInfo = this.#networkEventManager + .responseExtraInfo(event.requestId) + .shift(); + if (!extraInfo) { + // Wait until we get the corresponding ExtraInfo event. + this.#networkEventManager.queueEventGroup(event.requestId, { + responseReceivedEvent: event, + }); + return; + } + } + this.#emitResponseEvent(client, event, extraInfo); + } + + #onResponseReceivedExtraInfo( + client: CDPSession, + event: Protocol.Network.ResponseReceivedExtraInfoEvent + ): void { + // We may have skipped a redirect response/request pair due to waiting for + // this ExtraInfo event. If so, continue that work now that we have the + // request. + const redirectInfo = this.#networkEventManager.takeQueuedRedirectInfo( + event.requestId + ); + if (redirectInfo) { + this.#networkEventManager.responseExtraInfo(event.requestId).push(event); + this.#onRequest(client, redirectInfo.event, redirectInfo.fetchRequestId); + return; + } + + // We may have skipped response and loading events because we didn't have + // this ExtraInfo event yet. If so, emit those events now. + const queuedEvents = this.#networkEventManager.getQueuedEventGroup( + event.requestId + ); + if (queuedEvents) { + this.#networkEventManager.forgetQueuedEventGroup(event.requestId); + this.#emitResponseEvent( + client, + queuedEvents.responseReceivedEvent, + event + ); + if (queuedEvents.loadingFinishedEvent) { + this.#emitLoadingFinished(queuedEvents.loadingFinishedEvent); + } + if (queuedEvents.loadingFailedEvent) { + this.#emitLoadingFailed(queuedEvents.loadingFailedEvent); + } + return; + } + + // Wait until we get another event that can use this ExtraInfo event. + this.#networkEventManager.responseExtraInfo(event.requestId).push(event); + } + + #forgetRequest(request: CdpHTTPRequest, events: boolean): void { + const requestId = request._requestId; + const interceptionId = request._interceptionId; + + this.#networkEventManager.forgetRequest(requestId); + interceptionId !== undefined && + this.#attemptedAuthentications.delete(interceptionId); + + if (events) { + this.#networkEventManager.forget(requestId); + } + } + + #onLoadingFinished( + _client: CDPSession, + event: Protocol.Network.LoadingFinishedEvent + ): void { + // If the response event for this request is still waiting on a + // corresponding ExtraInfo event, then wait to emit this event too. + const queuedEvents = this.#networkEventManager.getQueuedEventGroup( + event.requestId + ); + if (queuedEvents) { + queuedEvents.loadingFinishedEvent = event; + } else { + this.#emitLoadingFinished(event); + } + } + + #emitLoadingFinished(event: Protocol.Network.LoadingFinishedEvent): void { + const request = this.#networkEventManager.getRequest(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) { + return; + } + + // Under certain conditions we never get the Network.responseReceived + // event from protocol. @see https://crbug.com/883475 + if (request.response()) { + request.response()?._resolveBody(); + } + this.#forgetRequest(request, true); + this.emit(NetworkManagerEvent.RequestFinished, request); + } + + #onLoadingFailed( + _client: CDPSession, + event: Protocol.Network.LoadingFailedEvent + ): void { + // If the response event for this request is still waiting on a + // corresponding ExtraInfo event, then wait to emit this event too. + const queuedEvents = this.#networkEventManager.getQueuedEventGroup( + event.requestId + ); + if (queuedEvents) { + queuedEvents.loadingFailedEvent = event; + } else { + this.#emitLoadingFailed(event); + } + } + + #emitLoadingFailed(event: Protocol.Network.LoadingFailedEvent): void { + const request = this.#networkEventManager.getRequest(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) { + return; + } + request._failureText = event.errorText; + const response = request.response(); + if (response) { + response._resolveBody(); + } + this.#forgetRequest(request, true); + this.emit(NetworkManagerEvent.RequestFailed, request); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts new file mode 100644 index 0000000000..491637f0ea --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts @@ -0,0 +1,1249 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Readable} from 'stream'; + +import type {Protocol} from 'devtools-protocol'; + +import {firstValueFrom, from, raceWith} from '../../third_party/rxjs/rxjs.js'; +import type {Browser} from '../api/Browser.js'; +import type {BrowserContext} from '../api/BrowserContext.js'; +import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {Frame, WaitForOptions} from '../api/Frame.js'; +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import { + Page, + PageEvent, + type GeolocationOptions, + type MediaFeature, + type Metrics, + type NewDocumentScriptEvaluation, + type ScreenshotClip, + type ScreenshotOptions, + type WaitTimeoutOptions, +} from '../api/Page.js'; +import { + ConsoleMessage, + type ConsoleMessageType, +} from '../common/ConsoleMessage.js'; +import {TargetCloseError} from '../common/Errors.js'; +import {FileChooser} from '../common/FileChooser.js'; +import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; +import type {PDFOptions} from '../common/PDFOptions.js'; +import type {BindingPayload, HandleFor} from '../common/types.js'; +import { + debugError, + evaluationString, + getReadableAsBuffer, + getReadableFromProtocolStream, + parsePDFOptions, + timeout, + validateDialogType, +} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {AsyncDisposableStack} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {Accessibility} from './Accessibility.js'; +import {Binding} from './Binding.js'; +import {CdpCDPSession} from './CDPSession.js'; +import {isTargetClosedError} from './Connection.js'; +import {Coverage} from './Coverage.js'; +import type {DeviceRequestPrompt} from './DeviceRequestPrompt.js'; +import {CdpDialog} from './Dialog.js'; +import {EmulationManager} from './EmulationManager.js'; +import {createCdpHandle} from './ExecutionContext.js'; +import {FirefoxTargetManager} from './FirefoxTargetManager.js'; +import type {CdpFrame} from './Frame.js'; +import {FrameManager} from './FrameManager.js'; +import {FrameManagerEvent} from './FrameManagerEvents.js'; +import {CdpKeyboard, CdpMouse, CdpTouchscreen} from './Input.js'; +import {MAIN_WORLD} from './IsolatedWorlds.js'; +import {releaseObject} from './JSHandle.js'; +import type {Credentials, NetworkConditions} from './NetworkManager.js'; +import type {CdpTarget} from './Target.js'; +import type {TargetManager} from './TargetManager.js'; +import {TargetManagerEvent} from './TargetManager.js'; +import {Tracing} from './Tracing.js'; +import { + createClientError, + pageBindingInitString, + valueFromRemoteObject, +} from './utils.js'; +import {CdpWebWorker} from './WebWorker.js'; + +/** + * @internal + */ +export class CdpPage extends Page { + static async _create( + client: CDPSession, + target: CdpTarget, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ): Promise<CdpPage> { + const page = new CdpPage(client, target, ignoreHTTPSErrors); + await page.#initialize(); + if (defaultViewport) { + try { + await page.setViewport(defaultViewport); + } catch (err) { + if (isErrorLike(err) && isTargetClosedError(err)) { + debugError(err); + } else { + throw err; + } + } + } + return page; + } + + #closed = false; + readonly #targetManager: TargetManager; + + #primaryTargetClient: CDPSession; + #primaryTarget: CdpTarget; + #tabTargetClient: CDPSession; + #tabTarget: CdpTarget; + #keyboard: CdpKeyboard; + #mouse: CdpMouse; + #touchscreen: CdpTouchscreen; + #accessibility: Accessibility; + #frameManager: FrameManager; + #emulationManager: EmulationManager; + #tracing: Tracing; + #bindings = new Map<string, Binding>(); + #exposedFunctions = new Map<string, string>(); + #coverage: Coverage; + #viewport: Viewport | null; + #workers = new Map<string, CdpWebWorker>(); + #fileChooserDeferreds = new Set<Deferred<FileChooser>>(); + #sessionCloseDeferred = Deferred.create<never, TargetCloseError>(); + #serviceWorkerBypassed = false; + #userDragInterceptionEnabled = false; + + readonly #frameManagerHandlers = [ + [ + FrameManagerEvent.FrameAttached, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameAttached, frame); + }, + ], + [ + FrameManagerEvent.FrameDetached, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameDetached, frame); + }, + ], + [ + FrameManagerEvent.FrameNavigated, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameNavigated, frame); + }, + ], + ] as const; + + readonly #networkManagerHandlers = [ + [ + NetworkManagerEvent.Request, + (request: HTTPRequest) => { + this.emit(PageEvent.Request, request); + }, + ], + [ + NetworkManagerEvent.RequestServedFromCache, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestServedFromCache, request); + }, + ], + [ + NetworkManagerEvent.Response, + (response: HTTPResponse) => { + this.emit(PageEvent.Response, response); + }, + ], + [ + NetworkManagerEvent.RequestFailed, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestFailed, request); + }, + ], + [ + NetworkManagerEvent.RequestFinished, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestFinished, request); + }, + ], + ] as const; + + readonly #sessionHandlers = [ + [ + CDPSessionEvent.Disconnected, + () => { + this.#sessionCloseDeferred.reject( + new TargetCloseError('Target closed') + ); + }, + ], + [ + 'Page.domContentEventFired', + () => { + return this.emit(PageEvent.DOMContentLoaded, undefined); + }, + ], + [ + 'Page.loadEventFired', + () => { + return this.emit(PageEvent.Load, undefined); + }, + ], + ['Runtime.consoleAPICalled', this.#onConsoleAPI.bind(this)], + ['Runtime.bindingCalled', this.#onBindingCalled.bind(this)], + ['Page.javascriptDialogOpening', this.#onDialog.bind(this)], + ['Runtime.exceptionThrown', this.#handleException.bind(this)], + ['Inspector.targetCrashed', this.#onTargetCrashed.bind(this)], + ['Performance.metrics', this.#emitMetrics.bind(this)], + ['Log.entryAdded', this.#onLogEntryAdded.bind(this)], + ['Page.fileChooserOpened', this.#onFileChooser.bind(this)], + ] as const; + + constructor( + client: CDPSession, + target: CdpTarget, + ignoreHTTPSErrors: boolean + ) { + super(); + this.#primaryTargetClient = client; + this.#tabTargetClient = client.parentSession()!; + assert(this.#tabTargetClient, 'Tab target session is not defined.'); + this.#tabTarget = (this.#tabTargetClient as CdpCDPSession)._target(); + assert(this.#tabTarget, 'Tab target is not defined.'); + this.#primaryTarget = target; + this.#targetManager = target._targetManager(); + this.#keyboard = new CdpKeyboard(client); + this.#mouse = new CdpMouse(client, this.#keyboard); + this.#touchscreen = new CdpTouchscreen(client, this.#keyboard); + this.#accessibility = new Accessibility(client); + this.#frameManager = new FrameManager( + client, + this, + ignoreHTTPSErrors, + this._timeoutSettings + ); + this.#emulationManager = new EmulationManager(client); + this.#tracing = new Tracing(client); + this.#coverage = new Coverage(client); + this.#viewport = null; + + for (const [eventName, handler] of this.#frameManagerHandlers) { + this.#frameManager.on(eventName, handler); + } + + for (const [eventName, handler] of this.#networkManagerHandlers) { + // TODO: Remove any. + this.#frameManager.networkManager.on(eventName, handler as any); + } + + this.#tabTargetClient.on( + CDPSessionEvent.Swapped, + this.#onActivation.bind(this) + ); + + this.#tabTargetClient.on( + CDPSessionEvent.Ready, + this.#onSecondaryTarget.bind(this) + ); + + this.#targetManager.on( + TargetManagerEvent.TargetGone, + this.#onDetachedFromTarget + ); + + this.#tabTarget._isClosedDeferred + .valueOrThrow() + .then(() => { + this.#targetManager.off( + TargetManagerEvent.TargetGone, + this.#onDetachedFromTarget + ); + + this.emit(PageEvent.Close, undefined); + this.#closed = true; + }) + .catch(debugError); + + this.#setupPrimaryTargetListeners(); + } + + async #onActivation(newSession: CDPSession): Promise<void> { + this.#primaryTargetClient = newSession; + assert( + this.#primaryTargetClient instanceof CdpCDPSession, + 'CDPSession is not instance of CDPSessionImpl' + ); + this.#primaryTarget = this.#primaryTargetClient._target(); + assert(this.#primaryTarget, 'Missing target on swap'); + this.#keyboard.updateClient(newSession); + this.#mouse.updateClient(newSession); + this.#touchscreen.updateClient(newSession); + this.#accessibility.updateClient(newSession); + this.#emulationManager.updateClient(newSession); + this.#tracing.updateClient(newSession); + this.#coverage.updateClient(newSession); + await this.#frameManager.swapFrameTree(newSession); + this.#setupPrimaryTargetListeners(); + } + + async #onSecondaryTarget(session: CDPSession): Promise<void> { + assert(session instanceof CdpCDPSession); + if (session._target()._subtype() !== 'prerender') { + return; + } + this.#frameManager.registerSpeculativeSession(session).catch(debugError); + this.#emulationManager + .registerSpeculativeSession(session) + .catch(debugError); + } + + /** + * Sets up listeners for the primary target. The primary target can change + * during a navigation to a prerended page. + */ + #setupPrimaryTargetListeners() { + this.#primaryTargetClient.on( + CDPSessionEvent.Ready, + this.#onAttachedToTarget + ); + + for (const [eventName, handler] of this.#sessionHandlers) { + // TODO: Remove any. + this.#primaryTargetClient.on(eventName, handler as any); + } + } + + #onDetachedFromTarget = (target: CdpTarget) => { + const sessionId = target._session()?.id(); + const worker = this.#workers.get(sessionId!); + if (!worker) { + return; + } + this.#workers.delete(sessionId!); + this.emit(PageEvent.WorkerDestroyed, worker); + }; + + #onAttachedToTarget = (session: CDPSession) => { + assert(session instanceof CdpCDPSession); + this.#frameManager.onAttachedToTarget(session._target()); + if (session._target()._getTargetInfo().type === 'worker') { + const worker = new CdpWebWorker( + session, + session._target().url(), + this.#addConsoleMessage.bind(this), + this.#handleException.bind(this) + ); + this.#workers.set(session.id(), worker); + this.emit(PageEvent.WorkerCreated, worker); + } + session.on(CDPSessionEvent.Ready, this.#onAttachedToTarget); + }; + + async #initialize(): Promise<void> { + try { + await Promise.all([ + this.#frameManager.initialize(this.#primaryTargetClient), + this.#primaryTargetClient.send('Performance.enable'), + this.#primaryTargetClient.send('Log.enable'), + ]); + } catch (err) { + if (isErrorLike(err) && isTargetClosedError(err)) { + debugError(err); + } else { + throw err; + } + } + } + + async #onFileChooser( + event: Protocol.Page.FileChooserOpenedEvent + ): Promise<void> { + if (!this.#fileChooserDeferreds.size) { + return; + } + + const frame = this.#frameManager.frame(event.frameId); + assert(frame, 'This should never happen.'); + + // This is guaranteed to be an HTMLInputElement handle by the event. + using handle = (await frame.worlds[MAIN_WORLD].adoptBackendNode( + event.backendNodeId + )) as ElementHandle<HTMLInputElement>; + + const fileChooser = new FileChooser(handle.move(), event); + for (const promise of this.#fileChooserDeferreds) { + promise.resolve(fileChooser); + } + this.#fileChooserDeferreds.clear(); + } + + _client(): CDPSession { + return this.#primaryTargetClient; + } + + override isServiceWorkerBypassed(): boolean { + return this.#serviceWorkerBypassed; + } + + override isDragInterceptionEnabled(): boolean { + return this.#userDragInterceptionEnabled; + } + + override isJavaScriptEnabled(): boolean { + return this.#emulationManager.javascriptEnabled; + } + + override async waitForFileChooser( + options: WaitTimeoutOptions = {} + ): Promise<FileChooser> { + const needsEnable = this.#fileChooserDeferreds.size === 0; + const {timeout = this._timeoutSettings.timeout()} = options; + const deferred = Deferred.create<FileChooser>({ + message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`, + timeout, + }); + this.#fileChooserDeferreds.add(deferred); + let enablePromise: Promise<void> | undefined; + if (needsEnable) { + enablePromise = this.#primaryTargetClient.send( + 'Page.setInterceptFileChooserDialog', + { + enabled: true, + } + ); + } + try { + const [result] = await Promise.all([ + deferred.valueOrThrow(), + enablePromise, + ]); + return result; + } catch (error) { + this.#fileChooserDeferreds.delete(deferred); + throw error; + } + } + + override async setGeolocation(options: GeolocationOptions): Promise<void> { + return await this.#emulationManager.setGeolocation(options); + } + + override target(): CdpTarget { + return this.#primaryTarget; + } + + override browser(): Browser { + return this.#primaryTarget.browser(); + } + + override browserContext(): BrowserContext { + return this.#primaryTarget.browserContext(); + } + + #onTargetCrashed(): void { + this.emit(PageEvent.Error, new Error('Page crashed!')); + } + + #onLogEntryAdded(event: Protocol.Log.EntryAddedEvent): void { + const {level, text, args, source, url, lineNumber} = event.entry; + if (args) { + args.map(arg => { + void releaseObject(this.#primaryTargetClient, arg); + }); + } + if (source !== 'worker') { + this.emit( + PageEvent.Console, + new ConsoleMessage(level, text, [], [{url, lineNumber}]) + ); + } + } + + override mainFrame(): CdpFrame { + return this.#frameManager.mainFrame(); + } + + override get keyboard(): CdpKeyboard { + return this.#keyboard; + } + + override get touchscreen(): CdpTouchscreen { + return this.#touchscreen; + } + + override get coverage(): Coverage { + return this.#coverage; + } + + override get tracing(): Tracing { + return this.#tracing; + } + + override get accessibility(): Accessibility { + return this.#accessibility; + } + + override frames(): Frame[] { + return this.#frameManager.frames(); + } + + override workers(): CdpWebWorker[] { + return Array.from(this.#workers.values()); + } + + override async setRequestInterception(value: boolean): Promise<void> { + return await this.#frameManager.networkManager.setRequestInterception( + value + ); + } + + override async setBypassServiceWorker(bypass: boolean): Promise<void> { + this.#serviceWorkerBypassed = bypass; + return await this.#primaryTargetClient.send( + 'Network.setBypassServiceWorker', + {bypass} + ); + } + + override async setDragInterception(enabled: boolean): Promise<void> { + this.#userDragInterceptionEnabled = enabled; + return await this.#primaryTargetClient.send('Input.setInterceptDrags', { + enabled, + }); + } + + override async setOfflineMode(enabled: boolean): Promise<void> { + return await this.#frameManager.networkManager.setOfflineMode(enabled); + } + + override async emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void> { + return await this.#frameManager.networkManager.emulateNetworkConditions( + networkConditions + ); + } + + override setDefaultNavigationTimeout(timeout: number): void { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + override setDefaultTimeout(timeout: number): void { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + override getDefaultTimeout(): number { + return this._timeoutSettings.timeout(); + } + + override async queryObjects<Prototype>( + prototypeHandle: JSHandle<Prototype> + ): Promise<JSHandle<Prototype[]>> { + assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); + assert( + prototypeHandle.id, + 'Prototype JSHandle must not be referencing primitive value' + ); + const response = await this.mainFrame().client.send( + 'Runtime.queryObjects', + { + prototypeObjectId: prototypeHandle.id, + } + ); + return createCdpHandle( + this.mainFrame().mainRealm(), + response.objects + ) as HandleFor<Prototype[]>; + } + + override async cookies( + ...urls: string[] + ): Promise<Protocol.Network.Cookie[]> { + const originalCookies = ( + await this.#primaryTargetClient.send('Network.getCookies', { + urls: urls.length ? urls : [this.url()], + }) + ).cookies; + + const unsupportedCookieAttributes = ['priority']; + const filterUnsupportedAttributes = ( + cookie: Protocol.Network.Cookie + ): Protocol.Network.Cookie => { + for (const attr of unsupportedCookieAttributes) { + delete (cookie as unknown as Record<string, unknown>)[attr]; + } + return cookie; + }; + return originalCookies.map(filterUnsupportedAttributes); + } + + override async deleteCookie( + ...cookies: Protocol.Network.DeleteCookiesRequest[] + ): Promise<void> { + const pageURL = this.url(); + for (const cookie of cookies) { + const item = Object.assign({}, cookie); + if (!cookie.url && pageURL.startsWith('http')) { + item.url = pageURL; + } + await this.#primaryTargetClient.send('Network.deleteCookies', item); + } + } + + override async setCookie( + ...cookies: Protocol.Network.CookieParam[] + ): Promise<void> { + const pageURL = this.url(); + const startsWithHTTP = pageURL.startsWith('http'); + const items = cookies.map(cookie => { + const item = Object.assign({}, cookie); + if (!item.url && startsWithHTTP) { + item.url = pageURL; + } + assert( + item.url !== 'about:blank', + `Blank page can not have cookie "${item.name}"` + ); + assert( + !String.prototype.startsWith.call(item.url || '', 'data:'), + `Data URL page can not have cookie "${item.name}"` + ); + return item; + }); + await this.deleteCookie(...items); + if (items.length) { + await this.#primaryTargetClient.send('Network.setCookies', { + cookies: items, + }); + } + } + + override async exposeFunction( + name: string, + pptrFunction: Function | {default: Function} + ): Promise<void> { + if (this.#bindings.has(name)) { + throw new Error( + `Failed to add page binding with name ${name}: window['${name}'] already exists!` + ); + } + + let binding: Binding; + switch (typeof pptrFunction) { + case 'function': + binding = new Binding( + name, + pptrFunction as (...args: unknown[]) => unknown + ); + break; + default: + binding = new Binding( + name, + pptrFunction.default as (...args: unknown[]) => unknown + ); + break; + } + + this.#bindings.set(name, binding); + + const expression = pageBindingInitString('exposedFun', name); + await this.#primaryTargetClient.send('Runtime.addBinding', {name}); + // TODO: investigate this as it appears to only apply to the main frame and + // local subframes instead of the entire frame tree (including future + // frame). + const {identifier} = await this.#primaryTargetClient.send( + 'Page.addScriptToEvaluateOnNewDocument', + { + source: expression, + } + ); + + this.#exposedFunctions.set(name, identifier); + + await Promise.all( + this.frames().map(frame => { + // If a frame has not started loading, it might never start. Rely on + // addScriptToEvaluateOnNewDocument in that case. + if (frame !== this.mainFrame() && !frame._hasStartedLoading) { + return; + } + return frame.evaluate(expression).catch(debugError); + }) + ); + } + + override async removeExposedFunction(name: string): Promise<void> { + const exposedFun = this.#exposedFunctions.get(name); + if (!exposedFun) { + throw new Error( + `Failed to remove page binding with name ${name}: window['${name}'] does not exists!` + ); + } + + await this.#primaryTargetClient.send('Runtime.removeBinding', {name}); + await this.removeScriptToEvaluateOnNewDocument(exposedFun); + + await Promise.all( + this.frames().map(frame => { + // If a frame has not started loading, it might never start. Rely on + // addScriptToEvaluateOnNewDocument in that case. + if (frame !== this.mainFrame() && !frame._hasStartedLoading) { + return; + } + return frame + .evaluate(name => { + // Removes the dangling Puppeteer binding wrapper. + // @ts-expect-error: In a different context. + globalThis[name] = undefined; + }, name) + .catch(debugError); + }) + ); + + this.#exposedFunctions.delete(name); + this.#bindings.delete(name); + } + + override async authenticate(credentials: Credentials): Promise<void> { + return await this.#frameManager.networkManager.authenticate(credentials); + } + + override async setExtraHTTPHeaders( + headers: Record<string, string> + ): Promise<void> { + return await this.#frameManager.networkManager.setExtraHTTPHeaders(headers); + } + + override async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata + ): Promise<void> { + return await this.#frameManager.networkManager.setUserAgent( + userAgent, + userAgentMetadata + ); + } + + override async metrics(): Promise<Metrics> { + const response = await this.#primaryTargetClient.send( + 'Performance.getMetrics' + ); + return this.#buildMetricsObject(response.metrics); + } + + #emitMetrics(event: Protocol.Performance.MetricsEvent): void { + this.emit(PageEvent.Metrics, { + title: event.title, + metrics: this.#buildMetricsObject(event.metrics), + }); + } + + #buildMetricsObject(metrics?: Protocol.Performance.Metric[]): Metrics { + const result: Record< + Protocol.Performance.Metric['name'], + Protocol.Performance.Metric['value'] + > = {}; + for (const metric of metrics || []) { + if (supportedMetrics.has(metric.name)) { + result[metric.name] = metric.value; + } + } + return result; + } + + #handleException(exception: Protocol.Runtime.ExceptionThrownEvent): void { + this.emit( + PageEvent.PageError, + createClientError(exception.exceptionDetails) + ); + } + + async #onConsoleAPI( + event: Protocol.Runtime.ConsoleAPICalledEvent + ): Promise<void> { + if (event.executionContextId === 0) { + // DevTools protocol stores the last 1000 console messages. These + // messages are always reported even for removed execution contexts. In + // this case, they are marked with executionContextId = 0 and are + // reported upon enabling Runtime agent. + // + // Ignore these messages since: + // - there's no execution context we can use to operate with message + // arguments + // - these messages are reported before Puppeteer clients can subscribe + // to the 'console' + // page event. + // + // @see https://github.com/puppeteer/puppeteer/issues/3865 + return; + } + const context = this.#frameManager.getExecutionContextById( + event.executionContextId, + this.#primaryTargetClient + ); + if (!context) { + debugError( + new Error( + `ExecutionContext not found for a console message: ${JSON.stringify( + event + )}` + ) + ); + return; + } + const values = event.args.map(arg => { + return createCdpHandle(context._world, arg); + }); + this.#addConsoleMessage(event.type, values, event.stackTrace); + } + + async #onBindingCalled( + event: Protocol.Runtime.BindingCalledEvent + ): Promise<void> { + let payload: BindingPayload; + try { + payload = JSON.parse(event.payload); + } catch { + // The binding was either called by something in the page or it was + // called before our wrapper was initialized. + return; + } + const {type, name, seq, args, isTrivial} = payload; + if (type !== 'exposedFun') { + return; + } + + const context = this.#frameManager.executionContextById( + event.executionContextId, + this.#primaryTargetClient + ); + if (!context) { + return; + } + + const binding = this.#bindings.get(name); + await binding?.run(context, seq, args, isTrivial); + } + + #addConsoleMessage( + eventType: ConsoleMessageType, + args: JSHandle[], + stackTrace?: Protocol.Runtime.StackTrace + ): void { + if (!this.listenerCount(PageEvent.Console)) { + args.forEach(arg => { + return arg.dispose(); + }); + return; + } + const textTokens = []; + // eslint-disable-next-line max-len -- The comment is long. + // eslint-disable-next-line rulesdir/use-using -- These are not owned by this function. + for (const arg of args) { + const remoteObject = arg.remoteObject(); + if (remoteObject.objectId) { + textTokens.push(arg.toString()); + } else { + textTokens.push(valueFromRemoteObject(remoteObject)); + } + } + const stackTraceLocations = []; + if (stackTrace) { + for (const callFrame of stackTrace.callFrames) { + stackTraceLocations.push({ + url: callFrame.url, + lineNumber: callFrame.lineNumber, + columnNumber: callFrame.columnNumber, + }); + } + } + const message = new ConsoleMessage( + eventType, + textTokens.join(' '), + args, + stackTraceLocations + ); + this.emit(PageEvent.Console, message); + } + + #onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void { + const type = validateDialogType(event.type); + const dialog = new CdpDialog( + this.#primaryTargetClient, + type, + event.message, + event.defaultPrompt + ); + this.emit(PageEvent.Dialog, dialog); + } + + override async reload( + options?: WaitForOptions + ): Promise<HTTPResponse | null> { + const [result] = await Promise.all([ + this.waitForNavigation(options), + this.#primaryTargetClient.send('Page.reload'), + ]); + + return result; + } + + override async createCDPSession(): Promise<CDPSession> { + return await this.target().createCDPSession(); + } + + override async goBack( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#go(-1, options); + } + + override async goForward( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#go(+1, options); + } + + async #go( + delta: number, + options: WaitForOptions + ): Promise<HTTPResponse | null> { + const history = await this.#primaryTargetClient.send( + 'Page.getNavigationHistory' + ); + const entry = history.entries[history.currentIndex + delta]; + if (!entry) { + return null; + } + const result = await Promise.all([ + this.waitForNavigation(options), + this.#primaryTargetClient.send('Page.navigateToHistoryEntry', { + entryId: entry.id, + }), + ]); + return result[0]; + } + + override async bringToFront(): Promise<void> { + await this.#primaryTargetClient.send('Page.bringToFront'); + } + + override async setJavaScriptEnabled(enabled: boolean): Promise<void> { + return await this.#emulationManager.setJavaScriptEnabled(enabled); + } + + override async setBypassCSP(enabled: boolean): Promise<void> { + await this.#primaryTargetClient.send('Page.setBypassCSP', {enabled}); + } + + override async emulateMediaType(type?: string): Promise<void> { + return await this.#emulationManager.emulateMediaType(type); + } + + override async emulateCPUThrottling(factor: number | null): Promise<void> { + return await this.#emulationManager.emulateCPUThrottling(factor); + } + + override async emulateMediaFeatures( + features?: MediaFeature[] + ): Promise<void> { + return await this.#emulationManager.emulateMediaFeatures(features); + } + + override async emulateTimezone(timezoneId?: string): Promise<void> { + return await this.#emulationManager.emulateTimezone(timezoneId); + } + + override async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + return await this.#emulationManager.emulateIdleState(overrides); + } + + override async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + return await this.#emulationManager.emulateVisionDeficiency(type); + } + + override async setViewport(viewport: Viewport): Promise<void> { + const needsReload = await this.#emulationManager.emulateViewport(viewport); + this.#viewport = viewport; + if (needsReload) { + await this.reload(); + } + } + + override viewport(): Viewport | null { + return this.#viewport; + } + + override async evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<NewDocumentScriptEvaluation> { + const source = evaluationString(pageFunction, ...args); + const {identifier} = await this.#primaryTargetClient.send( + 'Page.addScriptToEvaluateOnNewDocument', + { + source, + } + ); + + return {identifier}; + } + + override async removeScriptToEvaluateOnNewDocument( + identifier: string + ): Promise<void> { + await this.#primaryTargetClient.send( + 'Page.removeScriptToEvaluateOnNewDocument', + { + identifier, + } + ); + } + + override async setCacheEnabled(enabled = true): Promise<void> { + await this.#frameManager.networkManager.setCacheEnabled(enabled); + } + + override async _screenshot( + options: Readonly<ScreenshotOptions> + ): Promise<string> { + const { + fromSurface, + omitBackground, + optimizeForSpeed, + quality, + clip: userClip, + type, + captureBeyondViewport, + } = options; + + const isFirefox = + this.target()._targetManager() instanceof FirefoxTargetManager; + + await using stack = new AsyncDisposableStack(); + // Firefox omits background by default; it's not configurable. + if (!isFirefox && omitBackground && (type === 'png' || type === 'webp')) { + await this.#emulationManager.setTransparentBackgroundColor(); + stack.defer(async () => { + await this.#emulationManager + .resetDefaultBackgroundColor() + .catch(debugError); + }); + } + + let clip = userClip; + if (clip && !captureBeyondViewport) { + const viewport = await this.mainFrame() + .isolatedRealm() + .evaluate(() => { + const { + height, + pageLeft: x, + pageTop: y, + width, + } = window.visualViewport!; + return {x, y, height, width}; + }); + clip = getIntersectionRect(clip, viewport); + } + + // We need to do these spreads because Firefox doesn't allow unknown options. + const {data} = await this.#primaryTargetClient.send( + 'Page.captureScreenshot', + { + format: type, + ...(optimizeForSpeed ? {optimizeForSpeed} : {}), + ...(quality !== undefined ? {quality: Math.round(quality)} : {}), + ...(clip ? {clip: {...clip, scale: clip.scale ?? 1}} : {}), + ...(!fromSurface ? {fromSurface} : {}), + captureBeyondViewport, + } + ); + return data; + } + + override async createPDFStream(options: PDFOptions = {}): Promise<Readable> { + const {timeout: ms = this._timeoutSettings.timeout()} = options; + const { + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + width: paperWidth, + height: paperHeight, + margin, + pageRanges, + preferCSSPageSize, + omitBackground, + tagged: generateTaggedPDF, + } = parsePDFOptions(options); + + if (omitBackground) { + await this.#emulationManager.setTransparentBackgroundColor(); + } + + const printCommandPromise = this.#primaryTargetClient.send( + 'Page.printToPDF', + { + transferMode: 'ReturnAsStream', + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + paperWidth, + paperHeight, + marginTop: margin.top, + marginBottom: margin.bottom, + marginLeft: margin.left, + marginRight: margin.right, + pageRanges, + preferCSSPageSize, + generateTaggedPDF, + } + ); + + const result = await firstValueFrom( + from(printCommandPromise).pipe(raceWith(timeout(ms))) + ); + + if (omitBackground) { + await this.#emulationManager.resetDefaultBackgroundColor(); + } + + assert(result.stream, '`stream` is missing from `Page.printToPDF'); + return await getReadableFromProtocolStream( + this.#primaryTargetClient, + result.stream + ); + } + + override async pdf(options: PDFOptions = {}): Promise<Buffer> { + const {path = undefined} = options; + const readable = await this.createPDFStream(options); + const buffer = await getReadableAsBuffer(readable, path); + assert(buffer, 'Could not create buffer'); + return buffer; + } + + override async close( + options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined} + ): Promise<void> { + const connection = this.#primaryTargetClient.connection(); + assert( + connection, + 'Protocol error: Connection closed. Most likely the page has been closed.' + ); + const runBeforeUnload = !!options.runBeforeUnload; + if (runBeforeUnload) { + await this.#primaryTargetClient.send('Page.close'); + } else { + await connection.send('Target.closeTarget', { + targetId: this.#primaryTarget._targetId, + }); + await this.#tabTarget._isClosedDeferred.valueOrThrow(); + } + } + + override isClosed(): boolean { + return this.#closed; + } + + override get mouse(): CdpMouse { + return this.#mouse; + } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + */ + override async waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPrompt> { + return await this.mainFrame().waitForDevicePrompt(options); + } +} + +const supportedMetrics = new Set<string>([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); + +/** @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection */ +function getIntersectionRect( + clip: Readonly<ScreenshotClip>, + viewport: Readonly<Protocol.DOM.Rect> +): ScreenshotClip { + // Note these will already be normalized. + const x = Math.max(clip.x, viewport.x); + const y = Math.max(clip.y, viewport.y); + return { + x, + y, + width: Math.max( + Math.min(clip.x + clip.width, viewport.x + viewport.width) - x, + 0 + ), + height: Math.max( + Math.min(clip.y + clip.height, viewport.y + viewport.height) - y, + 0 + ), + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts new file mode 100644 index 0000000000..df035ae52b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2021 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {NetworkConditions} from './NetworkManager.js'; + +/** + * A list of network conditions to be used with + * {@link Page.emulateNetworkConditions}. + * + * @example + * + * ```ts + * import {PredefinedNetworkConditions} from 'puppeteer'; + * const slow3G = PredefinedNetworkConditions['Slow 3G']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulateNetworkConditions(slow3G); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * @public + */ +export const PredefinedNetworkConditions = Object.freeze({ + 'Slow 3G': { + download: ((500 * 1000) / 8) * 0.8, + upload: ((500 * 1000) / 8) * 0.8, + latency: 400 * 5, + } as NetworkConditions, + 'Fast 3G': { + download: ((1.6 * 1000 * 1000) / 8) * 0.9, + upload: ((750 * 1000) / 8) * 0.9, + latency: 150 * 3.75, + } as NetworkConditions, +}); + +/** + * @deprecated Import {@link PredefinedNetworkConditions}. + * + * @public + */ +export const networkConditions = PredefinedNetworkConditions; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts new file mode 100644 index 0000000000..b3e9ea83ec --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {Browser} from '../api/Browser.js'; +import type {BrowserContext} from '../api/BrowserContext.js'; +import type {CDPSession} from '../api/CDPSession.js'; +import {PageEvent, type Page} from '../api/Page.js'; +import {Target, TargetType} from '../api/Target.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import {Deferred} from '../util/Deferred.js'; + +import {CdpCDPSession} from './CDPSession.js'; +import {CdpPage} from './Page.js'; +import type {TargetManager} from './TargetManager.js'; +import {CdpWebWorker} from './WebWorker.js'; + +/** + * @internal + */ +export enum InitializationStatus { + SUCCESS = 'success', + ABORTED = 'aborted', +} + +/** + * @internal + */ +export class CdpTarget extends Target { + #browserContext?: BrowserContext; + #session?: CDPSession; + #targetInfo: Protocol.Target.TargetInfo; + #targetManager?: TargetManager; + #sessionFactory: + | ((isAutoAttachEmulated: boolean) => Promise<CDPSession>) + | undefined; + + _initializedDeferred = Deferred.create<InitializationStatus>(); + _isClosedDeferred = Deferred.create<void>(); + _targetId: string; + + /** + * To initialize the target for use, call initialize. + * + * @internal + */ + constructor( + targetInfo: Protocol.Target.TargetInfo, + session: CDPSession | undefined, + browserContext: BrowserContext | undefined, + targetManager: TargetManager | undefined, + sessionFactory: + | ((isAutoAttachEmulated: boolean) => Promise<CDPSession>) + | undefined + ) { + super(); + this.#session = session; + this.#targetManager = targetManager; + this.#targetInfo = targetInfo; + this.#browserContext = browserContext; + this._targetId = targetInfo.targetId; + this.#sessionFactory = sessionFactory; + if (this.#session && this.#session instanceof CdpCDPSession) { + this.#session._setTarget(this); + } + } + + override async asPage(): Promise<Page> { + const session = this._session(); + if (!session) { + return await this.createCDPSession().then(client => { + return CdpPage._create(client, this, false, null); + }); + } + return await CdpPage._create(session, this, false, null); + } + + _subtype(): string | undefined { + return this.#targetInfo.subtype; + } + + _session(): CDPSession | undefined { + return this.#session; + } + + protected _sessionFactory(): ( + isAutoAttachEmulated: boolean + ) => Promise<CDPSession> { + if (!this.#sessionFactory) { + throw new Error('sessionFactory is not initialized'); + } + return this.#sessionFactory; + } + + override createCDPSession(): Promise<CDPSession> { + if (!this.#sessionFactory) { + throw new Error('sessionFactory is not initialized'); + } + return this.#sessionFactory(false).then(session => { + (session as CdpCDPSession)._setTarget(this); + return session; + }); + } + + override url(): string { + return this.#targetInfo.url; + } + + override type(): TargetType { + const type = this.#targetInfo.type; + switch (type) { + case 'page': + return TargetType.PAGE; + case 'background_page': + return TargetType.BACKGROUND_PAGE; + case 'service_worker': + return TargetType.SERVICE_WORKER; + case 'shared_worker': + return TargetType.SHARED_WORKER; + case 'browser': + return TargetType.BROWSER; + case 'webview': + return TargetType.WEBVIEW; + case 'tab': + return TargetType.TAB; + default: + return TargetType.OTHER; + } + } + + _targetManager(): TargetManager { + if (!this.#targetManager) { + throw new Error('targetManager is not initialized'); + } + return this.#targetManager; + } + + _getTargetInfo(): Protocol.Target.TargetInfo { + return this.#targetInfo; + } + + override browser(): Browser { + if (!this.#browserContext) { + throw new Error('browserContext is not initialized'); + } + return this.#browserContext.browser(); + } + + override browserContext(): BrowserContext { + if (!this.#browserContext) { + throw new Error('browserContext is not initialized'); + } + return this.#browserContext; + } + + override opener(): Target | undefined { + const {openerId} = this.#targetInfo; + if (!openerId) { + return; + } + return this.browser() + .targets() + .find(target => { + return (target as CdpTarget)._targetId === openerId; + }); + } + + _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void { + this.#targetInfo = targetInfo; + this._checkIfInitialized(); + } + + _initialize(): void { + this._initializedDeferred.resolve(InitializationStatus.SUCCESS); + } + + _isTargetExposed(): boolean { + return this.type() !== TargetType.TAB && !this._subtype(); + } + + protected _checkIfInitialized(): void { + if (!this._initializedDeferred.resolved()) { + this._initializedDeferred.resolve(InitializationStatus.SUCCESS); + } + } +} + +/** + * @internal + */ +export class PageTarget extends CdpTarget { + #defaultViewport?: Viewport; + protected pagePromise?: Promise<Page>; + #ignoreHTTPSErrors: boolean; + + constructor( + targetInfo: Protocol.Target.TargetInfo, + session: CDPSession | undefined, + browserContext: BrowserContext, + targetManager: TargetManager, + sessionFactory: (isAutoAttachEmulated: boolean) => Promise<CDPSession>, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ) { + super(targetInfo, session, browserContext, targetManager, sessionFactory); + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#defaultViewport = defaultViewport ?? undefined; + } + + override _initialize(): void { + this._initializedDeferred + .valueOrThrow() + .then(async result => { + if (result === InitializationStatus.ABORTED) { + return; + } + const opener = this.opener(); + if (!(opener instanceof PageTarget)) { + return; + } + if (!opener || !opener.pagePromise || this.type() !== 'page') { + return true; + } + const openerPage = await opener.pagePromise; + if (!openerPage.listenerCount(PageEvent.Popup)) { + return true; + } + const popupPage = await this.page(); + openerPage.emit(PageEvent.Popup, popupPage); + return true; + }) + .catch(debugError); + this._checkIfInitialized(); + } + + override async page(): Promise<Page | null> { + if (!this.pagePromise) { + const session = this._session(); + this.pagePromise = ( + session + ? Promise.resolve(session) + : this._sessionFactory()(/* isAutoAttachEmulated=*/ false) + ).then(client => { + return CdpPage._create( + client, + this, + this.#ignoreHTTPSErrors, + this.#defaultViewport ?? null + ); + }); + } + return (await this.pagePromise) ?? null; + } + + override _checkIfInitialized(): void { + if (this._initializedDeferred.resolved()) { + return; + } + if (this._getTargetInfo().url !== '') { + this._initializedDeferred.resolve(InitializationStatus.SUCCESS); + } + } +} + +/** + * @internal + */ +export class DevToolsTarget extends PageTarget {} + +/** + * @internal + */ +export class WorkerTarget extends CdpTarget { + #workerPromise?: Promise<CdpWebWorker>; + + override async worker(): Promise<CdpWebWorker | null> { + if (!this.#workerPromise) { + const session = this._session(); + // TODO(einbinder): Make workers send their console logs. + this.#workerPromise = ( + session + ? Promise.resolve(session) + : this._sessionFactory()(/* isAutoAttachEmulated=*/ false) + ).then(client => { + return new CdpWebWorker( + client, + this._getTargetInfo().url, + () => {} /* consoleAPICalled */, + () => {} /* exceptionThrown */ + ); + }); + } + return await this.#workerPromise; + } +} + +/** + * @internal + */ +export class OtherTarget extends CdpTarget {} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts new file mode 100644 index 0000000000..248f63539d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {EventEmitter, EventType} from '../common/EventEmitter.js'; + +import type {CdpTarget} from './Target.js'; + +/** + * @internal + */ +export type TargetFactory = ( + targetInfo: Protocol.Target.TargetInfo, + session?: CDPSession, + parentSession?: CDPSession +) => CdpTarget; + +/** + * @internal + */ +export const enum TargetManagerEvent { + TargetDiscovered = 'targetDiscovered', + TargetAvailable = 'targetAvailable', + TargetGone = 'targetGone', + /** + * Emitted after a target has been initialized and whenever its URL changes. + */ + TargetChanged = 'targetChanged', +} + +/** + * @internal + */ +export interface TargetManagerEvents extends Record<EventType, unknown> { + [TargetManagerEvent.TargetAvailable]: CdpTarget; + [TargetManagerEvent.TargetDiscovered]: Protocol.Target.TargetInfo; + [TargetManagerEvent.TargetGone]: CdpTarget; + [TargetManagerEvent.TargetChanged]: { + target: CdpTarget; + wasInitialized: true; + previousURL: string; + }; +} + +/** + * TargetManager encapsulates all interactions with CDP targets and is + * responsible for coordinating the configuration of targets with the rest of + * Puppeteer. Code outside of this class should not subscribe `Target.*` events + * and only use the TargetManager events. + * + * There are two implementations: one for Chrome that uses CDP's auto-attach + * mechanism and one for Firefox because Firefox does not support auto-attach. + * + * @internal + */ +export interface TargetManager extends EventEmitter<TargetManagerEvents> { + getAvailableTargets(): ReadonlyMap<string, CdpTarget>; + initialize(): Promise<void>; + dispose(): void; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts new file mode 100644 index 0000000000..22eae9a5d4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {CDPSession} from '../api/CDPSession.js'; +import { + getReadableAsBuffer, + getReadableFromProtocolStream, +} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +/** + * @public + */ +export interface TracingOptions { + path?: string; + screenshots?: boolean; + categories?: string[]; +} + +/** + * The Tracing class exposes the tracing audit interface. + * @remarks + * You can use `tracing.start` and `tracing.stop` to create a trace file + * which can be opened in Chrome DevTools or {@link https://chromedevtools.github.io/timeline-viewer/ | timeline viewer}. + * + * @example + * + * ```ts + * await page.tracing.start({path: 'trace.json'}); + * await page.goto('https://www.google.com'); + * await page.tracing.stop(); + * ``` + * + * @public + */ +export class Tracing { + #client: CDPSession; + #recording = false; + #path?: string; + + /** + * @internal + */ + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + } + + /** + * Starts a trace for the current page. + * @remarks + * Only one trace can be active at a time per browser. + * + * @param options - Optional `TracingOptions`. + */ + async start(options: TracingOptions = {}): Promise<void> { + assert( + !this.#recording, + 'Cannot start recording trace while already recording trace.' + ); + + const defaultCategories = [ + '-*', + 'devtools.timeline', + 'v8.execute', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'toplevel', + 'blink.console', + 'blink.user_timing', + 'latencyInfo', + 'disabled-by-default-devtools.timeline.stack', + 'disabled-by-default-v8.cpu_profiler', + ]; + const {path, screenshots = false, categories = defaultCategories} = options; + + if (screenshots) { + categories.push('disabled-by-default-devtools.screenshot'); + } + + const excludedCategories = categories + .filter(cat => { + return cat.startsWith('-'); + }) + .map(cat => { + return cat.slice(1); + }); + const includedCategories = categories.filter(cat => { + return !cat.startsWith('-'); + }); + + this.#path = path; + this.#recording = true; + await this.#client.send('Tracing.start', { + transferMode: 'ReturnAsStream', + traceConfig: { + excludedCategories, + includedCategories, + }, + }); + } + + /** + * Stops a trace started with the `start` method. + * @returns Promise which resolves to buffer with trace data. + */ + async stop(): Promise<Buffer | undefined> { + const contentDeferred = Deferred.create<Buffer | undefined>(); + this.#client.once('Tracing.tracingComplete', async event => { + try { + assert(event.stream, 'Missing "stream"'); + const readable = await getReadableFromProtocolStream( + this.#client, + event.stream + ); + const buffer = await getReadableAsBuffer(readable, this.#path); + contentDeferred.resolve(buffer ?? undefined); + } catch (error) { + if (isErrorLike(error)) { + contentDeferred.reject(error); + } else { + contentDeferred.reject(new Error(`Unknown error: ${error}`)); + } + } + }); + await this.#client.send('Tracing.end'); + this.#recording = false; + return await contentDeferred.valueOrThrow(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts new file mode 100644 index 0000000000..552e8a6cf5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Realm} from '../api/Realm.js'; +import {WebWorker} from '../api/WebWorker.js'; +import type {ConsoleMessageType} from '../common/ConsoleMessage.js'; +import {TimeoutSettings} from '../common/TimeoutSettings.js'; +import {debugError} from '../common/util.js'; + +import {ExecutionContext} from './ExecutionContext.js'; +import {IsolatedWorld} from './IsolatedWorld.js'; +import {CdpJSHandle} from './JSHandle.js'; + +/** + * @internal + */ +export type ConsoleAPICalledCallback = ( + eventType: ConsoleMessageType, + handles: CdpJSHandle[], + trace?: Protocol.Runtime.StackTrace +) => void; + +/** + * @internal + */ +export type ExceptionThrownCallback = ( + event: Protocol.Runtime.ExceptionThrownEvent +) => void; + +/** + * @internal + */ +export class CdpWebWorker extends WebWorker { + #world: IsolatedWorld; + #client: CDPSession; + + constructor( + client: CDPSession, + url: string, + consoleAPICalled: ConsoleAPICalledCallback, + exceptionThrown: ExceptionThrownCallback + ) { + super(url); + this.#client = client; + this.#world = new IsolatedWorld(this, new TimeoutSettings()); + + this.#client.once('Runtime.executionContextCreated', async event => { + this.#world.setContext( + new ExecutionContext(client, event.context, this.#world) + ); + }); + this.#client.on('Runtime.consoleAPICalled', async event => { + try { + return consoleAPICalled( + event.type, + event.args.map((object: Protocol.Runtime.RemoteObject) => { + return new CdpJSHandle(this.#world, object); + }), + event.stackTrace + ); + } catch (err) { + debugError(err); + } + }); + this.#client.on('Runtime.exceptionThrown', exceptionThrown); + + // This might fail if the target is closed before we receive all execution contexts. + this.#client.send('Runtime.enable').catch(debugError); + } + + mainRealm(): Realm { + return this.#world; + } + + get client(): CDPSession { + return this.#client; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts new file mode 100644 index 0000000000..1533d63f35 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './Accessibility.js'; +export * from './AriaQueryHandler.js'; +export * from './Binding.js'; +export * from './Browser.js'; +export * from './BrowserConnector.js'; +export * from './cdp.js'; +export * from './CDPSession.js'; +export * from './ChromeTargetManager.js'; +export * from './Connection.js'; +export * from './Coverage.js'; +export * from './DeviceRequestPrompt.js'; +export * from './Dialog.js'; +export * from './ElementHandle.js'; +export * from './EmulationManager.js'; +export * from './ExecutionContext.js'; +export * from './FirefoxTargetManager.js'; +export * from './Frame.js'; +export * from './FrameManager.js'; +export * from './FrameManagerEvents.js'; +export * from './FrameTree.js'; +export * from './HTTPRequest.js'; +export * from './HTTPResponse.js'; +export * from './Input.js'; +export * from './IsolatedWorld.js'; +export * from './IsolatedWorlds.js'; +export * from './JSHandle.js'; +export * from './LifecycleWatcher.js'; +export * from './NetworkEventManager.js'; +export * from './NetworkManager.js'; +export * from './Page.js'; +export * from './PredefinedNetworkConditions.js'; +export * from './Target.js'; +export * from './TargetManager.js'; +export * from './Tracing.js'; +export * from './utils.js'; +export * from './WebWorker.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts new file mode 100644 index 0000000000..989a3cd6a3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts @@ -0,0 +1,232 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import {PuppeteerURL, evaluationString} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +/** + * @internal + */ +export function createEvaluationError( + details: Protocol.Runtime.ExceptionDetails +): unknown { + let name: string; + let message: string; + if (!details.exception) { + name = 'Error'; + message = details.text; + } else if ( + (details.exception.type !== 'object' || + details.exception.subtype !== 'error') && + !details.exception.objectId + ) { + return valueFromRemoteObject(details.exception); + } else { + const detail = getErrorDetails(details); + name = detail.name; + message = detail.message; + } + const messageHeight = message.split('\n').length; + const error = new Error(message); + error.name = name; + const stackLines = error.stack!.split('\n'); + const messageLines = stackLines.splice(0, messageHeight); + + // The first line is this function which we ignore. + stackLines.shift(); + if (details.stackTrace && stackLines.length < Error.stackTraceLimit) { + for (const frame of details.stackTrace.callFrames.reverse()) { + if ( + PuppeteerURL.isPuppeteerURL(frame.url) && + frame.url !== PuppeteerURL.INTERNAL_URL + ) { + const url = PuppeteerURL.parse(frame.url); + stackLines.unshift( + ` at ${frame.functionName || url.functionName} (${ + url.functionName + } at ${url.siteString}, <anonymous>:${frame.lineNumber}:${ + frame.columnNumber + })` + ); + } else { + stackLines.push( + ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ + frame.lineNumber + }:${frame.columnNumber})` + ); + } + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [...messageLines, ...stackLines].join('\n'); + return error; +} + +const getErrorDetails = (details: Protocol.Runtime.ExceptionDetails) => { + let name = ''; + let message: string; + const lines = details.exception?.description?.split('\n at ') ?? []; + const size = Math.min( + details.stackTrace?.callFrames.length ?? 0, + lines.length - 1 + ); + lines.splice(-size, size); + if (details.exception?.className) { + name = details.exception.className; + } + message = lines.join('\n'); + if (name && message.startsWith(`${name}: `)) { + message = message.slice(name.length + 2); + } + return {message, name}; +}; + +/** + * @internal + */ +export function createClientError( + details: Protocol.Runtime.ExceptionDetails +): Error { + let name: string; + let message: string; + if (!details.exception) { + name = 'Error'; + message = details.text; + } else if ( + (details.exception.type !== 'object' || + details.exception.subtype !== 'error') && + !details.exception.objectId + ) { + return valueFromRemoteObject(details.exception); + } else { + const detail = getErrorDetails(details); + name = detail.name; + message = detail.message; + } + const error = new Error(message); + error.name = name; + + const messageHeight = error.message.split('\n').length; + const messageLines = error.stack!.split('\n').splice(0, messageHeight); + + const stackLines = []; + if (details.stackTrace) { + for (const frame of details.stackTrace.callFrames) { + // Note we need to add `1` because the values are 0-indexed. + stackLines.push( + ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ + frame.lineNumber + 1 + }:${frame.columnNumber + 1})` + ); + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [...messageLines, ...stackLines].join('\n'); + return error; +} + +/** + * @internal + */ +export function valueFromRemoteObject( + remoteObject: Protocol.Runtime.RemoteObject +): any { + assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); + if (remoteObject.unserializableValue) { + if (remoteObject.type === 'bigint') { + return BigInt(remoteObject.unserializableValue.replace('n', '')); + } + switch (remoteObject.unserializableValue) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + throw new Error( + 'Unsupported unserializable value: ' + + remoteObject.unserializableValue + ); + } + } + return remoteObject.value; +} + +/** + * @internal + */ +export function addPageBinding(type: string, name: string): void { + // This is the CDP binding. + // @ts-expect-error: In a different context. + const callCdp = globalThis[name]; + + // Depending on the frame loading state either Runtime.evaluate or + // Page.addScriptToEvaluateOnNewDocument might succeed. Let's check that we + // don't re-wrap Puppeteer's binding. + if (callCdp[Symbol.toStringTag] === 'PuppeteerBinding') { + return; + } + + // We replace the CDP binding with a Puppeteer binding. + Object.assign(globalThis, { + [name](...args: unknown[]): Promise<unknown> { + // This is the Puppeteer binding. + // @ts-expect-error: In a different context. + const callPuppeteer = globalThis[name]; + callPuppeteer.args ??= new Map(); + callPuppeteer.callbacks ??= new Map(); + + const seq = (callPuppeteer.lastSeq ?? 0) + 1; + callPuppeteer.lastSeq = seq; + callPuppeteer.args.set(seq, args); + + callCdp( + JSON.stringify({ + type, + name, + seq, + args, + isTrivial: !args.some(value => { + return value instanceof Node; + }), + }) + ); + + return new Promise((resolve, reject) => { + callPuppeteer.callbacks.set(seq, { + resolve(value: unknown) { + callPuppeteer.args.delete(seq); + resolve(value); + }, + reject(value?: unknown) { + callPuppeteer.args.delete(seq); + reject(value); + }, + }); + }); + }, + }); + // @ts-expect-error: In a different context. + globalThis[name][Symbol.toStringTag] = 'PuppeteerBinding'; +} + +/** + * @internal + */ +export function pageBindingInitString(type: string, name: string): string { + return evaluationString(addPageBinding, type, name); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts new file mode 100644 index 0000000000..217e53bedd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Browser} from '../api/Browser.js'; +import {_connectToBiDiBrowser} from '../bidi/BrowserConnector.js'; +import {_connectToCdpBrowser} from '../cdp/BrowserConnector.js'; +import {isNode} from '../environment.js'; +import {assert} from '../util/assert.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import type {ConnectionTransport} from './ConnectionTransport.js'; +import type {ConnectOptions} from './ConnectOptions.js'; +import type {BrowserConnectOptions} from './ConnectOptions.js'; +import {getFetch} from './fetch.js'; + +const getWebSocketTransportClass = async () => { + return isNode + ? (await import('../node/NodeWebSocketTransport.js')).NodeWebSocketTransport + : (await import('../common/BrowserWebSocketTransport.js')) + .BrowserWebSocketTransport; +}; + +/** + * Users should never call this directly; it's called when calling + * `puppeteer.connect`. This method attaches Puppeteer to an existing browser instance. + * + * @internal + */ +export async function _connectToBrowser( + options: ConnectOptions +): Promise<Browser> { + const {connectionTransport, endpointUrl} = + await getConnectionTransport(options); + + if (options.protocol === 'webDriverBiDi') { + const bidiBrowser = await _connectToBiDiBrowser( + connectionTransport, + endpointUrl, + options + ); + return bidiBrowser; + } else { + const cdpBrowser = await _connectToCdpBrowser( + connectionTransport, + endpointUrl, + options + ); + return cdpBrowser; + } +} + +/** + * Establishes a websocket connection by given options and returns both transport and + * endpoint url the transport is connected to. + */ +async function getConnectionTransport( + options: BrowserConnectOptions & ConnectOptions +): Promise<{connectionTransport: ConnectionTransport; endpointUrl: string}> { + const {browserWSEndpoint, browserURL, transport, headers = {}} = options; + + assert( + Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === + 1, + 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect' + ); + + if (transport) { + return {connectionTransport: transport, endpointUrl: ''}; + } else if (browserWSEndpoint) { + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = + await WebSocketClass.create(browserWSEndpoint, headers); + return { + connectionTransport: connectionTransport, + endpointUrl: browserWSEndpoint, + }; + } else if (browserURL) { + const connectionURL = await getWSEndpoint(browserURL); + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = + await WebSocketClass.create(connectionURL); + return { + connectionTransport: connectionTransport, + endpointUrl: connectionURL, + }; + } + throw new Error('Invalid connection options'); +} + +async function getWSEndpoint(browserURL: string): Promise<string> { + const endpointURL = new URL('/json/version', browserURL); + + const fetch = await getFetch(); + try { + const result = await fetch(endpointURL.toString(), { + method: 'GET', + }); + if (!result.ok) { + throw new Error(`HTTP ${result.statusText}`); + } + const data = await result.json(); + return data.webSocketDebuggerUrl; + } catch (error) { + if (isErrorLike(error)) { + error.message = + `Failed to fetch browser webSocket URL from ${endpointURL}: ` + + error.message; + } + throw error; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts new file mode 100644 index 0000000000..cc0f81cb06 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {ConnectionTransport} from './ConnectionTransport.js'; + +/** + * @internal + */ +export class BrowserWebSocketTransport implements ConnectionTransport { + static create(url: string): Promise<BrowserWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + + ws.addEventListener('open', () => { + return resolve(new BrowserWebSocketTransport(ws)); + }); + ws.addEventListener('error', reject); + }); + } + + #ws: WebSocket; + onmessage?: (message: string) => void; + onclose?: () => void; + + constructor(ws: WebSocket) { + this.#ws = ws; + this.#ws.addEventListener('message', event => { + if (this.onmessage) { + this.onmessage.call(null, event.data); + } + }); + this.#ws.addEventListener('close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }); + // Silently ignore all errors - we don't know what to do with them. + this.#ws.addEventListener('error', () => {}); + } + + send(message: string): void { + this.#ws.send(message); + } + + close(): void { + this.#ws.close(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts new file mode 100644 index 0000000000..ea9f3d5abb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Deferred} from '../util/Deferred.js'; +import {rewriteError} from '../util/ErrorLike.js'; + +import {ProtocolError, TargetCloseError} from './Errors.js'; +import {debugError} from './util.js'; + +/** + * Manages callbacks and their IDs for the protocol request/response communication. + * + * @internal + */ +export class CallbackRegistry { + #callbacks = new Map<number, Callback>(); + #idGenerator = createIncrementalIdGenerator(); + + create( + label: string, + timeout: number | undefined, + request: (id: number) => void + ): Promise<unknown> { + const callback = new Callback(this.#idGenerator(), label, timeout); + this.#callbacks.set(callback.id, callback); + try { + request(callback.id); + } catch (error) { + // We still throw sync errors synchronously and clean up the scheduled + // callback. + callback.promise + .valueOrThrow() + .catch(debugError) + .finally(() => { + this.#callbacks.delete(callback.id); + }); + callback.reject(error as Error); + throw error; + } + // Must only have sync code up until here. + return callback.promise.valueOrThrow().finally(() => { + this.#callbacks.delete(callback.id); + }); + } + + reject(id: number, message: string, originalMessage?: string): void { + const callback = this.#callbacks.get(id); + if (!callback) { + return; + } + this._reject(callback, message, originalMessage); + } + + _reject( + callback: Callback, + errorMessage: string | ProtocolError, + originalMessage?: string + ): void { + let error: ProtocolError; + let message: string; + if (errorMessage instanceof ProtocolError) { + error = errorMessage; + error.cause = callback.error; + message = errorMessage.message; + } else { + error = callback.error; + message = errorMessage; + } + + callback.reject( + rewriteError( + error, + `Protocol error (${callback.label}): ${message}`, + originalMessage + ) + ); + } + + resolve(id: number, value: unknown): void { + const callback = this.#callbacks.get(id); + if (!callback) { + return; + } + callback.resolve(value); + } + + clear(): void { + for (const callback of this.#callbacks.values()) { + // TODO: probably we can accept error messages as params. + this._reject(callback, new TargetCloseError('Target closed')); + } + this.#callbacks.clear(); + } + + /** + * @internal + */ + getPendingProtocolErrors(): Error[] { + const result: Error[] = []; + for (const callback of this.#callbacks.values()) { + result.push( + new Error(`${callback.label} timed out. Trace: ${callback.error.stack}`) + ); + } + return result; + } +} +/** + * @internal + */ + +export class Callback { + #id: number; + #error = new ProtocolError(); + #deferred = Deferred.create<unknown>(); + #timer?: ReturnType<typeof setTimeout>; + #label: string; + + constructor(id: number, label: string, timeout?: number) { + this.#id = id; + this.#label = label; + if (timeout) { + this.#timer = setTimeout(() => { + this.#deferred.reject( + rewriteError( + this.#error, + `${label} timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.` + ) + ); + }, timeout); + } + } + + resolve(value: unknown): void { + clearTimeout(this.#timer); + this.#deferred.resolve(value); + } + + reject(error: Error): void { + clearTimeout(this.#timer); + this.#deferred.reject(error); + } + + get id(): number { + return this.#id; + } + + get promise(): Deferred<unknown> { + return this.#deferred; + } + + get error(): ProtocolError { + return this.#error; + } + + get label(): string { + return this.#label; + } +} + +/** + * @internal + */ +export function createIncrementalIdGenerator(): GetIdFn { + let id = 0; + return (): number => { + return ++id; + }; +} + +/** + * @internal + */ +export type GetIdFn = () => number; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts new file mode 100644 index 0000000000..c64d109a7c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Product} from './Product.js'; + +/** + * Defines experiment options for Puppeteer. + * + * See individual properties for more information. + * + * @public + */ +export type ExperimentsConfiguration = Record<string, never>; + +/** + * Defines options to configure Puppeteer's behavior during installation and + * runtime. + * + * See individual properties for more information. + * + * @public + */ +export interface Configuration { + /** + * Specifies a certain version of the browser you'd like Puppeteer to use. + * + * Can be overridden by `PUPPETEER_BROWSER_REVISION`. + * + * See {@link PuppeteerNode.launch | puppeteer.launch} on how executable path + * is inferred. + * + * @defaultValue A compatible-revision of the browser. + */ + browserRevision?: string; + /** + * Defines the directory to be used by Puppeteer for caching. + * + * Can be overridden by `PUPPETEER_CACHE_DIR`. + * + * @defaultValue `path.join(os.homedir(), '.cache', 'puppeteer')` + */ + cacheDirectory?: string; + /** + * Specifies the URL prefix that is used to download the browser. + * + * Can be overridden by `PUPPETEER_DOWNLOAD_BASE_URL`. + * + * @remarks + * This must include the protocol and may even need a path prefix. + * + * @defaultValue Either https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing or + * https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central, + * depending on the product. + */ + downloadBaseUrl?: string; + /** + * Specifies the path for the downloads folder. + * + * Can be overridden by `PUPPETEER_DOWNLOAD_PATH`. + * + * @defaultValue `<cacheDirectory>` + */ + downloadPath?: string; + /** + * Specifies an executable path to be used in + * {@link PuppeteerNode.launch | puppeteer.launch}. + * + * Can be overridden by `PUPPETEER_EXECUTABLE_PATH`. + * + * @defaultValue **Auto-computed.** + */ + executablePath?: string; + /** + * Specifies which browser you'd like Puppeteer to use. + * + * Can be overridden by `PUPPETEER_PRODUCT`. + * + * @defaultValue `chrome` + */ + defaultProduct?: Product; + /** + * Defines the directory to be used by Puppeteer for creating temporary files. + * + * Can be overridden by `PUPPETEER_TMP_DIR`. + * + * @defaultValue `os.tmpdir()` + */ + temporaryDirectory?: string; + /** + * Tells Puppeteer to not download during installation. + * + * Can be overridden by `PUPPETEER_SKIP_DOWNLOAD`. + */ + skipDownload?: boolean; + /** + * Tells Puppeteer to not Chrome download during installation. + * + * Can be overridden by `PUPPETEER_SKIP_CHROME_DOWNLOAD`. + */ + skipChromeDownload?: boolean; + /** + * Tells Puppeteer to not chrome-headless-shell download during installation. + * + * Can be overridden by `PUPPETEER_SKIP_CHROME_HEADLESSS_HELL_DOWNLOAD`. + */ + skipChromeHeadlessShellDownload?: boolean; + /** + * Tells Puppeteer to log at the given level. + * + * @defaultValue `warn` + */ + logLevel?: 'silent' | 'error' | 'warn'; + /** + * Defines experimental options for Puppeteer. + */ + experiments?: ExperimentsConfiguration; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts new file mode 100644 index 0000000000..ce46585162 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + IsPageTargetCallback, + TargetFilterCallback, +} from '../api/Browser.js'; + +import type {ConnectionTransport} from './ConnectionTransport.js'; +import type {Viewport} from './Viewport.js'; + +/** + * @public + */ +export type ProtocolType = 'cdp' | 'webDriverBiDi'; + +/** + * Generic browser options that can be passed when launching any browser or when + * connecting to an existing browser instance. + * @public + */ +export interface BrowserConnectOptions { + /** + * Whether to ignore HTTPS errors during navigation. + * @defaultValue `false` + */ + ignoreHTTPSErrors?: boolean; + /** + * Sets the viewport for each page. + * + * @defaultValue '\{width: 800, height: 600\}' + */ + defaultViewport?: Viewport | null; + /** + * Slows down Puppeteer operations by the specified amount of milliseconds to + * aid debugging. + */ + slowMo?: number; + /** + * Callback to decide if Puppeteer should connect to a given target or not. + */ + targetFilter?: TargetFilterCallback; + /** + * @internal + */ + _isPageTarget?: IsPageTargetCallback; + + /** + * @defaultValue 'cdp' + * @public + */ + protocol?: ProtocolType; + /** + * Timeout setting for individual protocol (CDP) calls. + * + * @defaultValue `180_000` + */ + protocolTimeout?: number; +} + +/** + * @public + */ +export interface ConnectOptions extends BrowserConnectOptions { + browserWSEndpoint?: string; + browserURL?: string; + transport?: ConnectionTransport; + /** + * Headers to use for the web socket connection. + * @remarks + * Only works in the Node.js environment. + */ + headers?: Record<string, string>; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts new file mode 100644 index 0000000000..ff36a2557a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @public + */ +export interface ConnectionTransport { + send(message: string): void; + close(): void; + onmessage?: (message: string) => void; + onclose?: () => void; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts new file mode 100644 index 0000000000..85d2db9f75 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JSHandle} from '../api/JSHandle.js'; + +/** + * @public + */ +export interface ConsoleMessageLocation { + /** + * URL of the resource if known or `undefined` otherwise. + */ + url?: string; + + /** + * 0-based line number in the resource if known or `undefined` otherwise. + */ + lineNumber?: number; + + /** + * 0-based column number in the resource if known or `undefined` otherwise. + */ + columnNumber?: number; +} + +/** + * The supported types for console messages. + * @public + */ +export type ConsoleMessageType = + | 'log' + | 'debug' + | 'info' + | 'error' + | 'warning' + | 'dir' + | 'dirxml' + | 'table' + | 'trace' + | 'clear' + | 'startGroup' + | 'startGroupCollapsed' + | 'endGroup' + | 'assert' + | 'profile' + | 'profileEnd' + | 'count' + | 'timeEnd' + | 'verbose'; + +/** + * ConsoleMessage objects are dispatched by page via the 'console' event. + * @public + */ +export class ConsoleMessage { + #type: ConsoleMessageType; + #text: string; + #args: JSHandle[]; + #stackTraceLocations: ConsoleMessageLocation[]; + + /** + * @public + */ + constructor( + type: ConsoleMessageType, + text: string, + args: JSHandle[], + stackTraceLocations: ConsoleMessageLocation[] + ) { + this.#type = type; + this.#text = text; + this.#args = args; + this.#stackTraceLocations = stackTraceLocations; + } + + /** + * The type of the console message. + */ + type(): ConsoleMessageType { + return this.#type; + } + + /** + * The text of the console message. + */ + text(): string { + return this.#text; + } + + /** + * An array of arguments passed to the console. + */ + args(): JSHandle[] { + return this.#args; + } + + /** + * The location of the console message. + */ + location(): ConsoleMessageLocation { + return this.#stackTraceLocations[0] ?? {}; + } + + /** + * The array of locations on the stack of the console message. + */ + stackTrace(): ConsoleMessageLocation[] { + return this.#stackTraceLocations; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts new file mode 100644 index 0000000000..33e5f889c1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type PuppeteerUtil from '../injected/injected.js'; +import {assert} from '../util/assert.js'; +import {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import { + QueryHandler, + type QuerySelector, + type QuerySelectorAll, +} from './QueryHandler.js'; +import {scriptInjector} from './ScriptInjector.js'; + +/** + * @public + */ +export interface CustomQueryHandler { + /** + * Searches for a {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | Node} matching the given `selector` from {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | node}. + */ + queryOne?: (node: Node, selector: string) => Node | null; + /** + * Searches for some {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | Nodes} matching the given `selector` from {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | node}. + */ + queryAll?: (node: Node, selector: string) => Iterable<Node>; +} + +/** + * The registry of {@link CustomQueryHandler | custom query handlers}. + * + * @example + * + * ```ts + * Puppeteer.customQueryHandlers.register('lit', { … }); + * const aHandle = await page.$('lit/…'); + * ``` + * + * @internal + */ +export class CustomQueryHandlerRegistry { + #handlers = new Map< + string, + [registerScript: string, Handler: typeof QueryHandler] + >(); + + get(name: string): typeof QueryHandler | undefined { + const handler = this.#handlers.get(name); + return handler ? handler[1] : undefined; + } + + /** + * Registers a {@link CustomQueryHandler | custom query handler}. + * + * @remarks + * After registration, the handler can be used everywhere where a selector is + * expected by prepending the selection string with `<name>/`. The name is + * only allowed to consist of lower- and upper case latin letters. + * + * @example + * + * ```ts + * Puppeteer.customQueryHandlers.register('lit', { … }); + * const aHandle = await page.$('lit/…'); + * ``` + * + * @param name - Name to register under. + * @param queryHandler - {@link CustomQueryHandler | Custom query handler} to + * register. + */ + register(name: string, handler: CustomQueryHandler): void { + assert( + !this.#handlers.has(name), + `Cannot register over existing handler: ${name}` + ); + assert( + /^[a-zA-Z]+$/.test(name), + `Custom query handler names may only contain [a-zA-Z]` + ); + assert( + handler.queryAll || handler.queryOne, + `At least one query method must be implemented.` + ); + + const Handler = class extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = interpolateFunction( + (node, selector, PuppeteerUtil) => { + return PuppeteerUtil.customQuerySelectors + .get(PLACEHOLDER('name'))! + .querySelectorAll(node, selector); + }, + {name: JSON.stringify(name)} + ); + static override querySelector: QuerySelector = interpolateFunction( + (node, selector, PuppeteerUtil) => { + return PuppeteerUtil.customQuerySelectors + .get(PLACEHOLDER('name'))! + .querySelector(node, selector); + }, + {name: JSON.stringify(name)} + ); + }; + const registerScript = interpolateFunction( + (PuppeteerUtil: PuppeteerUtil) => { + PuppeteerUtil.customQuerySelectors.register(PLACEHOLDER('name'), { + queryAll: PLACEHOLDER('queryAll'), + queryOne: PLACEHOLDER('queryOne'), + }); + }, + { + name: JSON.stringify(name), + queryAll: handler.queryAll + ? stringifyFunction(handler.queryAll) + : String(undefined), + queryOne: handler.queryOne + ? stringifyFunction(handler.queryOne) + : String(undefined), + } + ).toString(); + + this.#handlers.set(name, [registerScript, Handler]); + scriptInjector.append(registerScript); + } + + /** + * Unregisters the {@link CustomQueryHandler | custom query handler} for the + * given name. + * + * @throws `Error` if there is no handler under the given name. + */ + unregister(name: string): void { + const handler = this.#handlers.get(name); + if (!handler) { + throw new Error(`Cannot unregister unknown handler: ${name}`); + } + scriptInjector.pop(handler[0]); + this.#handlers.delete(name); + } + + /** + * Gets the names of all {@link CustomQueryHandler | custom query handlers}. + */ + names(): string[] { + return [...this.#handlers.keys()]; + } + + /** + * Unregisters all custom query handlers. + */ + clear(): void { + for (const [registerScript] of this.#handlers) { + scriptInjector.pop(registerScript); + } + this.#handlers.clear(); + } +} + +/** + * @internal + */ +export const customQueryHandlers = new CustomQueryHandlerRegistry(); + +/** + * @deprecated Import {@link Puppeteer} and use the static method + * {@link Puppeteer.registerCustomQueryHandler} + * + * @public + */ +export function registerCustomQueryHandler( + name: string, + handler: CustomQueryHandler +): void { + customQueryHandlers.register(name, handler); +} + +/** + * @deprecated Import {@link Puppeteer} and use the static method + * {@link Puppeteer.unregisterCustomQueryHandler} + * + * @public + */ +export function unregisterCustomQueryHandler(name: string): void { + customQueryHandlers.unregister(name); +} + +/** + * @deprecated Import {@link Puppeteer} and use the static method + * {@link Puppeteer.customQueryHandlerNames} + * + * @public + */ +export function customQueryHandlerNames(): string[] { + return customQueryHandlers.names(); +} + +/** + * @deprecated Import {@link Puppeteer} and use the static method + * {@link Puppeteer.clearCustomQueryHandlers} + * + * @public + */ +export function clearCustomQueryHandlers(): void { + customQueryHandlers.clear(); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts new file mode 100644 index 0000000000..06ac9f58f9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Debug from 'debug'; + +import {isNode} from '../environment.js'; + +declare global { + // eslint-disable-next-line no-var + var __PUPPETEER_DEBUG: string; +} + +/** + * @internal + */ +let debugModule: typeof Debug | null = null; +/** + * @internal + */ +export async function importDebug(): Promise<typeof Debug> { + if (!debugModule) { + debugModule = (await import('debug')).default; + } + return debugModule; +} + +/** + * A debug function that can be used in any environment. + * + * @remarks + * If used in Node, it falls back to the + * {@link https://www.npmjs.com/package/debug | debug module}. In the browser it + * uses `console.log`. + * + * In Node, use the `DEBUG` environment variable to control logging: + * + * ``` + * DEBUG=* // logs all channels + * DEBUG=foo // logs the `foo` channel + * DEBUG=foo* // logs any channels starting with `foo` + * ``` + * + * In the browser, set `window.__PUPPETEER_DEBUG` to a string: + * + * ``` + * window.__PUPPETEER_DEBUG='*'; // logs all channels + * window.__PUPPETEER_DEBUG='foo'; // logs the `foo` channel + * window.__PUPPETEER_DEBUG='foo*'; // logs any channels starting with `foo` + * ``` + * + * @example + * + * ``` + * const log = debug('Page'); + * + * log('new page created') + * // logs "Page: new page created" + * ``` + * + * @param prefix - this will be prefixed to each log. + * @returns a function that can be called to log to that debug channel. + * + * @internal + */ +export const debug = (prefix: string): ((...args: unknown[]) => void) => { + if (isNode) { + return async (...logArgs: unknown[]) => { + if (captureLogs) { + capturedLogs.push(prefix + logArgs); + } + (await importDebug())(prefix)(logArgs); + }; + } + + return (...logArgs: unknown[]): void => { + const debugLevel = (globalThis as any).__PUPPETEER_DEBUG; + if (!debugLevel) { + return; + } + + const everythingShouldBeLogged = debugLevel === '*'; + + const prefixMatchesDebugLevel = + everythingShouldBeLogged || + /** + * If the debug level is `foo*`, that means we match any prefix that + * starts with `foo`. If the level is `foo`, we match only the prefix + * `foo`. + */ + (debugLevel.endsWith('*') + ? prefix.startsWith(debugLevel) + : prefix === debugLevel); + + if (!prefixMatchesDebugLevel) { + return; + } + + // eslint-disable-next-line no-console + console.log(`${prefix}:`, ...logArgs); + }; +}; + +/** + * @internal + */ +let capturedLogs: string[] = []; +/** + * @internal + */ +let captureLogs = false; + +/** + * @internal + */ +export function setLogCapture(value: boolean): void { + capturedLogs = []; + captureLogs = value; +} + +/** + * @internal + */ +export function getCapturedLogs(): string[] { + return capturedLogs; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts new file mode 100644 index 0000000000..dbf5c13c95 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts @@ -0,0 +1,1552 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Viewport} from './Viewport.js'; + +/** + * @public + */ +export interface Device { + userAgent: string; + viewport: Viewport; +} + +const knownDevices = [ + { + name: 'Blackberry PlayBook', + userAgent: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', + viewport: { + width: 600, + height: 1024, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Blackberry PlayBook landscape', + userAgent: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', + viewport: { + width: 1024, + height: 600, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'BlackBerry Z30', + userAgent: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'BlackBerry Z30 landscape', + userAgent: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Note 3', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Note 3 landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Note II', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Note II landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S III', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S III landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S5', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S8', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36', + viewport: { + width: 360, + height: 740, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S8 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36', + viewport: { + width: 740, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S9+', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.111 Mobile Safari/537.36', + viewport: { + width: 320, + height: 658, + deviceScaleFactor: 4.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S9+ landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.111 Mobile Safari/537.36', + viewport: { + width: 658, + height: 320, + deviceScaleFactor: 4.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Tab S4', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.80 Safari/537.36', + viewport: { + width: 712, + height: 1138, + deviceScaleFactor: 2.25, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Tab S4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.80 Safari/537.36', + viewport: { + width: 1138, + height: 712, + deviceScaleFactor: 2.25, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad (gen 6)', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad (gen 6) landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad (gen 7)', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 810, + height: 1080, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad (gen 7) landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 1080, + height: 810, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Mini', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Mini landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Pro', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 1366, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Pro landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1366, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Pro 11', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 834, + height: 1194, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Pro 11 landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 1194, + height: 834, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 4', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', + viewport: { + width: 320, + height: 480, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 4 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', + viewport: { + width: 480, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 5', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 320, + height: 568, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 5 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 568, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 6', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 6 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 6 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 6 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 7', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 7 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 7 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 7 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 8', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 8 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 8 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 8 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone SE', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 320, + height: 568, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone SE landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 568, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone X', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone X landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone XR', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + viewport: { + width: 414, + height: 896, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone XR landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + viewport: { + width: 896, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 11', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 414, + height: 828, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 11 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 828, + height: 414, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 11 Pro', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 11 Pro landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 11 Pro Max', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 414, + height: 896, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 11 Pro Max landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 896, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 12', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 390, + height: 844, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 12 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 844, + height: 390, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 12 Pro', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 390, + height: 844, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 12 Pro landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 844, + height: 390, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 12 Pro Max', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 428, + height: 926, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 12 Pro Max landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 926, + height: 428, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 12 Mini', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 12 Mini landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 13', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 390, + height: 844, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 13 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 844, + height: 390, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 13 Pro', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 390, + height: 844, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 13 Pro landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 844, + height: 390, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 13 Pro Max', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 428, + height: 926, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 13 Pro Max landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 926, + height: 428, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 13 Mini', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 13 Mini landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'JioPhone 2', + userAgent: + 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + viewport: { + width: 240, + height: 320, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'JioPhone 2 landscape', + userAgent: + 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + viewport: { + width: 320, + height: 240, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Kindle Fire HDX', + userAgent: + 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', + viewport: { + width: 800, + height: 1280, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Kindle Fire HDX landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', + viewport: { + width: 1280, + height: 800, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'LG Optimus L70', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 384, + height: 640, + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'LG Optimus L70 landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 384, + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Microsoft Lumia 550', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Microsoft Lumia 950', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 4, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Microsoft Lumia 950 landscape', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 4, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 10', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 800, + height: 1280, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 10 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 1280, + height: 800, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 4', + userAgent: + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 384, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 384, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 5', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 5X', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 5X landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 6', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 6 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 6P', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 6P landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 7', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 600, + height: 960, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 7 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 960, + height: 600, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nokia Lumia 520', + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', + viewport: { + width: 320, + height: 533, + deviceScaleFactor: 1.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nokia Lumia 520 landscape', + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', + viewport: { + width: 533, + height: 320, + deviceScaleFactor: 1.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nokia N9', + userAgent: + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + viewport: { + width: 480, + height: 854, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nokia N9 landscape', + userAgent: + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + viewport: { + width: 854, + height: 480, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 2', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 411, + height: 731, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 2 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 731, + height: 411, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 2 XL', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 411, + height: 823, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 2 XL landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 823, + height: 411, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 3', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36', + viewport: { + width: 393, + height: 786, + deviceScaleFactor: 2.75, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 3 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36', + viewport: { + width: 786, + height: 393, + deviceScaleFactor: 2.75, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 4', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36', + viewport: { + width: 353, + height: 745, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36', + viewport: { + width: 745, + height: 353, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 4a (5G)', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 353, + height: 745, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 4a (5G) landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 745, + height: 353, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 5', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 393, + height: 851, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 851, + height: 393, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Moto G4', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Moto G4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, +] as const; + +const knownDevicesByName = {} as Record< + (typeof knownDevices)[number]['name'], + Device +>; + +for (const device of knownDevices) { + knownDevicesByName[device.name] = device; +} + +/** + * A list of devices to be used with {@link Page.emulate}. + * + * @example + * + * ```ts + * import {KnownDevices} from 'puppeteer'; + * const iPhone = KnownDevices['iPhone 6']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulate(iPhone); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * @public + */ +export const KnownDevices = Object.freeze(knownDevicesByName); + +/** + * @deprecated Import {@link KnownDevices} + * + * @public + */ +export const devices = KnownDevices; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts new file mode 100644 index 0000000000..8225d64f07 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @deprecated Do not use. + * + * @public + */ +export class CustomError extends Error { + /** + * @internal + */ + constructor(message?: string) { + super(message); + this.name = this.constructor.name; + } + + /** + * @internal + */ + get [Symbol.toStringTag](): string { + return this.constructor.name; + } +} + +/** + * TimeoutError is emitted whenever certain operations are terminated due to + * timeout. + * + * @remarks + * Example operations are {@link Page.waitForSelector | page.waitForSelector} or + * {@link PuppeteerNode.launch | puppeteer.launch}. + * + * @public + */ +export class TimeoutError extends CustomError {} + +/** + * ProtocolError is emitted whenever there is an error from the protocol. + * + * @public + */ +export class ProtocolError extends CustomError { + #code?: number; + #originalMessage = ''; + + set code(code: number | undefined) { + this.#code = code; + } + /** + * @readonly + * @public + */ + get code(): number | undefined { + return this.#code; + } + + set originalMessage(originalMessage: string) { + this.#originalMessage = originalMessage; + } + /** + * @readonly + * @public + */ + get originalMessage(): string { + return this.#originalMessage; + } +} + +/** + * Puppeteer will throw this error if a method is not + * supported by the currently used protocol + * + * @public + */ +export class UnsupportedOperation extends CustomError {} + +/** + * @internal + */ +export class TargetCloseError extends ProtocolError {} + +/** + * @deprecated Do not use. + * + * @public + */ +export interface PuppeteerErrors { + TimeoutError: typeof TimeoutError; + ProtocolError: typeof ProtocolError; +} + +/** + * @deprecated Import error classes directly. + * + * Puppeteer methods might throw errors if they are unable to fulfill a request. + * For example, `page.waitForSelector(selector[, options])` might fail if the + * selector doesn't match any nodes during the given timeframe. + * + * For certain types of errors Puppeteer uses specific error classes. These + * classes are available via `puppeteer.errors`. + * + * @example + * An example of handling a timeout error: + * + * ```ts + * try { + * await page.waitForSelector('.foo'); + * } catch (e) { + * if (e instanceof TimeoutError) { + * // Do something if this is a timeout. + * } + * } + * ``` + * + * @public + */ +export const errors: PuppeteerErrors = Object.freeze({ + TimeoutError, + ProtocolError, +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts new file mode 100644 index 0000000000..cf05ef6700 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it, beforeEach} from 'node:test'; + +import expect from 'expect'; +import sinon from 'sinon'; + +import {EventEmitter} from './EventEmitter.js'; + +describe('EventEmitter', () => { + let emitter: EventEmitter<Record<string, unknown>>; + + beforeEach(() => { + emitter = new EventEmitter(); + }); + + describe('on', () => { + const onTests = (methodName: 'on' | 'addListener'): void => { + it(`${methodName}: adds an event listener that is fired when the event is emitted`, () => { + const listener = sinon.spy(); + emitter[methodName]('foo', listener); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + }); + + it(`${methodName} sends the event data to the handler`, () => { + const listener = sinon.spy(); + const data = {}; + emitter[methodName]('foo', listener); + emitter.emit('foo', data); + expect(listener.callCount).toEqual(1); + expect(listener.firstCall.args[0]).toBe(data); + }); + + it(`${methodName}: supports chaining`, () => { + const listener = sinon.spy(); + const returnValue = emitter[methodName]('foo', listener); + expect(returnValue).toBe(emitter); + }); + }; + onTests('on'); + // we support addListener for legacy reasons + onTests('addListener'); + }); + + describe('off', () => { + const offTests = (methodName: 'off' | 'removeListener'): void => { + it(`${methodName}: removes the listener so it is no longer called`, () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + emitter.off('foo', listener); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + }); + + it(`${methodName}: supports chaining`, () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + const returnValue = emitter.off('foo', listener); + expect(returnValue).toBe(emitter); + }); + }; + offTests('off'); + // we support removeListener for legacy reasons + offTests('removeListener'); + }); + + describe('once', () => { + it('only calls the listener once and then removes it', () => { + const listener = sinon.spy(); + emitter.once('foo', listener); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + }); + + it('supports chaining', () => { + const listener = sinon.spy(); + const returnValue = emitter.once('foo', listener); + expect(returnValue).toBe(emitter); + }); + }); + + describe('emit', () => { + it('calls all the listeners for an event', () => { + const listener1 = sinon.spy(); + const listener2 = sinon.spy(); + const listener3 = sinon.spy(); + emitter.on('foo', listener1).on('foo', listener2).on('bar', listener3); + + emitter.emit('foo', undefined); + + expect(listener1.callCount).toEqual(1); + expect(listener2.callCount).toEqual(1); + expect(listener3.callCount).toEqual(0); + }); + + it('passes data through to the listener', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + const data = {}; + + emitter.emit('foo', data); + expect(listener.callCount).toEqual(1); + expect(listener.firstCall.args[0]).toBe(data); + }); + + it('returns true if the event has listeners', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + expect(emitter.emit('foo', undefined)).toBe(true); + }); + + it('returns false if the event has listeners', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + expect(emitter.emit('notFoo', undefined)).toBe(false); + }); + }); + + describe('listenerCount', () => { + it('returns the number of listeners for the given event', () => { + emitter.on('foo', () => {}); + emitter.on('foo', () => {}); + emitter.on('bar', () => {}); + expect(emitter.listenerCount('foo')).toEqual(2); + expect(emitter.listenerCount('bar')).toEqual(1); + expect(emitter.listenerCount('noListeners')).toEqual(0); + }); + }); + + describe('removeAllListeners', () => { + it('removes every listener from all events by default', () => { + emitter.on('foo', () => {}).on('bar', () => {}); + + emitter.removeAllListeners(); + expect(emitter.emit('foo', undefined)).toBe(false); + expect(emitter.emit('bar', undefined)).toBe(false); + }); + + it('returns the emitter for chaining', () => { + expect(emitter.removeAllListeners()).toBe(emitter); + }); + + it('can filter to remove only listeners for a given event name', () => { + emitter + .on('foo', () => {}) + .on('bar', () => {}) + .on('bar', () => {}); + + emitter.removeAllListeners('bar'); + expect(emitter.emit('foo', undefined)).toBe(true); + expect(emitter.emit('bar', undefined)).toBe(false); + }); + }); + + describe('dispose', () => { + it('should dispose higher order emitters properly', () => { + let values = ''; + emitter.on('foo', () => { + values += '1'; + }); + const higherOrderEmitter = new EventEmitter(emitter); + + higherOrderEmitter.on('foo', () => { + values += '2'; + }); + higherOrderEmitter.emit('foo', undefined); + + expect(values).toMatch('12'); + + higherOrderEmitter.off('foo'); + higherOrderEmitter.emit('foo', undefined); + + expect(values).toMatch('121'); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts new file mode 100644 index 0000000000..4a8bcb801f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import mitt, {type Emitter} from '../../third_party/mitt/mitt.js'; +import {disposeSymbol} from '../util/disposable.js'; + +/** + * @public + */ +export type EventType = string | symbol; + +/** + * @public + */ +export type Handler<T = unknown> = (event: T) => void; + +/** + * @public + */ +export interface CommonEventEmitter<Events extends Record<EventType, unknown>> { + on<Key extends keyof Events>(type: Key, handler: Handler<Events[Key]>): this; + off<Key extends keyof Events>( + type: Key, + handler?: Handler<Events[Key]> + ): this; + emit<Key extends keyof Events>(type: Key, event: Events[Key]): boolean; + /* To maintain parity with the built in NodeJS event emitter which uses removeListener + * rather than `off`. + * If you're implementing new code you should use `off`. + */ + addListener<Key extends keyof Events>( + type: Key, + handler: Handler<Events[Key]> + ): this; + removeListener<Key extends keyof Events>( + type: Key, + handler: Handler<Events[Key]> + ): this; + once<Key extends keyof Events>( + type: Key, + handler: Handler<Events[Key]> + ): this; + listenerCount(event: keyof Events): number; + + removeAllListeners(event?: keyof Events): this; +} + +/** + * @public + */ +export type EventsWithWildcard<Events extends Record<EventType, unknown>> = + Events & { + '*': Events[keyof Events]; + }; + +/** + * The EventEmitter class that many Puppeteer classes extend. + * + * @remarks + * + * This allows you to listen to events that Puppeteer classes fire and act + * accordingly. Therefore you'll mostly use {@link EventEmitter.on | on} and + * {@link EventEmitter.off | off} to bind + * and unbind to event listeners. + * + * @public + */ +export class EventEmitter<Events extends Record<EventType, unknown>> + implements CommonEventEmitter<EventsWithWildcard<Events>> +{ + #emitter: Emitter<EventsWithWildcard<Events>> | EventEmitter<Events>; + #handlers = new Map<keyof Events | '*', Array<Handler<any>>>(); + + /** + * If you pass an emitter, the returned emitter will wrap the passed emitter. + * + * @internal + */ + constructor( + emitter: Emitter<EventsWithWildcard<Events>> | EventEmitter<Events> = mitt( + new Map() + ) + ) { + this.#emitter = emitter; + } + + /** + * Bind an event listener to fire when an event occurs. + * @param type - the event type you'd like to listen to. Can be a string or symbol. + * @param handler - the function to be called when the event occurs. + * @returns `this` to enable you to chain method calls. + */ + on<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + handler: Handler<EventsWithWildcard<Events>[Key]> + ): this { + const handlers = this.#handlers.get(type); + if (handlers === undefined) { + this.#handlers.set(type, [handler]); + } else { + handlers.push(handler); + } + + this.#emitter.on(type, handler); + return this; + } + + /** + * Remove an event listener from firing. + * @param type - the event type you'd like to stop listening to. + * @param handler - the function that should be removed. + * @returns `this` to enable you to chain method calls. + */ + off<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + handler?: Handler<EventsWithWildcard<Events>[Key]> + ): this { + const handlers = this.#handlers.get(type) ?? []; + if (handler === undefined) { + for (const handler of handlers) { + this.#emitter.off(type, handler); + } + this.#handlers.delete(type); + return this; + } + const index = handlers.lastIndexOf(handler); + if (index > -1) { + this.#emitter.off(type, ...handlers.splice(index, 1)); + } + return this; + } + + /** + * Emit an event and call any associated listeners. + * + * @param type - the event you'd like to emit + * @param eventData - any data you'd like to emit with the event + * @returns `true` if there are any listeners, `false` if there are not. + */ + emit<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + event: EventsWithWildcard<Events>[Key] + ): boolean { + this.#emitter.emit(type, event); + return this.listenerCount(type) > 0; + } + + /** + * Remove an event listener. + * + * @deprecated please use {@link EventEmitter.off} instead. + */ + removeListener<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + handler: Handler<EventsWithWildcard<Events>[Key]> + ): this { + return this.off(type, handler); + } + + /** + * Add an event listener. + * + * @deprecated please use {@link EventEmitter.on} instead. + */ + addListener<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + handler: Handler<EventsWithWildcard<Events>[Key]> + ): this { + return this.on(type, handler); + } + + /** + * Like `on` but the listener will only be fired once and then it will be removed. + * @param type - the event you'd like to listen to + * @param handler - the handler function to run when the event occurs + * @returns `this` to enable you to chain method calls. + */ + once<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + handler: Handler<EventsWithWildcard<Events>[Key]> + ): this { + const onceHandler: Handler<EventsWithWildcard<Events>[Key]> = eventData => { + handler(eventData); + this.off(type, onceHandler); + }; + + return this.on(type, onceHandler); + } + + /** + * Gets the number of listeners for a given event. + * + * @param type - the event to get the listener count for + * @returns the number of listeners bound to the given event + */ + listenerCount(type: keyof EventsWithWildcard<Events>): number { + return this.#handlers.get(type)?.length || 0; + } + + /** + * Removes all listeners. If given an event argument, it will remove only + * listeners for that event. + * + * @param type - the event to remove listeners for. + * @returns `this` to enable you to chain method calls. + */ + removeAllListeners(type?: keyof EventsWithWildcard<Events>): this { + if (type !== undefined) { + return this.off(type); + } + this[disposeSymbol](); + return this; + } + + /** + * @internal + */ + [disposeSymbol](): void { + for (const [type, handlers] of this.#handlers) { + for (const handler of handlers) { + this.#emitter.off(type, handler); + } + } + this.#handlers.clear(); + } +} + +/** + * @internal + */ +export class EventSubscription< + Target extends CommonEventEmitter<Record<Type, Event>>, + Type extends EventType = EventType, + Event = unknown, +> { + #target: Target; + #type: Type; + #handler: Handler<Event>; + + constructor(target: Target, type: Type, handler: Handler<Event>) { + this.#target = target; + this.#type = type; + this.#handler = handler; + this.#target.on(this.#type, this.#handler); + } + + [disposeSymbol](): void { + this.#target.off(this.#type, this.#handler); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts new file mode 100644 index 0000000000..2e4fd14fa7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {ElementHandle} from '../api/ElementHandle.js'; +import {assert} from '../util/assert.js'; + +/** + * File choosers let you react to the page requesting for a file. + * + * @remarks + * `FileChooser` instances are returned via the {@link Page.waitForFileChooser} method. + * + * In browsers, only one file chooser can be opened at a time. + * All file choosers must be accepted or canceled. Not doing so will prevent + * subsequent file choosers from appearing. + * + * @example + * + * ```ts + * const [fileChooser] = await Promise.all([ + * page.waitForFileChooser(), + * page.click('#upload-file-button'), // some button that triggers file selection + * ]); + * await fileChooser.accept(['/tmp/myfile.pdf']); + * ``` + * + * @public + */ +export class FileChooser { + #element: ElementHandle<HTMLInputElement>; + #multiple: boolean; + #handled = false; + + /** + * @internal + */ + constructor( + element: ElementHandle<HTMLInputElement>, + event: Protocol.Page.FileChooserOpenedEvent + ) { + this.#element = element; + this.#multiple = event.mode !== 'selectSingle'; + } + + /** + * Whether file chooser allow for + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#attr-multiple | multiple} + * file selection. + */ + isMultiple(): boolean { + return this.#multiple; + } + + /** + * Accept the file chooser request with the given file paths. + * + * @remarks This will not validate whether the file paths exists. Also, if a + * path is relative, then it is resolved against the + * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}. + * For locals script connecting to remote chrome environments, paths must be + * absolute. + */ + async accept(paths: string[]): Promise<void> { + assert( + !this.#handled, + 'Cannot accept FileChooser which is already handled!' + ); + this.#handled = true; + await this.#element.uploadFile(...paths); + } + + /** + * Closes the file chooser without selecting any files. + */ + async cancel(): Promise<void> { + assert( + !this.#handled, + 'Cannot cancel FileChooser which is already handled!' + ); + this.#handled = true; + // XXX: These events should converted to trusted events. Perhaps do this + // in `DOM.setFileInputFiles`? + await this.#element.evaluate(element => { + element.dispatchEvent(new Event('cancel', {bubbles: true})); + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts new file mode 100644 index 0000000000..1d8bb01414 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ARIAQueryHandler} from '../cdp/AriaQueryHandler.js'; + +import {customQueryHandlers} from './CustomQueryHandler.js'; +import {PierceQueryHandler} from './PierceQueryHandler.js'; +import {PQueryHandler} from './PQueryHandler.js'; +import type {QueryHandler} from './QueryHandler.js'; +import {TextQueryHandler} from './TextQueryHandler.js'; +import {XPathQueryHandler} from './XPathQueryHandler.js'; + +const BUILTIN_QUERY_HANDLERS = { + aria: ARIAQueryHandler, + pierce: PierceQueryHandler, + xpath: XPathQueryHandler, + text: TextQueryHandler, +} as const; + +const QUERY_SEPARATORS = ['=', '/']; + +/** + * @internal + */ +export function getQueryHandlerAndSelector(selector: string): { + updatedSelector: string; + QueryHandler: typeof QueryHandler; +} { + for (const handlerMap of [ + customQueryHandlers.names().map(name => { + return [name, customQueryHandlers.get(name)!] as const; + }), + Object.entries(BUILTIN_QUERY_HANDLERS), + ]) { + for (const [name, QueryHandler] of handlerMap) { + for (const separator of QUERY_SEPARATORS) { + const prefix = `${name}${separator}`; + if (selector.startsWith(prefix)) { + selector = selector.slice(prefix.length); + return {updatedSelector: selector, QueryHandler}; + } + } + } + } + return {updatedSelector: selector, QueryHandler: PQueryHandler}; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts new file mode 100644 index 0000000000..c88003ed71 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JSHandle} from '../api/JSHandle.js'; +import {DisposableStack, disposeSymbol} from '../util/disposable.js'; + +import type {AwaitableIterable, HandleFor} from './types.js'; + +const DEFAULT_BATCH_SIZE = 20; + +/** + * This will transpose an iterator JSHandle into a fast, Puppeteer-side iterator + * of JSHandles. + * + * @param size - The number of elements to transpose. This should be something + * reasonable. + */ +async function* fastTransposeIteratorHandle<T>( + iterator: JSHandle<AwaitableIterator<T>>, + size: number +) { + using array = await iterator.evaluateHandle(async (iterator, size) => { + const results = []; + while (results.length < size) { + const result = await iterator.next(); + if (result.done) { + break; + } + results.push(result.value); + } + return results; + }, size); + const properties = (await array.getProperties()) as Map<string, HandleFor<T>>; + const handles = properties.values(); + using stack = new DisposableStack(); + stack.defer(() => { + for (using handle of handles) { + handle[disposeSymbol](); + } + }); + yield* handles; + return properties.size === 0; +} + +/** + * This will transpose an iterator JSHandle in batches based on the default size + * of {@link fastTransposeIteratorHandle}. + */ + +async function* transposeIteratorHandle<T>( + iterator: JSHandle<AwaitableIterator<T>> +) { + let size = DEFAULT_BATCH_SIZE; + while (!(yield* fastTransposeIteratorHandle(iterator, size))) { + size <<= 1; + } +} + +type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>; + +/** + * @internal + */ +export async function* transposeIterableHandle<T>( + handle: JSHandle<AwaitableIterable<T>> +): AsyncIterableIterator<HandleFor<T>> { + using generatorHandle = await handle.evaluateHandle(iterable => { + return (async function* () { + yield* iterable; + })(); + }); + yield* transposeIteratorHandle(generatorHandle); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts new file mode 100644 index 0000000000..ed30281dd8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JSHandle} from '../api/JSHandle.js'; +import type PuppeteerUtil from '../injected/injected.js'; + +/** + * @internal + */ +export interface PuppeteerUtilWrapper { + puppeteerUtil: Promise<JSHandle<PuppeteerUtil>>; +} + +/** + * @internal + */ +export class LazyArg<T, Context = PuppeteerUtilWrapper> { + static create = <T>( + get: (context: PuppeteerUtilWrapper) => Promise<T> | T + ): T => { + // We don't want to introduce LazyArg to the type system, otherwise we would + // have to make it public. + return new LazyArg(get) as unknown as T; + }; + + #get: (context: Context) => Promise<T> | T; + private constructor(get: (context: Context) => Promise<T> | T) { + this.#get = get; + } + + async get(context: Context): Promise<T> { + return await this.#get(context); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts new file mode 100644 index 0000000000..eae26252d1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; + +import type {EventType} from './EventEmitter.js'; + +/** + * We use symbols to prevent any external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace NetworkManagerEvent { + export const Request = Symbol('NetworkManager.Request'); + export const RequestServedFromCache = Symbol( + 'NetworkManager.RequestServedFromCache' + ); + export const Response = Symbol('NetworkManager.Response'); + export const RequestFailed = Symbol('NetworkManager.RequestFailed'); + export const RequestFinished = Symbol('NetworkManager.RequestFinished'); +} + +/** + * @internal + */ +export interface NetworkManagerEvents extends Record<EventType, unknown> { + [NetworkManagerEvent.Request]: HTTPRequest; + [NetworkManagerEvent.RequestServedFromCache]: HTTPRequest | undefined; + [NetworkManagerEvent.Response]: HTTPResponse; + [NetworkManagerEvent.RequestFailed]: HTTPRequest; + [NetworkManagerEvent.RequestFinished]: HTTPRequest; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts new file mode 100644 index 0000000000..7cae9191a9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @public + */ +export interface PDFMargin { + top?: string | number; + bottom?: string | number; + left?: string | number; + right?: string | number; +} + +/** + * @public + */ +export type LowerCasePaperFormat = + | 'letter' + | 'legal' + | 'tabloid' + | 'ledger' + | 'a0' + | 'a1' + | 'a2' + | 'a3' + | 'a4' + | 'a5' + | 'a6'; + +/** + * All the valid paper format types when printing a PDF. + * + * @remarks + * + * The sizes of each format are as follows: + * + * - `Letter`: 8.5in x 11in + * + * - `Legal`: 8.5in x 14in + * + * - `Tabloid`: 11in x 17in + * + * - `Ledger`: 17in x 11in + * + * - `A0`: 33.1in x 46.8in + * + * - `A1`: 23.4in x 33.1in + * + * - `A2`: 16.54in x 23.4in + * + * - `A3`: 11.7in x 16.54in + * + * - `A4`: 8.27in x 11.7in + * + * - `A5`: 5.83in x 8.27in + * + * - `A6`: 4.13in x 5.83in + * + * @public + */ +export type PaperFormat = + | Uppercase<LowerCasePaperFormat> + | Capitalize<LowerCasePaperFormat> + | LowerCasePaperFormat; + +/** + * Valid options to configure PDF generation via {@link Page.pdf}. + * @public + */ +export interface PDFOptions { + /** + * Scales the rendering of the web page. Amount must be between `0.1` and `2`. + * @defaultValue `1` + */ + scale?: number; + /** + * Whether to show the header and footer. + * @defaultValue `false` + */ + displayHeaderFooter?: boolean; + /** + * HTML template for the print header. Should be valid HTML with the following + * classes used to inject values into them: + * + * - `date` formatted print date + * + * - `title` document title + * + * - `url` document location + * + * - `pageNumber` current page number + * + * - `totalPages` total pages in the document + */ + headerTemplate?: string; + /** + * HTML template for the print footer. Has the same constraints and support + * for special classes as {@link PDFOptions | PDFOptions.headerTemplate}. + */ + footerTemplate?: string; + /** + * Set to `true` to print background graphics. + * @defaultValue `false` + */ + printBackground?: boolean; + /** + * Whether to print in landscape orientation. + * @defaultValue `false` + */ + landscape?: boolean; + /** + * Paper ranges to print, e.g. `1-5, 8, 11-13`. + * @defaultValue The empty string, which means all pages are printed. + */ + pageRanges?: string; + /** + * @remarks + * If set, this takes priority over the `width` and `height` options. + * @defaultValue `letter`. + */ + format?: PaperFormat; + /** + * Sets the width of paper. You can pass in a number or a string with a unit. + */ + width?: string | number; + /** + * Sets the height of paper. You can pass in a number or a string with a unit. + */ + height?: string | number; + /** + * Give any CSS `@page` size declared in the page priority over what is + * declared in the `width` or `height` or `format` option. + * @defaultValue `false`, which will scale the content to fit the paper size. + */ + preferCSSPageSize?: boolean; + /** + * Set the PDF margins. + * @defaultValue `undefined` no margins are set. + */ + margin?: PDFMargin; + /** + * The path to save the file to. + * + * @remarks + * + * If the path is relative, it's resolved relative to the current working directory. + * + * @defaultValue `undefined`, which means the PDF will not be written to disk. + */ + path?: string; + /** + * Hides default white background and allows generating pdfs with transparency. + * @defaultValue `false` + */ + omitBackground?: boolean; + /** + * Generate tagged (accessible) PDF. + * @defaultValue `false` + * @experimental + */ + tagged?: boolean; + /** + * Timeout in milliseconds. Pass `0` to disable timeout. + * @defaultValue `30_000` + */ + timeout?: number; +} + +/** + * @internal + */ +export interface PaperFormatDimensions { + width: number; + height: number; +} + +/** + * @internal + */ +export interface ParsedPDFOptionsInterface { + width: number; + height: number; + margin: { + top: number; + bottom: number; + left: number; + right: number; + }; +} + +/** + * @internal + */ +export type ParsedPDFOptions = Required< + Omit<PDFOptions, 'path' | 'format' | 'timeout'> & ParsedPDFOptionsInterface +>; + +/** + * @internal + */ +export const paperFormats: Record<LowerCasePaperFormat, PaperFormatDimensions> = + { + letter: {width: 8.5, height: 11}, + legal: {width: 8.5, height: 14}, + tabloid: {width: 11, height: 17}, + ledger: {width: 17, height: 11}, + a0: {width: 33.1, height: 46.8}, + a1: {width: 23.4, height: 33.1}, + a2: {width: 16.54, height: 23.4}, + a3: {width: 11.7, height: 16.54}, + a4: {width: 8.27, height: 11.7}, + a5: {width: 5.83, height: 8.27}, + a6: {width: 4.13, height: 5.83}, + } as const; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts new file mode 100644 index 0000000000..db9b832d77 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + QueryHandler, + type QuerySelector, + type QuerySelectorAll, +} from './QueryHandler.js'; + +/** + * @internal + */ +export class PQueryHandler extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = ( + element, + selector, + {pQuerySelectorAll} + ) => { + return pQuerySelectorAll(element, selector); + }; + static override querySelector: QuerySelector = ( + element, + selector, + {pQuerySelector} + ) => { + return pQuerySelector(element, selector); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts new file mode 100644 index 0000000000..36ddbe7f3e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type PuppeteerUtil from '../injected/injected.js'; + +import {QueryHandler} from './QueryHandler.js'; + +/** + * @internal + */ +export class PierceQueryHandler extends QueryHandler { + static override querySelector = ( + element: Node, + selector: string, + {pierceQuerySelector}: PuppeteerUtil + ): Node | null => { + return pierceQuerySelector(element, selector); + }; + static override querySelectorAll = ( + element: Node, + selector: string, + {pierceQuerySelectorAll}: PuppeteerUtil + ): Iterable<Node> => { + return pierceQuerySelectorAll(element, selector); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts new file mode 100644 index 0000000000..dcd75aceb6 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Supported products. + * @public + */ +export type Product = 'chrome' | 'firefox'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts new file mode 100644 index 0000000000..844a3622bd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Browser} from '../api/Browser.js'; + +import {_connectToBrowser} from './BrowserConnector.js'; +import type {ConnectOptions} from './ConnectOptions.js'; +import { + type CustomQueryHandler, + customQueryHandlers, +} from './CustomQueryHandler.js'; + +/** + * Settings that are common to the Puppeteer class, regardless of environment. + * + * @internal + */ +export interface CommonPuppeteerSettings { + isPuppeteerCore: boolean; +} + +/** + * The main Puppeteer class. + * + * IMPORTANT: if you are using Puppeteer in a Node environment, you will get an + * instance of {@link PuppeteerNode} when you import or require `puppeteer`. + * That class extends `Puppeteer`, so has all the methods documented below as + * well as all that are defined on {@link PuppeteerNode}. + * + * @public + */ +export class Puppeteer { + /** + * Operations for {@link CustomQueryHandler | custom query handlers}. See + * {@link CustomQueryHandlerRegistry}. + * + * @internal + */ + static customQueryHandlers = customQueryHandlers; + + /** + * Registers a {@link CustomQueryHandler | custom query handler}. + * + * @remarks + * After registration, the handler can be used everywhere where a selector is + * expected by prepending the selection string with `<name>/`. The name is only + * allowed to consist of lower- and upper case latin letters. + * + * @example + * + * ``` + * puppeteer.registerCustomQueryHandler('text', { … }); + * const aHandle = await page.$('text/…'); + * ``` + * + * @param name - The name that the custom query handler will be registered + * under. + * @param queryHandler - The {@link CustomQueryHandler | custom query handler} + * to register. + * + * @public + */ + static registerCustomQueryHandler( + name: string, + queryHandler: CustomQueryHandler + ): void { + return this.customQueryHandlers.register(name, queryHandler); + } + + /** + * Unregisters a custom query handler for a given name. + */ + static unregisterCustomQueryHandler(name: string): void { + return this.customQueryHandlers.unregister(name); + } + + /** + * Gets the names of all custom query handlers. + */ + static customQueryHandlerNames(): string[] { + return this.customQueryHandlers.names(); + } + + /** + * Unregisters all custom query handlers. + */ + static clearCustomQueryHandlers(): void { + return this.customQueryHandlers.clear(); + } + + /** + * @internal + */ + _isPuppeteerCore: boolean; + /** + * @internal + */ + protected _changedProduct = false; + + /** + * @internal + */ + constructor(settings: CommonPuppeteerSettings) { + this._isPuppeteerCore = settings.isPuppeteerCore; + + this.connect = this.connect.bind(this); + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @remarks + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + connect(options: ConnectOptions): Promise<Browser> { + return _connectToBrowser(options); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts new file mode 100644 index 0000000000..1655c7dba2 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ElementHandle} from '../api/ElementHandle.js'; +import {_isElementHandle} from '../api/ElementHandleSymbol.js'; +import type {Frame} from '../api/Frame.js'; +import type {WaitForSelectorOptions} from '../api/Page.js'; +import type PuppeteerUtil from '../injected/injected.js'; +import {isErrorLike} from '../util/ErrorLike.js'; +import {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import {transposeIterableHandle} from './HandleIterator.js'; +import {LazyArg} from './LazyArg.js'; +import type {Awaitable, AwaitableIterable} from './types.js'; + +/** + * @internal + */ +export type QuerySelectorAll = ( + node: Node, + selector: string, + PuppeteerUtil: PuppeteerUtil +) => AwaitableIterable<Node>; + +/** + * @internal + */ +export type QuerySelector = ( + node: Node, + selector: string, + PuppeteerUtil: PuppeteerUtil +) => Awaitable<Node | null>; + +/** + * @internal + */ +export class QueryHandler { + // Either one of these may be implemented, but at least one must be. + static querySelectorAll?: QuerySelectorAll; + static querySelector?: QuerySelector; + + static get _querySelector(): QuerySelector { + if (this.querySelector) { + return this.querySelector; + } + if (!this.querySelectorAll) { + throw new Error('Cannot create default `querySelector`.'); + } + + return (this.querySelector = interpolateFunction( + async (node, selector, PuppeteerUtil) => { + const querySelectorAll: QuerySelectorAll = + PLACEHOLDER('querySelectorAll'); + const results = querySelectorAll(node, selector, PuppeteerUtil); + for await (const result of results) { + return result; + } + return null; + }, + { + querySelectorAll: stringifyFunction(this.querySelectorAll), + } + )); + } + + static get _querySelectorAll(): QuerySelectorAll { + if (this.querySelectorAll) { + return this.querySelectorAll; + } + if (!this.querySelector) { + throw new Error('Cannot create default `querySelectorAll`.'); + } + + return (this.querySelectorAll = interpolateFunction( + async function* (node, selector, PuppeteerUtil) { + const querySelector: QuerySelector = PLACEHOLDER('querySelector'); + const result = await querySelector(node, selector, PuppeteerUtil); + if (result) { + yield result; + } + }, + { + querySelector: stringifyFunction(this.querySelector), + } + )); + } + + /** + * Queries for multiple nodes given a selector and {@link ElementHandle}. + * + * Akin to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll | Document.querySelectorAll()}. + */ + static async *queryAll( + element: ElementHandle<Node>, + selector: string + ): AwaitableIterable<ElementHandle<Node>> { + using handle = await element.evaluateHandle( + this._querySelectorAll, + selector, + LazyArg.create(context => { + return context.puppeteerUtil; + }) + ); + yield* transposeIterableHandle(handle); + } + + /** + * Queries for a single node given a selector and {@link ElementHandle}. + * + * Akin to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector}. + */ + static async queryOne( + element: ElementHandle<Node>, + selector: string + ): Promise<ElementHandle<Node> | null> { + using result = await element.evaluateHandle( + this._querySelector, + selector, + LazyArg.create(context => { + return context.puppeteerUtil; + }) + ); + if (!(_isElementHandle in result)) { + return null; + } + return result.move(); + } + + /** + * Waits until a single node appears for a given selector and + * {@link ElementHandle}. + * + * This will always query the handle in the Puppeteer world and migrate the + * result to the main world. + */ + static async waitFor( + elementOrFrame: ElementHandle<Node> | Frame, + selector: string, + options: WaitForSelectorOptions + ): Promise<ElementHandle<Node> | null> { + let frame!: Frame; + using element = await (async () => { + if (!(_isElementHandle in elementOrFrame)) { + frame = elementOrFrame; + return; + } + frame = elementOrFrame.frame; + return await frame.isolatedRealm().adoptHandle(elementOrFrame); + })(); + + const {visible = false, hidden = false, timeout, signal} = options; + + try { + signal?.throwIfAborted(); + + using handle = await frame.isolatedRealm().waitForFunction( + async (PuppeteerUtil, query, selector, root, visible) => { + const querySelector = PuppeteerUtil.createFunction( + query + ) as QuerySelector; + const node = await querySelector( + root ?? document, + selector, + PuppeteerUtil + ); + return PuppeteerUtil.checkVisibility(node, visible); + }, + { + polling: visible || hidden ? 'raf' : 'mutation', + root: element, + timeout, + signal, + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + stringifyFunction(this._querySelector), + selector, + element, + visible ? true : hidden ? false : undefined + ); + + if (signal?.aborted) { + throw signal.reason; + } + + if (!(_isElementHandle in handle)) { + return null; + } + return await frame.mainRealm().transferHandle(handle); + } catch (error) { + if (!isErrorLike(error)) { + throw error; + } + if (error.name === 'AbortError') { + throw error; + } + error.message = `Waiting for selector \`${selector}\` failed: ${error.message}`; + throw error; + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts new file mode 100644 index 0000000000..0264c9175f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts @@ -0,0 +1,52 @@ +import {source as injectedSource} from '../generated/injected.js'; + +/** + * @internal + */ +export class ScriptInjector { + #updated = false; + #amendments = new Set<string>(); + + // Appends a statement of the form `(PuppeteerUtil) => {...}`. + append(statement: string): void { + this.#update(() => { + this.#amendments.add(statement); + }); + } + + pop(statement: string): void { + this.#update(() => { + this.#amendments.delete(statement); + }); + } + + inject(inject: (script: string) => void, force = false): void { + if (this.#updated || force) { + inject(this.#get()); + } + this.#updated = false; + } + + #update(callback: () => void): void { + callback(); + this.#updated = true; + } + + #get(): string { + return `(() => { + const module = {}; + ${injectedSource} + ${[...this.#amendments] + .map(statement => { + return `(${statement})(module.exports.default);`; + }) + .join('')} + return module.exports.default; + })()`; + } +} + +/** + * @internal + */ +export const scriptInjector = new ScriptInjector(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts new file mode 100644 index 0000000000..188eeea9ad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +/** + * The SecurityDetails class represents the security details of a + * response that was received over a secure connection. + * + * @public + */ +export class SecurityDetails { + #subjectName: string; + #issuer: string; + #validFrom: number; + #validTo: number; + #protocol: string; + #sanList: string[]; + + /** + * @internal + */ + constructor(securityPayload: Protocol.Network.SecurityDetails) { + this.#subjectName = securityPayload.subjectName; + this.#issuer = securityPayload.issuer; + this.#validFrom = securityPayload.validFrom; + this.#validTo = securityPayload.validTo; + this.#protocol = securityPayload.protocol; + this.#sanList = securityPayload.sanList; + } + + /** + * The name of the issuer of the certificate. + */ + issuer(): string { + return this.#issuer; + } + + /** + * {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp} + * marking the start of the certificate's validity. + */ + validFrom(): number { + return this.#validFrom; + } + + /** + * {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp} + * marking the end of the certificate's validity. + */ + validTo(): number { + return this.#validTo; + } + + /** + * The security protocol being used, e.g. "TLS 1.2". + */ + protocol(): string { + return this.#protocol; + } + + /** + * The name of the subject to which the certificate was issued. + */ + subjectName(): string { + return this.#subjectName; + } + + /** + * The list of {@link https://en.wikipedia.org/wiki/Subject_Alternative_Name | subject alternative names (SANs)} of the certificate. + */ + subjectAlternativeNames(): string[] { + return this.#sanList; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts new file mode 100644 index 0000000000..3ad1409c1b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export class TaskQueue { + #chain: Promise<void>; + + constructor() { + this.#chain = Promise.resolve(); + } + + postTask<T>(task: () => Promise<T>): Promise<T> { + const result = this.#chain.then(task); + this.#chain = result.then( + () => { + return undefined; + }, + () => { + return undefined; + } + ); + return result; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts new file mode 100644 index 0000000000..450ed06957 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {QueryHandler, type QuerySelectorAll} from './QueryHandler.js'; + +/** + * @internal + */ +export class TextQueryHandler extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = ( + element, + selector, + {textQuerySelectorAll} + ) => { + return textQuerySelectorAll(element, selector); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts new file mode 100644 index 0000000000..7789d89b75 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const DEFAULT_TIMEOUT = 30000; + +/** + * @internal + */ +export class TimeoutSettings { + #defaultTimeout: number | null; + #defaultNavigationTimeout: number | null; + + constructor() { + this.#defaultTimeout = null; + this.#defaultNavigationTimeout = null; + } + + setDefaultTimeout(timeout: number): void { + this.#defaultTimeout = timeout; + } + + setDefaultNavigationTimeout(timeout: number): void { + this.#defaultNavigationTimeout = timeout; + } + + navigationTimeout(): number { + if (this.#defaultNavigationTimeout !== null) { + return this.#defaultNavigationTimeout; + } + if (this.#defaultTimeout !== null) { + return this.#defaultTimeout; + } + return DEFAULT_TIMEOUT; + } + + timeout(): number { + if (this.#defaultTimeout !== null) { + return this.#defaultTimeout; + } + return DEFAULT_TIMEOUT; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts new file mode 100644 index 0000000000..0a6d2f2e18 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts @@ -0,0 +1,671 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export interface KeyDefinition { + keyCode?: number; + shiftKeyCode?: number; + key?: string; + shiftKey?: string; + code?: string; + text?: string; + shiftText?: string; + location?: number; +} + +/** + * All the valid keys that can be passed to functions that take user input, such + * as {@link Keyboard.press | keyboard.press } + * + * @public + */ +export type KeyInput = + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | 'Power' + | 'Eject' + | 'Abort' + | 'Help' + | 'Backspace' + | 'Tab' + | 'Numpad5' + | 'NumpadEnter' + | 'Enter' + | '\r' + | '\n' + | 'ShiftLeft' + | 'ShiftRight' + | 'ControlLeft' + | 'ControlRight' + | 'AltLeft' + | 'AltRight' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Convert' + | 'NonConvert' + | 'Space' + | 'Numpad9' + | 'PageUp' + | 'Numpad3' + | 'PageDown' + | 'End' + | 'Numpad1' + | 'Home' + | 'Numpad7' + | 'ArrowLeft' + | 'Numpad4' + | 'Numpad8' + | 'ArrowUp' + | 'ArrowRight' + | 'Numpad6' + | 'Numpad2' + | 'ArrowDown' + | 'Select' + | 'Open' + | 'PrintScreen' + | 'Insert' + | 'Numpad0' + | 'Delete' + | 'NumpadDecimal' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'NumpadMultiply' + | 'NumpadAdd' + | 'NumpadSubtract' + | 'NumpadDivide' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'F13' + | 'F14' + | 'F15' + | 'F16' + | 'F17' + | 'F18' + | 'F19' + | 'F20' + | 'F21' + | 'F22' + | 'F23' + | 'F24' + | 'NumLock' + | 'ScrollLock' + | 'AudioVolumeMute' + | 'AudioVolumeDown' + | 'AudioVolumeUp' + | 'MediaTrackNext' + | 'MediaTrackPrevious' + | 'MediaStop' + | 'MediaPlayPause' + | 'Semicolon' + | 'Equal' + | 'NumpadEqual' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'AltGraph' + | 'Props' + | 'Cancel' + | 'Clear' + | 'Shift' + | 'Control' + | 'Alt' + | 'Accept' + | 'ModeChange' + | ' ' + | 'Print' + | 'Execute' + | '\u0000' + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z' + | 'Meta' + | '*' + | '+' + | '-' + | '/' + | ';' + | '=' + | ',' + | '.' + | '`' + | '[' + | '\\' + | ']' + | "'" + | 'Attn' + | 'CrSel' + | 'ExSel' + | 'EraseEof' + | 'Play' + | 'ZoomOut' + | ')' + | '!' + | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '(' + | 'A' + | 'B' + | 'C' + | 'D' + | 'E' + | 'F' + | 'G' + | 'H' + | 'I' + | 'J' + | 'K' + | 'L' + | 'M' + | 'N' + | 'O' + | 'P' + | 'Q' + | 'R' + | 'S' + | 'T' + | 'U' + | 'V' + | 'W' + | 'X' + | 'Y' + | 'Z' + | ':' + | '<' + | '_' + | '>' + | '?' + | '~' + | '{' + | '|' + | '}' + | '"' + | 'SoftLeft' + | 'SoftRight' + | 'Camera' + | 'Call' + | 'EndCall' + | 'VolumeDown' + | 'VolumeUp'; + +/** + * @internal + */ +export const _keyDefinitions: Readonly<Record<KeyInput, KeyDefinition>> = { + '0': {keyCode: 48, key: '0', code: 'Digit0'}, + '1': {keyCode: 49, key: '1', code: 'Digit1'}, + '2': {keyCode: 50, key: '2', code: 'Digit2'}, + '3': {keyCode: 51, key: '3', code: 'Digit3'}, + '4': {keyCode: 52, key: '4', code: 'Digit4'}, + '5': {keyCode: 53, key: '5', code: 'Digit5'}, + '6': {keyCode: 54, key: '6', code: 'Digit6'}, + '7': {keyCode: 55, key: '7', code: 'Digit7'}, + '8': {keyCode: 56, key: '8', code: 'Digit8'}, + '9': {keyCode: 57, key: '9', code: 'Digit9'}, + Power: {key: 'Power', code: 'Power'}, + Eject: {key: 'Eject', code: 'Eject'}, + Abort: {keyCode: 3, code: 'Abort', key: 'Cancel'}, + Help: {keyCode: 6, code: 'Help', key: 'Help'}, + Backspace: {keyCode: 8, code: 'Backspace', key: 'Backspace'}, + Tab: {keyCode: 9, code: 'Tab', key: 'Tab'}, + Numpad5: { + keyCode: 12, + shiftKeyCode: 101, + key: 'Clear', + code: 'Numpad5', + shiftKey: '5', + location: 3, + }, + NumpadEnter: { + keyCode: 13, + code: 'NumpadEnter', + key: 'Enter', + text: '\r', + location: 3, + }, + Enter: {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'}, + '\r': {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'}, + '\n': {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'}, + ShiftLeft: {keyCode: 16, code: 'ShiftLeft', key: 'Shift', location: 1}, + ShiftRight: {keyCode: 16, code: 'ShiftRight', key: 'Shift', location: 2}, + ControlLeft: { + keyCode: 17, + code: 'ControlLeft', + key: 'Control', + location: 1, + }, + ControlRight: { + keyCode: 17, + code: 'ControlRight', + key: 'Control', + location: 2, + }, + AltLeft: {keyCode: 18, code: 'AltLeft', key: 'Alt', location: 1}, + AltRight: {keyCode: 18, code: 'AltRight', key: 'Alt', location: 2}, + Pause: {keyCode: 19, code: 'Pause', key: 'Pause'}, + CapsLock: {keyCode: 20, code: 'CapsLock', key: 'CapsLock'}, + Escape: {keyCode: 27, code: 'Escape', key: 'Escape'}, + Convert: {keyCode: 28, code: 'Convert', key: 'Convert'}, + NonConvert: {keyCode: 29, code: 'NonConvert', key: 'NonConvert'}, + Space: {keyCode: 32, code: 'Space', key: ' '}, + Numpad9: { + keyCode: 33, + shiftKeyCode: 105, + key: 'PageUp', + code: 'Numpad9', + shiftKey: '9', + location: 3, + }, + PageUp: {keyCode: 33, code: 'PageUp', key: 'PageUp'}, + Numpad3: { + keyCode: 34, + shiftKeyCode: 99, + key: 'PageDown', + code: 'Numpad3', + shiftKey: '3', + location: 3, + }, + PageDown: {keyCode: 34, code: 'PageDown', key: 'PageDown'}, + End: {keyCode: 35, code: 'End', key: 'End'}, + Numpad1: { + keyCode: 35, + shiftKeyCode: 97, + key: 'End', + code: 'Numpad1', + shiftKey: '1', + location: 3, + }, + Home: {keyCode: 36, code: 'Home', key: 'Home'}, + Numpad7: { + keyCode: 36, + shiftKeyCode: 103, + key: 'Home', + code: 'Numpad7', + shiftKey: '7', + location: 3, + }, + ArrowLeft: {keyCode: 37, code: 'ArrowLeft', key: 'ArrowLeft'}, + Numpad4: { + keyCode: 37, + shiftKeyCode: 100, + key: 'ArrowLeft', + code: 'Numpad4', + shiftKey: '4', + location: 3, + }, + Numpad8: { + keyCode: 38, + shiftKeyCode: 104, + key: 'ArrowUp', + code: 'Numpad8', + shiftKey: '8', + location: 3, + }, + ArrowUp: {keyCode: 38, code: 'ArrowUp', key: 'ArrowUp'}, + ArrowRight: {keyCode: 39, code: 'ArrowRight', key: 'ArrowRight'}, + Numpad6: { + keyCode: 39, + shiftKeyCode: 102, + key: 'ArrowRight', + code: 'Numpad6', + shiftKey: '6', + location: 3, + }, + Numpad2: { + keyCode: 40, + shiftKeyCode: 98, + key: 'ArrowDown', + code: 'Numpad2', + shiftKey: '2', + location: 3, + }, + ArrowDown: {keyCode: 40, code: 'ArrowDown', key: 'ArrowDown'}, + Select: {keyCode: 41, code: 'Select', key: 'Select'}, + Open: {keyCode: 43, code: 'Open', key: 'Execute'}, + PrintScreen: {keyCode: 44, code: 'PrintScreen', key: 'PrintScreen'}, + Insert: {keyCode: 45, code: 'Insert', key: 'Insert'}, + Numpad0: { + keyCode: 45, + shiftKeyCode: 96, + key: 'Insert', + code: 'Numpad0', + shiftKey: '0', + location: 3, + }, + Delete: {keyCode: 46, code: 'Delete', key: 'Delete'}, + NumpadDecimal: { + keyCode: 46, + shiftKeyCode: 110, + code: 'NumpadDecimal', + key: '\u0000', + shiftKey: '.', + location: 3, + }, + Digit0: {keyCode: 48, code: 'Digit0', shiftKey: ')', key: '0'}, + Digit1: {keyCode: 49, code: 'Digit1', shiftKey: '!', key: '1'}, + Digit2: {keyCode: 50, code: 'Digit2', shiftKey: '@', key: '2'}, + Digit3: {keyCode: 51, code: 'Digit3', shiftKey: '#', key: '3'}, + Digit4: {keyCode: 52, code: 'Digit4', shiftKey: '$', key: '4'}, + Digit5: {keyCode: 53, code: 'Digit5', shiftKey: '%', key: '5'}, + Digit6: {keyCode: 54, code: 'Digit6', shiftKey: '^', key: '6'}, + Digit7: {keyCode: 55, code: 'Digit7', shiftKey: '&', key: '7'}, + Digit8: {keyCode: 56, code: 'Digit8', shiftKey: '*', key: '8'}, + Digit9: {keyCode: 57, code: 'Digit9', shiftKey: '(', key: '9'}, + KeyA: {keyCode: 65, code: 'KeyA', shiftKey: 'A', key: 'a'}, + KeyB: {keyCode: 66, code: 'KeyB', shiftKey: 'B', key: 'b'}, + KeyC: {keyCode: 67, code: 'KeyC', shiftKey: 'C', key: 'c'}, + KeyD: {keyCode: 68, code: 'KeyD', shiftKey: 'D', key: 'd'}, + KeyE: {keyCode: 69, code: 'KeyE', shiftKey: 'E', key: 'e'}, + KeyF: {keyCode: 70, code: 'KeyF', shiftKey: 'F', key: 'f'}, + KeyG: {keyCode: 71, code: 'KeyG', shiftKey: 'G', key: 'g'}, + KeyH: {keyCode: 72, code: 'KeyH', shiftKey: 'H', key: 'h'}, + KeyI: {keyCode: 73, code: 'KeyI', shiftKey: 'I', key: 'i'}, + KeyJ: {keyCode: 74, code: 'KeyJ', shiftKey: 'J', key: 'j'}, + KeyK: {keyCode: 75, code: 'KeyK', shiftKey: 'K', key: 'k'}, + KeyL: {keyCode: 76, code: 'KeyL', shiftKey: 'L', key: 'l'}, + KeyM: {keyCode: 77, code: 'KeyM', shiftKey: 'M', key: 'm'}, + KeyN: {keyCode: 78, code: 'KeyN', shiftKey: 'N', key: 'n'}, + KeyO: {keyCode: 79, code: 'KeyO', shiftKey: 'O', key: 'o'}, + KeyP: {keyCode: 80, code: 'KeyP', shiftKey: 'P', key: 'p'}, + KeyQ: {keyCode: 81, code: 'KeyQ', shiftKey: 'Q', key: 'q'}, + KeyR: {keyCode: 82, code: 'KeyR', shiftKey: 'R', key: 'r'}, + KeyS: {keyCode: 83, code: 'KeyS', shiftKey: 'S', key: 's'}, + KeyT: {keyCode: 84, code: 'KeyT', shiftKey: 'T', key: 't'}, + KeyU: {keyCode: 85, code: 'KeyU', shiftKey: 'U', key: 'u'}, + KeyV: {keyCode: 86, code: 'KeyV', shiftKey: 'V', key: 'v'}, + KeyW: {keyCode: 87, code: 'KeyW', shiftKey: 'W', key: 'w'}, + KeyX: {keyCode: 88, code: 'KeyX', shiftKey: 'X', key: 'x'}, + KeyY: {keyCode: 89, code: 'KeyY', shiftKey: 'Y', key: 'y'}, + KeyZ: {keyCode: 90, code: 'KeyZ', shiftKey: 'Z', key: 'z'}, + MetaLeft: {keyCode: 91, code: 'MetaLeft', key: 'Meta', location: 1}, + MetaRight: {keyCode: 92, code: 'MetaRight', key: 'Meta', location: 2}, + ContextMenu: {keyCode: 93, code: 'ContextMenu', key: 'ContextMenu'}, + NumpadMultiply: { + keyCode: 106, + code: 'NumpadMultiply', + key: '*', + location: 3, + }, + NumpadAdd: {keyCode: 107, code: 'NumpadAdd', key: '+', location: 3}, + NumpadSubtract: { + keyCode: 109, + code: 'NumpadSubtract', + key: '-', + location: 3, + }, + NumpadDivide: {keyCode: 111, code: 'NumpadDivide', key: '/', location: 3}, + F1: {keyCode: 112, code: 'F1', key: 'F1'}, + F2: {keyCode: 113, code: 'F2', key: 'F2'}, + F3: {keyCode: 114, code: 'F3', key: 'F3'}, + F4: {keyCode: 115, code: 'F4', key: 'F4'}, + F5: {keyCode: 116, code: 'F5', key: 'F5'}, + F6: {keyCode: 117, code: 'F6', key: 'F6'}, + F7: {keyCode: 118, code: 'F7', key: 'F7'}, + F8: {keyCode: 119, code: 'F8', key: 'F8'}, + F9: {keyCode: 120, code: 'F9', key: 'F9'}, + F10: {keyCode: 121, code: 'F10', key: 'F10'}, + F11: {keyCode: 122, code: 'F11', key: 'F11'}, + F12: {keyCode: 123, code: 'F12', key: 'F12'}, + F13: {keyCode: 124, code: 'F13', key: 'F13'}, + F14: {keyCode: 125, code: 'F14', key: 'F14'}, + F15: {keyCode: 126, code: 'F15', key: 'F15'}, + F16: {keyCode: 127, code: 'F16', key: 'F16'}, + F17: {keyCode: 128, code: 'F17', key: 'F17'}, + F18: {keyCode: 129, code: 'F18', key: 'F18'}, + F19: {keyCode: 130, code: 'F19', key: 'F19'}, + F20: {keyCode: 131, code: 'F20', key: 'F20'}, + F21: {keyCode: 132, code: 'F21', key: 'F21'}, + F22: {keyCode: 133, code: 'F22', key: 'F22'}, + F23: {keyCode: 134, code: 'F23', key: 'F23'}, + F24: {keyCode: 135, code: 'F24', key: 'F24'}, + NumLock: {keyCode: 144, code: 'NumLock', key: 'NumLock'}, + ScrollLock: {keyCode: 145, code: 'ScrollLock', key: 'ScrollLock'}, + AudioVolumeMute: { + keyCode: 173, + code: 'AudioVolumeMute', + key: 'AudioVolumeMute', + }, + AudioVolumeDown: { + keyCode: 174, + code: 'AudioVolumeDown', + key: 'AudioVolumeDown', + }, + AudioVolumeUp: {keyCode: 175, code: 'AudioVolumeUp', key: 'AudioVolumeUp'}, + MediaTrackNext: { + keyCode: 176, + code: 'MediaTrackNext', + key: 'MediaTrackNext', + }, + MediaTrackPrevious: { + keyCode: 177, + code: 'MediaTrackPrevious', + key: 'MediaTrackPrevious', + }, + MediaStop: {keyCode: 178, code: 'MediaStop', key: 'MediaStop'}, + MediaPlayPause: { + keyCode: 179, + code: 'MediaPlayPause', + key: 'MediaPlayPause', + }, + Semicolon: {keyCode: 186, code: 'Semicolon', shiftKey: ':', key: ';'}, + Equal: {keyCode: 187, code: 'Equal', shiftKey: '+', key: '='}, + NumpadEqual: {keyCode: 187, code: 'NumpadEqual', key: '=', location: 3}, + Comma: {keyCode: 188, code: 'Comma', shiftKey: '<', key: ','}, + Minus: {keyCode: 189, code: 'Minus', shiftKey: '_', key: '-'}, + Period: {keyCode: 190, code: 'Period', shiftKey: '>', key: '.'}, + Slash: {keyCode: 191, code: 'Slash', shiftKey: '?', key: '/'}, + Backquote: {keyCode: 192, code: 'Backquote', shiftKey: '~', key: '`'}, + BracketLeft: {keyCode: 219, code: 'BracketLeft', shiftKey: '{', key: '['}, + Backslash: {keyCode: 220, code: 'Backslash', shiftKey: '|', key: '\\'}, + BracketRight: {keyCode: 221, code: 'BracketRight', shiftKey: '}', key: ']'}, + Quote: {keyCode: 222, code: 'Quote', shiftKey: '"', key: "'"}, + AltGraph: {keyCode: 225, code: 'AltGraph', key: 'AltGraph'}, + Props: {keyCode: 247, code: 'Props', key: 'CrSel'}, + Cancel: {keyCode: 3, key: 'Cancel', code: 'Abort'}, + Clear: {keyCode: 12, key: 'Clear', code: 'Numpad5', location: 3}, + Shift: {keyCode: 16, key: 'Shift', code: 'ShiftLeft', location: 1}, + Control: {keyCode: 17, key: 'Control', code: 'ControlLeft', location: 1}, + Alt: {keyCode: 18, key: 'Alt', code: 'AltLeft', location: 1}, + Accept: {keyCode: 30, key: 'Accept'}, + ModeChange: {keyCode: 31, key: 'ModeChange'}, + ' ': {keyCode: 32, key: ' ', code: 'Space'}, + Print: {keyCode: 42, key: 'Print'}, + Execute: {keyCode: 43, key: 'Execute', code: 'Open'}, + '\u0000': {keyCode: 46, key: '\u0000', code: 'NumpadDecimal', location: 3}, + a: {keyCode: 65, key: 'a', code: 'KeyA'}, + b: {keyCode: 66, key: 'b', code: 'KeyB'}, + c: {keyCode: 67, key: 'c', code: 'KeyC'}, + d: {keyCode: 68, key: 'd', code: 'KeyD'}, + e: {keyCode: 69, key: 'e', code: 'KeyE'}, + f: {keyCode: 70, key: 'f', code: 'KeyF'}, + g: {keyCode: 71, key: 'g', code: 'KeyG'}, + h: {keyCode: 72, key: 'h', code: 'KeyH'}, + i: {keyCode: 73, key: 'i', code: 'KeyI'}, + j: {keyCode: 74, key: 'j', code: 'KeyJ'}, + k: {keyCode: 75, key: 'k', code: 'KeyK'}, + l: {keyCode: 76, key: 'l', code: 'KeyL'}, + m: {keyCode: 77, key: 'm', code: 'KeyM'}, + n: {keyCode: 78, key: 'n', code: 'KeyN'}, + o: {keyCode: 79, key: 'o', code: 'KeyO'}, + p: {keyCode: 80, key: 'p', code: 'KeyP'}, + q: {keyCode: 81, key: 'q', code: 'KeyQ'}, + r: {keyCode: 82, key: 'r', code: 'KeyR'}, + s: {keyCode: 83, key: 's', code: 'KeyS'}, + t: {keyCode: 84, key: 't', code: 'KeyT'}, + u: {keyCode: 85, key: 'u', code: 'KeyU'}, + v: {keyCode: 86, key: 'v', code: 'KeyV'}, + w: {keyCode: 87, key: 'w', code: 'KeyW'}, + x: {keyCode: 88, key: 'x', code: 'KeyX'}, + y: {keyCode: 89, key: 'y', code: 'KeyY'}, + z: {keyCode: 90, key: 'z', code: 'KeyZ'}, + Meta: {keyCode: 91, key: 'Meta', code: 'MetaLeft', location: 1}, + '*': {keyCode: 106, key: '*', code: 'NumpadMultiply', location: 3}, + '+': {keyCode: 107, key: '+', code: 'NumpadAdd', location: 3}, + '-': {keyCode: 109, key: '-', code: 'NumpadSubtract', location: 3}, + '/': {keyCode: 111, key: '/', code: 'NumpadDivide', location: 3}, + ';': {keyCode: 186, key: ';', code: 'Semicolon'}, + '=': {keyCode: 187, key: '=', code: 'Equal'}, + ',': {keyCode: 188, key: ',', code: 'Comma'}, + '.': {keyCode: 190, key: '.', code: 'Period'}, + '`': {keyCode: 192, key: '`', code: 'Backquote'}, + '[': {keyCode: 219, key: '[', code: 'BracketLeft'}, + '\\': {keyCode: 220, key: '\\', code: 'Backslash'}, + ']': {keyCode: 221, key: ']', code: 'BracketRight'}, + "'": {keyCode: 222, key: "'", code: 'Quote'}, + Attn: {keyCode: 246, key: 'Attn'}, + CrSel: {keyCode: 247, key: 'CrSel', code: 'Props'}, + ExSel: {keyCode: 248, key: 'ExSel'}, + EraseEof: {keyCode: 249, key: 'EraseEof'}, + Play: {keyCode: 250, key: 'Play'}, + ZoomOut: {keyCode: 251, key: 'ZoomOut'}, + ')': {keyCode: 48, key: ')', code: 'Digit0'}, + '!': {keyCode: 49, key: '!', code: 'Digit1'}, + '@': {keyCode: 50, key: '@', code: 'Digit2'}, + '#': {keyCode: 51, key: '#', code: 'Digit3'}, + $: {keyCode: 52, key: '$', code: 'Digit4'}, + '%': {keyCode: 53, key: '%', code: 'Digit5'}, + '^': {keyCode: 54, key: '^', code: 'Digit6'}, + '&': {keyCode: 55, key: '&', code: 'Digit7'}, + '(': {keyCode: 57, key: '(', code: 'Digit9'}, + A: {keyCode: 65, key: 'A', code: 'KeyA'}, + B: {keyCode: 66, key: 'B', code: 'KeyB'}, + C: {keyCode: 67, key: 'C', code: 'KeyC'}, + D: {keyCode: 68, key: 'D', code: 'KeyD'}, + E: {keyCode: 69, key: 'E', code: 'KeyE'}, + F: {keyCode: 70, key: 'F', code: 'KeyF'}, + G: {keyCode: 71, key: 'G', code: 'KeyG'}, + H: {keyCode: 72, key: 'H', code: 'KeyH'}, + I: {keyCode: 73, key: 'I', code: 'KeyI'}, + J: {keyCode: 74, key: 'J', code: 'KeyJ'}, + K: {keyCode: 75, key: 'K', code: 'KeyK'}, + L: {keyCode: 76, key: 'L', code: 'KeyL'}, + M: {keyCode: 77, key: 'M', code: 'KeyM'}, + N: {keyCode: 78, key: 'N', code: 'KeyN'}, + O: {keyCode: 79, key: 'O', code: 'KeyO'}, + P: {keyCode: 80, key: 'P', code: 'KeyP'}, + Q: {keyCode: 81, key: 'Q', code: 'KeyQ'}, + R: {keyCode: 82, key: 'R', code: 'KeyR'}, + S: {keyCode: 83, key: 'S', code: 'KeyS'}, + T: {keyCode: 84, key: 'T', code: 'KeyT'}, + U: {keyCode: 85, key: 'U', code: 'KeyU'}, + V: {keyCode: 86, key: 'V', code: 'KeyV'}, + W: {keyCode: 87, key: 'W', code: 'KeyW'}, + X: {keyCode: 88, key: 'X', code: 'KeyX'}, + Y: {keyCode: 89, key: 'Y', code: 'KeyY'}, + Z: {keyCode: 90, key: 'Z', code: 'KeyZ'}, + ':': {keyCode: 186, key: ':', code: 'Semicolon'}, + '<': {keyCode: 188, key: '<', code: 'Comma'}, + _: {keyCode: 189, key: '_', code: 'Minus'}, + '>': {keyCode: 190, key: '>', code: 'Period'}, + '?': {keyCode: 191, key: '?', code: 'Slash'}, + '~': {keyCode: 192, key: '~', code: 'Backquote'}, + '{': {keyCode: 219, key: '{', code: 'BracketLeft'}, + '|': {keyCode: 220, key: '|', code: 'Backslash'}, + '}': {keyCode: 221, key: '}', code: 'BracketRight'}, + '"': {keyCode: 222, key: '"', code: 'Quote'}, + SoftLeft: {key: 'SoftLeft', code: 'SoftLeft', location: 4}, + SoftRight: {key: 'SoftRight', code: 'SoftRight', location: 4}, + Camera: {keyCode: 44, key: 'Camera', code: 'Camera', location: 4}, + Call: {key: 'Call', code: 'Call', location: 4}, + EndCall: {keyCode: 95, key: 'EndCall', code: 'EndCall', location: 4}, + VolumeDown: { + keyCode: 182, + key: 'VolumeDown', + code: 'VolumeDown', + location: 4, + }, + VolumeUp: {keyCode: 183, key: 'VolumeUp', code: 'VolumeUp', location: 4}, +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts new file mode 100644 index 0000000000..46a937a88f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @public + */ +export interface Viewport { + /** + * The page width in CSS pixels. + * + * @remarks + * Setting this value to `0` will reset this value to the system default. + */ + width: number; + /** + * The page height in CSS pixels. + * + * @remarks + * Setting this value to `0` will reset this value to the system default. + */ + height: number; + /** + * Specify device scale factor. + * See {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio | devicePixelRatio} for more info. + * + * @remarks + * Setting this value to `0` will reset this value to the system default. + * + * @defaultValue `1` + */ + deviceScaleFactor?: number; + /** + * Whether the `meta viewport` tag is taken into account. + * @defaultValue `false` + */ + isMobile?: boolean; + /** + * Specifies if the viewport is in landscape mode. + * @defaultValue `false` + */ + isLandscape?: boolean; + /** + * Specify if the viewport supports touch events. + * @defaultValue `false` + */ + hasTouch?: boolean; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts new file mode 100644 index 0000000000..d0c1e2a038 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import type {Realm} from '../api/Realm.js'; +import type {Poller} from '../injected/Poller.js'; +import {Deferred} from '../util/Deferred.js'; +import {isErrorLike} from '../util/ErrorLike.js'; +import {stringifyFunction} from '../util/Function.js'; + +import {TimeoutError} from './Errors.js'; +import {LazyArg} from './LazyArg.js'; +import type {HandleFor} from './types.js'; + +/** + * @internal + */ +export interface WaitTaskOptions { + polling: 'raf' | 'mutation' | number; + root?: ElementHandle<Node>; + timeout: number; + signal?: AbortSignal; +} + +/** + * @internal + */ +export class WaitTask<T = unknown> { + #world: Realm; + #polling: 'raf' | 'mutation' | number; + #root?: ElementHandle<Node>; + + #fn: string; + #args: unknown[]; + + #timeout?: NodeJS.Timeout; + #timeoutError?: TimeoutError; + + #result = Deferred.create<HandleFor<T>>(); + + #poller?: JSHandle<Poller<T>>; + #signal?: AbortSignal; + #reruns: AbortController[] = []; + + constructor( + world: Realm, + options: WaitTaskOptions, + fn: ((...args: unknown[]) => Promise<T>) | string, + ...args: unknown[] + ) { + this.#world = world; + this.#polling = options.polling; + this.#root = options.root; + this.#signal = options.signal; + this.#signal?.addEventListener( + 'abort', + () => { + void this.terminate(this.#signal?.reason); + }, + { + once: true, + } + ); + + switch (typeof fn) { + case 'string': + this.#fn = `() => {return (${fn});}`; + break; + default: + this.#fn = stringifyFunction(fn); + break; + } + this.#args = args; + + this.#world.taskManager.add(this); + + if (options.timeout) { + this.#timeoutError = new TimeoutError( + `Waiting failed: ${options.timeout}ms exceeded` + ); + this.#timeout = setTimeout(() => { + void this.terminate(this.#timeoutError); + }, options.timeout); + } + + void this.rerun(); + } + + get result(): Promise<HandleFor<T>> { + return this.#result.valueOrThrow(); + } + + async rerun(): Promise<void> { + for (const prev of this.#reruns) { + prev.abort(); + } + this.#reruns.length = 0; + const controller = new AbortController(); + this.#reruns.push(controller); + try { + switch (this.#polling) { + case 'raf': + this.#poller = await this.#world.evaluateHandle( + ({RAFPoller, createFunction}, fn, ...args) => { + const fun = createFunction(fn); + return new RAFPoller(() => { + return fun(...args) as Promise<T>; + }); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + this.#fn, + ...this.#args + ); + break; + case 'mutation': + this.#poller = await this.#world.evaluateHandle( + ({MutationPoller, createFunction}, root, fn, ...args) => { + const fun = createFunction(fn); + return new MutationPoller(() => { + return fun(...args) as Promise<T>; + }, root || document); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + this.#root, + this.#fn, + ...this.#args + ); + break; + default: + this.#poller = await this.#world.evaluateHandle( + ({IntervalPoller, createFunction}, ms, fn, ...args) => { + const fun = createFunction(fn); + return new IntervalPoller(() => { + return fun(...args) as Promise<T>; + }, ms); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + this.#polling, + this.#fn, + ...this.#args + ); + break; + } + + await this.#poller.evaluate(poller => { + void poller.start(); + }); + + const result = await this.#poller.evaluateHandle(poller => { + return poller.result(); + }); + this.#result.resolve(result); + + await this.terminate(); + } catch (error) { + if (controller.signal.aborted) { + return; + } + const badError = this.getBadError(error); + if (badError) { + await this.terminate(badError); + } + } + } + + async terminate(error?: Error): Promise<void> { + this.#world.taskManager.delete(this); + + clearTimeout(this.#timeout); + + if (error && !this.#result.finished()) { + this.#result.reject(error); + } + + if (this.#poller) { + try { + await this.#poller.evaluateHandle(async poller => { + await poller.stop(); + }); + if (this.#poller) { + await this.#poller.dispose(); + this.#poller = undefined; + } + } catch { + // Ignore errors since they most likely come from low-level cleanup. + } + } + } + + /** + * Not all errors lead to termination. They usually imply we need to rerun the task. + */ + getBadError(error: unknown): Error | undefined { + if (isErrorLike(error)) { + // When frame is detached the task should have been terminated by the IsolatedWorld. + // This can fail if we were adding this task while the frame was detached, + // so we terminate here instead. + if ( + error.message.includes( + 'Execution context is not available in detached frame' + ) + ) { + return new Error('Waiting failed: Frame detached'); + } + + // When the page is navigated, the promise is rejected. + // We will try again in the new execution context. + if (error.message.includes('Execution context was destroyed')) { + return; + } + + // We could have tried to evaluate in a context which was already + // destroyed. + if (error.message.includes('Cannot find context with specified id')) { + return; + } + + // Errors coming from WebDriver BiDi. TODO: Adjust messages after + // https://github.com/w3c/webdriver-bidi/issues/540 is resolved. + if ( + error.message.includes( + "AbortError: Actor 'MessageHandlerFrame' destroyed" + ) + ) { + return; + } + + return error; + } + + return new Error('WaitTask failed with an error', { + cause: error, + }); + } +} + +/** + * @internal + */ +export class TaskManager { + #tasks: Set<WaitTask> = new Set<WaitTask>(); + + add(task: WaitTask<any>): void { + this.#tasks.add(task); + } + + delete(task: WaitTask<any>): void { + this.#tasks.delete(task); + } + + terminateAll(error?: Error): void { + for (const task of this.#tasks) { + void task.terminate(error); + } + this.#tasks.clear(); + } + + async rerunAll(): Promise<void> { + await Promise.all( + [...this.#tasks].map(task => { + return task.rerun(); + }) + ); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts new file mode 100644 index 0000000000..b6e3a67bad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + QueryHandler, + type QuerySelectorAll, + type QuerySelector, +} from './QueryHandler.js'; + +/** + * @internal + */ +export class XPathQueryHandler extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = ( + element, + selector, + {xpathQuerySelectorAll} + ) => { + return xpathQuerySelectorAll(element, selector); + }; + + static override querySelector: QuerySelector = ( + element: Node, + selector: string, + {xpathQuerySelectorAll} + ) => { + for (const result of xpathQuerySelectorAll(element, selector, 1)) { + return result; + } + return null; + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts new file mode 100644 index 0000000000..6ef8925605 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './BrowserWebSocketTransport.js'; +export * from './CallbackRegistry.js'; +export * from './Configuration.js'; +export * from './ConnectionTransport.js'; +export * from './ConnectOptions.js'; +export * from './ConsoleMessage.js'; +export * from './CustomQueryHandler.js'; +export * from './Debug.js'; +export * from './Device.js'; +export * from './Errors.js'; +export * from './EventEmitter.js'; +export * from './fetch.js'; +export * from './FileChooser.js'; +export * from './GetQueryHandler.js'; +export * from './HandleIterator.js'; +export * from './LazyArg.js'; +export * from './NetworkManagerEvents.js'; +export * from './PDFOptions.js'; +export * from './PierceQueryHandler.js'; +export * from './PQueryHandler.js'; +export * from './Product.js'; +export * from './Puppeteer.js'; +export * from './QueryHandler.js'; +export * from './ScriptInjector.js'; +export * from './SecurityDetails.js'; +export * from './TaskQueue.js'; +export * from './TextQueryHandler.js'; +export * from './TimeoutSettings.js'; +export * from './types.js'; +export * from './USKeyboardLayout.js'; +export * from './util.js'; +export * from './Viewport.js'; +export * from './WaitTask.js'; +export * from './XPathQueryHandler.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts new file mode 100644 index 0000000000..6c7a2b451c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Gets the global version if we're in the browser, else loads the node-fetch module. + * + * @internal + */ +export const getFetch = async (): Promise<typeof fetch> => { + return (globalThis as any).fetch || (await import('cross-fetch')).fetch; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts new file mode 100644 index 0000000000..3f2cf5d4f3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {JSHandle} from '../api/JSHandle.js'; + +import type {LazyArg} from './LazyArg.js'; + +/** + * @public + */ +export type AwaitablePredicate<T> = (value: T) => Awaitable<boolean>; + +/** + * @public + */ +export interface Moveable { + /** + * Moves the resource when 'using'. + */ + move(): this; +} + +/** + * @internal + */ +export interface Disposed { + get disposed(): boolean; +} + +/** + * @internal + */ +export interface BindingPayload { + type: string; + name: string; + seq: number; + args: unknown[]; + /** + * Determines whether the arguments of the payload are trivial. + */ + isTrivial: boolean; +} + +/** + * @internal + */ +export type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>; + +/** + * @public + */ +export type AwaitableIterable<T> = Iterable<T> | AsyncIterable<T>; + +/** + * @public + */ +export type Awaitable<T> = T | PromiseLike<T>; + +/** + * @public + */ +export type HandleFor<T> = T extends Node ? ElementHandle<T> : JSHandle<T>; + +/** + * @public + */ +export type HandleOr<T> = HandleFor<T> | JSHandle<T> | T; + +/** + * @public + */ +export type FlattenHandle<T> = T extends HandleOr<infer U> ? U : never; + +/** + * @internal + */ +export type FlattenLazyArg<T> = T extends LazyArg<infer U> ? U : T; + +/** + * @internal + */ +export type InnerLazyParams<T extends unknown[]> = { + [K in keyof T]: FlattenLazyArg<T[K]>; +}; + +/** + * @public + */ +export type InnerParams<T extends unknown[]> = { + [K in keyof T]: FlattenHandle<T[K]>; +}; + +/** + * @public + */ +export type ElementFor< + TagName extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap, +> = TagName extends keyof HTMLElementTagNameMap + ? HTMLElementTagNameMap[TagName] + : TagName extends keyof SVGElementTagNameMap + ? SVGElementTagNameMap[TagName] + : never; + +/** + * @public + */ +export type EvaluateFunc<T extends unknown[]> = ( + ...params: InnerParams<T> +) => Awaitable<unknown>; + +/** + * @public + */ +export type EvaluateFuncWith<V, T extends unknown[]> = ( + ...params: [V, ...InnerParams<T>] +) => Awaitable<unknown>; + +/** + * @public + */ +export type NodeFor<ComplexSelector extends string> = + TypeSelectorOfComplexSelector<ComplexSelector> extends infer TypeSelector + ? TypeSelector extends + | keyof HTMLElementTagNameMap + | keyof SVGElementTagNameMap + ? ElementFor<TypeSelector> + : Element + : never; + +type TypeSelectorOfComplexSelector<ComplexSelector extends string> = + CompoundSelectorsOfComplexSelector<ComplexSelector> extends infer CompoundSelectors + ? CompoundSelectors extends NonEmptyReadonlyArray<string> + ? Last<CompoundSelectors> extends infer LastCompoundSelector + ? LastCompoundSelector extends string + ? TypeSelectorOfCompoundSelector<LastCompoundSelector> + : never + : never + : unknown + : never; + +type TypeSelectorOfCompoundSelector<CompoundSelector extends string> = + SplitWithDelemiters< + CompoundSelector, + BeginSubclassSelectorTokens + > extends infer CompoundSelectorTokens + ? CompoundSelectorTokens extends [infer TypeSelector, ...any[]] + ? TypeSelector extends '' + ? unknown + : TypeSelector + : never + : never; + +type Last<Arr extends NonEmptyReadonlyArray<unknown>> = Arr extends [ + infer Head, + ...infer Tail, +] + ? Tail extends NonEmptyReadonlyArray<unknown> + ? Last<Tail> + : Head + : never; + +type NonEmptyReadonlyArray<T> = [T, ...(readonly T[])]; + +type CompoundSelectorsOfComplexSelector<ComplexSelector extends string> = + SplitWithDelemiters< + ComplexSelector, + CombinatorTokens + > extends infer IntermediateTokens + ? IntermediateTokens extends readonly string[] + ? Drop<IntermediateTokens, ''> + : never + : never; + +type SplitWithDelemiters< + Input extends string, + Delemiters extends readonly string[], +> = Delemiters extends [infer FirstDelemiter, ...infer RestDelemiters] + ? FirstDelemiter extends string + ? RestDelemiters extends readonly string[] + ? FlatmapSplitWithDelemiters<Split<Input, FirstDelemiter>, RestDelemiters> + : never + : never + : [Input]; + +type BeginSubclassSelectorTokens = ['.', '#', '[', ':']; + +type CombinatorTokens = [' ', '>', '+', '~', '|', '|']; + +type Drop< + Arr extends readonly unknown[], + Remove, + Acc extends unknown[] = [], +> = Arr extends [infer Head, ...infer Tail] + ? Head extends Remove + ? Drop<Tail, Remove> + : Drop<Tail, Remove, [...Acc, Head]> + : Acc; + +type FlatmapSplitWithDelemiters< + Inputs extends readonly string[], + Delemiters extends readonly string[], + Acc extends string[] = [], +> = Inputs extends [infer FirstInput, ...infer RestInputs] + ? FirstInput extends string + ? RestInputs extends readonly string[] + ? FlatmapSplitWithDelemiters< + RestInputs, + Delemiters, + [...Acc, ...SplitWithDelemiters<FirstInput, Delemiters>] + > + : Acc + : Acc + : Acc; + +type Split< + Input extends string, + Delimiter extends string, + Acc extends string[] = [], +> = Input extends `${infer Prefix}${Delimiter}${infer Suffix}` + ? Split<Suffix, Delimiter, [...Acc, Prefix]> + : [...Acc, Input]; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts new file mode 100644 index 0000000000..2c8f76f664 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts @@ -0,0 +1,447 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type FS from 'fs/promises'; +import type {Readable} from 'stream'; + +import {map, NEVER, Observable, timer} from '../../third_party/rxjs/rxjs.js'; +import type {CDPSession} from '../api/CDPSession.js'; +import {isNode} from '../environment.js'; +import {assert} from '../util/assert.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {debug} from './Debug.js'; +import {TimeoutError} from './Errors.js'; +import type {EventEmitter, EventType} from './EventEmitter.js'; +import type { + LowerCasePaperFormat, + ParsedPDFOptions, + PDFOptions, +} from './PDFOptions.js'; +import {paperFormats} from './PDFOptions.js'; + +/** + * @internal + */ +export const debugError = debug('puppeteer:error'); + +/** + * @internal + */ +export const DEFAULT_VIEWPORT = Object.freeze({width: 800, height: 600}); + +/** + * @internal + */ +const SOURCE_URL = Symbol('Source URL for Puppeteer evaluation scripts'); + +/** + * @internal + */ +export class PuppeteerURL { + static INTERNAL_URL = 'pptr:internal'; + + static fromCallSite( + functionName: string, + site: NodeJS.CallSite + ): PuppeteerURL { + const url = new PuppeteerURL(); + url.#functionName = functionName; + url.#siteString = site.toString(); + return url; + } + + static parse = (url: string): PuppeteerURL => { + url = url.slice('pptr:'.length); + const [functionName = '', siteString = ''] = url.split(';'); + const puppeteerUrl = new PuppeteerURL(); + puppeteerUrl.#functionName = functionName; + puppeteerUrl.#siteString = decodeURIComponent(siteString); + return puppeteerUrl; + }; + + static isPuppeteerURL = (url: string): boolean => { + return url.startsWith('pptr:'); + }; + + #functionName!: string; + #siteString!: string; + + get functionName(): string { + return this.#functionName; + } + + get siteString(): string { + return this.#siteString; + } + + toString(): string { + return `pptr:${[ + this.#functionName, + encodeURIComponent(this.#siteString), + ].join(';')}`; + } +} + +/** + * @internal + */ +export const withSourcePuppeteerURLIfNone = <T extends NonNullable<unknown>>( + functionName: string, + object: T +): T => { + if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { + return object; + } + const original = Error.prepareStackTrace; + Error.prepareStackTrace = (_, stack) => { + // First element is the function. + // Second element is the caller of this function. + // Third element is the caller of the caller of this function + // which is precisely what we want. + return stack[2]; + }; + const site = new Error().stack as unknown as NodeJS.CallSite; + Error.prepareStackTrace = original; + return Object.assign(object, { + [SOURCE_URL]: PuppeteerURL.fromCallSite(functionName, site), + }); +}; + +/** + * @internal + */ +export const getSourcePuppeteerURLIfAvailable = < + T extends NonNullable<unknown>, +>( + object: T +): PuppeteerURL | undefined => { + if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { + return object[SOURCE_URL as keyof T] as PuppeteerURL; + } + return undefined; +}; + +/** + * @internal + */ +export const isString = (obj: unknown): obj is string => { + return typeof obj === 'string' || obj instanceof String; +}; + +/** + * @internal + */ +export const isNumber = (obj: unknown): obj is number => { + return typeof obj === 'number' || obj instanceof Number; +}; + +/** + * @internal + */ +export const isPlainObject = (obj: unknown): obj is Record<any, unknown> => { + return typeof obj === 'object' && obj?.constructor === Object; +}; + +/** + * @internal + */ +export const isRegExp = (obj: unknown): obj is RegExp => { + return typeof obj === 'object' && obj?.constructor === RegExp; +}; + +/** + * @internal + */ +export const isDate = (obj: unknown): obj is Date => { + return typeof obj === 'object' && obj?.constructor === Date; +}; + +/** + * @internal + */ +export function evaluationString( + fun: Function | string, + ...args: unknown[] +): string { + if (isString(fun)) { + assert(args.length === 0, 'Cannot evaluate a string with arguments'); + return fun; + } + + function serializeArgument(arg: unknown): string { + if (Object.is(arg, undefined)) { + return 'undefined'; + } + return JSON.stringify(arg); + } + + return `(${fun})(${args.map(serializeArgument).join(',')})`; +} + +/** + * @internal + */ +let fs: typeof FS | null = null; +/** + * @internal + */ +export async function importFSPromises(): Promise<typeof FS> { + if (!fs) { + try { + fs = await import('fs/promises'); + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + 'Cannot write to a path outside of a Node-like environment.' + ); + } + throw error; + } + } + return fs; +} + +/** + * @internal + */ +export async function getReadableAsBuffer( + readable: Readable, + path?: string +): Promise<Buffer | null> { + const buffers = []; + if (path) { + const fs = await importFSPromises(); + const fileHandle = await fs.open(path, 'w+'); + try { + for await (const chunk of readable) { + buffers.push(chunk); + await fileHandle.writeFile(chunk); + } + } finally { + await fileHandle.close(); + } + } else { + for await (const chunk of readable) { + buffers.push(chunk); + } + } + try { + return Buffer.concat(buffers); + } catch (error) { + return null; + } +} + +/** + * @internal + */ +export async function getReadableFromProtocolStream( + client: CDPSession, + handle: string +): Promise<Readable> { + // TODO: Once Node 18 becomes the lowest supported version, we can migrate to + // ReadableStream. + if (!isNode) { + throw new Error('Cannot create a stream outside of Node.js environment.'); + } + + const {Readable} = await import('stream'); + + let eof = false; + return new Readable({ + async read(size: number) { + if (eof) { + return; + } + + try { + const response = await client.send('IO.read', {handle, size}); + this.push(response.data, response.base64Encoded ? 'base64' : undefined); + if (response.eof) { + eof = true; + await client.send('IO.close', {handle}); + this.push(null); + } + } catch (error) { + if (isErrorLike(error)) { + this.destroy(error); + return; + } + throw error; + } + }, + }); +} + +/** + * @internal + */ +export function validateDialogType( + type: string +): 'alert' | 'confirm' | 'prompt' | 'beforeunload' { + let dialogType = null; + const validDialogTypes = new Set([ + 'alert', + 'confirm', + 'prompt', + 'beforeunload', + ]); + + if (validDialogTypes.has(type)) { + dialogType = type; + } + assert(dialogType, `Unknown javascript dialog type: ${type}`); + return dialogType as 'alert' | 'confirm' | 'prompt' | 'beforeunload'; +} + +/** + * @internal + */ +export function timeout(ms: number): Observable<never> { + return ms === 0 + ? NEVER + : timer(ms).pipe( + map(() => { + throw new TimeoutError(`Timed out after waiting ${ms}ms`); + }) + ); +} + +/** + * @internal + */ +export const UTILITY_WORLD_NAME = '__puppeteer_utility_world__'; + +/** + * @internal + */ +export const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; +/** + * @internal + */ +export function getSourceUrlComment(url: string): string { + return `//# sourceURL=${url}`; +} + +/** + * @internal + */ +export const NETWORK_IDLE_TIME = 500; + +/** + * @internal + */ +export function parsePDFOptions( + options: PDFOptions = {}, + lengthUnit: 'in' | 'cm' = 'in' +): ParsedPDFOptions { + const defaults: Omit<ParsedPDFOptions, 'width' | 'height' | 'margin'> = { + scale: 1, + displayHeaderFooter: false, + headerTemplate: '', + footerTemplate: '', + printBackground: false, + landscape: false, + pageRanges: '', + preferCSSPageSize: false, + omitBackground: false, + tagged: false, + }; + + let width = 8.5; + let height = 11; + if (options.format) { + const format = + paperFormats[options.format.toLowerCase() as LowerCasePaperFormat]; + assert(format, 'Unknown paper format: ' + options.format); + width = format.width; + height = format.height; + } else { + width = convertPrintParameterToInches(options.width, lengthUnit) ?? width; + height = + convertPrintParameterToInches(options.height, lengthUnit) ?? height; + } + + const margin = { + top: convertPrintParameterToInches(options.margin?.top, lengthUnit) || 0, + left: convertPrintParameterToInches(options.margin?.left, lengthUnit) || 0, + bottom: + convertPrintParameterToInches(options.margin?.bottom, lengthUnit) || 0, + right: + convertPrintParameterToInches(options.margin?.right, lengthUnit) || 0, + }; + + return { + ...defaults, + ...options, + width, + height, + margin, + }; +} + +/** + * @internal + */ +export const unitToPixels = { + px: 1, + in: 96, + cm: 37.8, + mm: 3.78, +}; + +function convertPrintParameterToInches( + parameter?: string | number, + lengthUnit: 'in' | 'cm' = 'in' +): number | undefined { + if (typeof parameter === 'undefined') { + return undefined; + } + let pixels; + if (isNumber(parameter)) { + // Treat numbers as pixel values to be aligned with phantom's paperSize. + pixels = parameter; + } else if (isString(parameter)) { + const text = parameter; + let unit = text.substring(text.length - 2).toLowerCase(); + let valueText = ''; + if (unit in unitToPixels) { + valueText = text.substring(0, text.length - 2); + } else { + // In case of unknown unit try to parse the whole parameter as number of pixels. + // This is consistent with phantom's paperSize behavior. + unit = 'px'; + valueText = text; + } + const value = Number(valueText); + assert(!isNaN(value), 'Failed to parse parameter value: ' + text); + pixels = value * unitToPixels[unit as keyof typeof unitToPixels]; + } else { + throw new Error( + 'page.pdf() Cannot handle parameter type: ' + typeof parameter + ); + } + return pixels / unitToPixels[lengthUnit]; +} + +/** + * @internal + */ +export function fromEmitterEvent< + Events extends Record<EventType, unknown>, + Event extends keyof Events, +>(emitter: EventEmitter<Events>, eventName: Event): Observable<Events[Event]> { + return new Observable(subscriber => { + const listener = (event: Events[Event]) => { + subscriber.next(event); + }; + emitter.on(eventName, listener); + return () => { + emitter.off(eventName, listener); + }; + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts b/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts new file mode 100644 index 0000000000..bf7227243d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const isNode = !!(typeof process !== 'undefined' && process.version); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts new file mode 100644 index 0000000000..972b6a6c64 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +declare global { + interface Window { + /** + * @internal + */ + __ariaQuerySelector(root: Node, selector: string): Promise<Node | null>; + /** + * @internal + */ + __ariaQuerySelectorAll(root: Node, selector: string): Promise<Node[]>; + } +} + +export const ariaQuerySelector = ( + root: Node, + selector: string +): Promise<Node | null> => { + return window.__ariaQuerySelector(root, selector); +}; +export const ariaQuerySelectorAll = async function* ( + root: Node, + selector: string +): AsyncIterable<Node> { + yield* await window.__ariaQuerySelectorAll(root, selector); +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts new file mode 100644 index 0000000000..ccd041deea --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CustomQueryHandler} from '../common/CustomQueryHandler.js'; +import type {Awaitable, AwaitableIterable} from '../common/types.js'; + +export interface CustomQuerySelector { + querySelector(root: Node, selector: string): Awaitable<Node | null>; + querySelectorAll(root: Node, selector: string): AwaitableIterable<Node>; +} + +/** + * This class mimics the injected {@link CustomQuerySelectorRegistry}. + */ +class CustomQuerySelectorRegistry { + #selectors = new Map<string, CustomQuerySelector>(); + + register(name: string, handler: CustomQueryHandler): void { + if (!handler.queryOne && handler.queryAll) { + const querySelectorAll = handler.queryAll; + handler.queryOne = (node, selector) => { + for (const result of querySelectorAll(node, selector)) { + return result; + } + return null; + }; + } else if (handler.queryOne && !handler.queryAll) { + const querySelector = handler.queryOne; + handler.queryAll = (node, selector) => { + const result = querySelector(node, selector); + return result ? [result] : []; + }; + } else if (!handler.queryOne || !handler.queryAll) { + throw new Error('At least one query method must be defined.'); + } + + this.#selectors.set(name, { + querySelector: handler.queryOne, + querySelectorAll: handler.queryAll!, + }); + } + + unregister(name: string): void { + this.#selectors.delete(name); + } + + get(name: string): CustomQuerySelector | undefined { + return this.#selectors.get(name); + } + + clear() { + this.#selectors.clear(); + } +} + +export const customQuerySelectors = new CustomQuerySelectorRegistry(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts new file mode 100644 index 0000000000..11499c072f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {AwaitableIterable} from '../common/types.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +import {ariaQuerySelectorAll} from './ARIAQuerySelector.js'; +import {customQuerySelectors} from './CustomQuerySelector.js'; +import { + type ComplexPSelector, + type ComplexPSelectorList, + type CompoundPSelector, + type CSSSelector, + parsePSelectors, + PCombinator, + type PPseudoSelector, +} from './PSelectorParser.js'; +import {textQuerySelectorAll} from './TextQuerySelector.js'; +import {pierce, pierceAll} from './util.js'; +import {xpathQuerySelectorAll} from './XPathQuerySelector.js'; + +const IDENT_TOKEN_START = /[-\w\P{ASCII}*]/; + +interface QueryableNode extends Node { + querySelectorAll: typeof Document.prototype.querySelectorAll; +} + +const isQueryableNode = (node: Node): node is QueryableNode => { + return 'querySelectorAll' in node; +}; + +class SelectorError extends Error { + constructor(selector: string, message: string) { + super(`${selector} is not a valid selector: ${message}`); + } +} + +class PQueryEngine { + #input: string; + + #complexSelector: ComplexPSelector; + #compoundSelector: CompoundPSelector = []; + #selector: CSSSelector | PPseudoSelector | undefined = undefined; + + elements: AwaitableIterable<Node>; + + constructor(element: Node, input: string, complexSelector: ComplexPSelector) { + this.elements = [element]; + this.#input = input; + this.#complexSelector = complexSelector; + this.#next(); + } + + async run(): Promise<void> { + if (typeof this.#selector === 'string') { + switch (this.#selector.trimStart()) { + case ':scope': + // `:scope` has some special behavior depending on the node. It always + // represents the current node within a compound selector, but by + // itself, it depends on the node. For example, Document is + // represented by `<html>`, but any HTMLElement is not represented by + // itself (i.e. `null`). This can be troublesome if our combinators + // are used right after so we treat this selector specially. + this.#next(); + break; + } + } + + for (; this.#selector !== undefined; this.#next()) { + const selector = this.#selector; + const input = this.#input; + if (typeof selector === 'string') { + // The regular expression tests if the selector is a type/universal + // selector. Any other case means we want to apply the selector onto + // the element itself (e.g. `element.class`, `element>div`, + // `element:hover`, etc.). + if (selector[0] && IDENT_TOKEN_START.test(selector[0])) { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + if (isQueryableNode(element)) { + yield* element.querySelectorAll(selector); + } + } + ); + } else { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + if (!element.parentElement) { + if (!isQueryableNode(element)) { + return; + } + yield* element.querySelectorAll(selector); + return; + } + + let index = 0; + for (const child of element.parentElement.children) { + ++index; + if (child === element) { + break; + } + } + yield* element.parentElement.querySelectorAll( + `:scope>:nth-child(${index})${selector}` + ); + } + ); + } + } else { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + switch (selector.name) { + case 'text': + yield* textQuerySelectorAll(element, selector.value); + break; + case 'xpath': + yield* xpathQuerySelectorAll(element, selector.value); + break; + case 'aria': + yield* ariaQuerySelectorAll(element, selector.value); + break; + default: + const querySelector = customQuerySelectors.get(selector.name); + if (!querySelector) { + throw new SelectorError( + input, + `Unknown selector type: ${selector.name}` + ); + } + yield* querySelector.querySelectorAll(element, selector.value); + } + } + ); + } + } + } + + #next() { + if (this.#compoundSelector.length !== 0) { + this.#selector = this.#compoundSelector.shift(); + return; + } + if (this.#complexSelector.length === 0) { + this.#selector = undefined; + return; + } + const selector = this.#complexSelector.shift(); + switch (selector) { + case PCombinator.Child: { + this.elements = AsyncIterableUtil.flatMap(this.elements, pierce); + this.#next(); + break; + } + case PCombinator.Descendent: { + this.elements = AsyncIterableUtil.flatMap(this.elements, pierceAll); + this.#next(); + break; + } + default: + this.#compoundSelector = selector as CompoundPSelector; + this.#next(); + break; + } + } +} + +class DepthCalculator { + #cache = new WeakMap<Node, number[]>(); + + calculate(node: Node | null, depth: number[] = []): number[] { + if (node === null) { + return depth; + } + if (node instanceof ShadowRoot) { + node = node.host; + } + + const cachedDepth = this.#cache.get(node); + if (cachedDepth) { + return [...cachedDepth, ...depth]; + } + + let index = 0; + for ( + let prevSibling = node.previousSibling; + prevSibling; + prevSibling = prevSibling.previousSibling + ) { + ++index; + } + + const value = this.calculate(node.parentNode, [index]); + this.#cache.set(node, value); + return [...value, ...depth]; + } +} + +const compareDepths = (a: number[], b: number[]): -1 | 0 | 1 => { + if (a.length + b.length === 0) { + return 0; + } + const [i = -1, ...otherA] = a; + const [j = -1, ...otherB] = b; + if (i === j) { + return compareDepths(otherA, otherB); + } + return i < j ? -1 : 1; +}; + +const domSort = async function* (elements: AwaitableIterable<Node>) { + const results = new Set<Node>(); + for await (const element of elements) { + results.add(element); + } + const calculator = new DepthCalculator(); + yield* [...results.values()] + .map(result => { + return [result, calculator.calculate(result)] as const; + }) + .sort(([, a], [, b]) => { + return compareDepths(a, b); + }) + .map(([result]) => { + return result; + }); +}; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const pQuerySelectorAll = function ( + root: Node, + selector: string +): AwaitableIterable<Node> { + let selectors: ComplexPSelectorList; + let isPureCSS: boolean; + try { + [selectors, isPureCSS] = parsePSelectors(selector); + } catch (error) { + return (root as unknown as QueryableNode).querySelectorAll(selector); + } + + if (isPureCSS) { + return (root as unknown as QueryableNode).querySelectorAll(selector); + } + // If there are any empty elements, then this implies the selector has + // contiguous combinators (e.g. `>>> >>>>`) or starts/ends with one which we + // treat as illegal, similar to existing behavior. + if ( + selectors.some(parts => { + let i = 0; + return parts.some(parts => { + if (typeof parts === 'string') { + ++i; + } else { + i = 0; + } + return i > 1; + }); + }) + ) { + throw new SelectorError( + selector, + 'Multiple deep combinators found in sequence.' + ); + } + + return domSort( + AsyncIterableUtil.flatMap(selectors, selectorParts => { + const query = new PQueryEngine(root, selector, selectorParts); + void query.run(); + return query.elements; + }) + ); +}; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const pQuerySelector = async function ( + root: Node, + selector: string +): Promise<Node | null> { + for await (const element of pQuerySelectorAll(root, selector)) { + return element; + } + return null; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts new file mode 100644 index 0000000000..8044562348 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {type Token, tokenize, TOKENS, stringify} from 'parsel-js'; + +export type CSSSelector = string; +export interface PPseudoSelector { + name: string; + value: string; +} +export const enum PCombinator { + Descendent = '>>>', + Child = '>>>>', +} +export type CompoundPSelector = Array<CSSSelector | PPseudoSelector>; +export type ComplexPSelector = Array<CompoundPSelector | PCombinator>; +export type ComplexPSelectorList = ComplexPSelector[]; + +TOKENS['combinator'] = /\s*(>>>>?|[\s>+~])\s*/g; + +const ESCAPE_REGEXP = /\\[\s\S]/g; +const unquote = (text: string): string => { + if (text.length <= 1) { + return text; + } + if ((text[0] === '"' || text[0] === "'") && text.endsWith(text[0])) { + text = text.slice(1, -1); + } + return text.replace(ESCAPE_REGEXP, match => { + return match[1] as string; + }); +}; + +export function parsePSelectors( + selector: string +): [selector: ComplexPSelectorList, isPureCSS: boolean] { + let isPureCSS = true; + const tokens = tokenize(selector); + if (tokens.length === 0) { + return [[], isPureCSS]; + } + let compoundSelector: CompoundPSelector = []; + let complexSelector: ComplexPSelector = [compoundSelector]; + const selectors: ComplexPSelectorList = [complexSelector]; + const storage: Token[] = []; + for (const token of tokens) { + switch (token.type) { + case 'combinator': + switch (token.content) { + case PCombinator.Descendent: + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector.push(PCombinator.Descendent); + complexSelector.push(compoundSelector); + continue; + case PCombinator.Child: + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector.push(PCombinator.Child); + complexSelector.push(compoundSelector); + continue; + } + break; + case 'pseudo-element': + if (!token.name.startsWith('-p-')) { + break; + } + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector.push({ + name: token.name.slice(3), + value: unquote(token.argument ?? ''), + }); + continue; + case 'comma': + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector = [compoundSelector]; + selectors.push(complexSelector); + continue; + } + storage.push(token); + } + if (storage.length) { + compoundSelector.push(stringify(storage)); + } + return [selectors, isPureCSS]; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts new file mode 100644 index 0000000000..c224ee8324 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const pierceQuerySelector = ( + root: Node, + selector: string +): Element | null => { + let found: Node | null = null; + const search = (root: Node) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as Element; + if (currentNode.shadowRoot) { + search(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (currentNode !== root && !found && currentNode.matches(selector)) { + found = currentNode; + } + } while (!found && iter.nextNode()); + }; + if (root instanceof Document) { + root = root.documentElement; + } + search(root); + return found; +}; + +/** + * @internal + */ +export const pierceQuerySelectorAll = ( + element: Node, + selector: string +): Element[] => { + const result: Element[] = []; + const collect = (root: Node) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as Element; + if (currentNode.shadowRoot) { + collect(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (currentNode !== root && currentNode.matches(selector)) { + result.push(currentNode); + } + } while (iter.nextNode()); + }; + if (element instanceof Document) { + element = element.documentElement; + } + collect(element); + return result; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts new file mode 100644 index 0000000000..68b9f1812b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +/** + * @internal + */ +export interface Poller<T> { + start(): Promise<void>; + stop(): Promise<void>; + result(): Promise<T>; +} + +/** + * @internal + */ +export class MutationPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + + #root: Node; + + #observer?: MutationObserver; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>, root: Node) { + this.#fn = fn; + this.#root = root; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + this.#observer = new MutationObserver(async () => { + const result = await this.#fn(); + if (!result) { + return; + } + deferred.resolve(result); + await this.stop(); + }); + this.#observer.observe(this.#root, { + childList: true, + subtree: true, + attributes: true, + }); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + if (this.#observer) { + this.#observer.disconnect(); + this.#observer = undefined; + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} + +/** + * @internal + */ +export class RAFPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>) { + this.#fn = fn; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + const poll = async () => { + if (deferred.finished()) { + return; + } + const result = await this.#fn(); + if (!result) { + window.requestAnimationFrame(poll); + return; + } + deferred.resolve(result); + await this.stop(); + }; + window.requestAnimationFrame(poll); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} + +/** + * @internal + */ + +export class IntervalPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + #ms: number; + + #interval?: NodeJS.Timeout; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>, ms: number) { + this.#fn = fn; + this.#ms = ms; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + this.#interval = setInterval(async () => { + const result = await this.#fn(); + if (!result) { + return; + } + deferred.resolve(result); + await this.stop(); + }, this.#ms); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + if (this.#interval) { + clearInterval(this.#interval); + this.#interval = undefined; + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts new file mode 100644 index 0000000000..ffe8980d5e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +interface NonTrivialValueNode extends Node { + value: string; +} + +const TRIVIAL_VALUE_INPUT_TYPES = new Set(['checkbox', 'image', 'radio']); + +/** + * Determines if the node has a non-trivial value property. + * + * @internal + */ +const isNonTrivialValueNode = (node: Node): node is NonTrivialValueNode => { + if (node instanceof HTMLSelectElement) { + return true; + } + if (node instanceof HTMLTextAreaElement) { + return true; + } + if ( + node instanceof HTMLInputElement && + !TRIVIAL_VALUE_INPUT_TYPES.has(node.type) + ) { + return true; + } + return false; +}; + +const UNSUITABLE_NODE_NAMES = new Set(['SCRIPT', 'STYLE']); + +/** + * Determines whether a given node is suitable for text matching. + * + * @internal + */ +export const isSuitableNodeForTextMatching = (node: Node): boolean => { + return ( + !UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node) + ); +}; + +/** + * @internal + */ +export interface TextContent { + // Contains the full text of the node. + full: string; + // Contains the text immediately beneath the node. + immediate: string[]; +} + +/** + * Maps {@link Node}s to their computed {@link TextContent}. + */ +const textContentCache = new WeakMap<Node, TextContent>(); +const eraseFromCache = (node: Node | null) => { + while (node) { + textContentCache.delete(node); + if (node instanceof ShadowRoot) { + node = node.host; + } else { + node = node.parentNode; + } + } +}; + +/** + * Erases the cache when the tree has mutated text. + */ +const observedNodes = new WeakSet<Node>(); +const textChangeObserver = new MutationObserver(mutations => { + for (const mutation of mutations) { + eraseFromCache(mutation.target); + } +}); + +/** + * Builds the text content of a node using some custom logic. + * + * @remarks + * The primary reason this function exists is due to {@link ShadowRoot}s not having + * text content. + * + * @internal + */ +export const createTextContent = (root: Node): TextContent => { + let value = textContentCache.get(root); + if (value) { + return value; + } + value = {full: '', immediate: []}; + if (!isSuitableNodeForTextMatching(root)) { + return value; + } + + let currentImmediate = ''; + if (isNonTrivialValueNode(root)) { + value.full = root.value; + value.immediate.push(root.value); + + root.addEventListener( + 'input', + event => { + eraseFromCache(event.target as HTMLInputElement); + }, + {once: true, capture: true} + ); + } else { + for (let child = root.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.TEXT_NODE) { + value.full += child.nodeValue ?? ''; + currentImmediate += child.nodeValue ?? ''; + continue; + } + if (currentImmediate) { + value.immediate.push(currentImmediate); + } + currentImmediate = ''; + if (child.nodeType === Node.ELEMENT_NODE) { + value.full += createTextContent(child).full; + } + } + if (currentImmediate) { + value.immediate.push(currentImmediate); + } + if (root instanceof Element && root.shadowRoot) { + value.full += createTextContent(root.shadowRoot).full; + } + + if (!observedNodes.has(root)) { + textChangeObserver.observe(root, { + childList: true, + characterData: true, + subtree: true, + }); + observedNodes.add(root); + } + } + textContentCache.set(root, value); + return value; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts new file mode 100644 index 0000000000..debc423ccf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createTextContent, + isSuitableNodeForTextMatching, +} from './TextContent.js'; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const textQuerySelectorAll = function* ( + root: Node, + selector: string +): Generator<Element> { + let yielded = false; + for (const node of root.childNodes) { + if (node instanceof Element && isSuitableNodeForTextMatching(node)) { + let matches: Generator<Element, boolean>; + if (!node.shadowRoot) { + matches = textQuerySelectorAll(node, selector); + } else { + matches = textQuerySelectorAll(node.shadowRoot, selector); + } + for (const match of matches) { + yield match; + yielded = true; + } + } + } + if (yielded) { + return; + } + + if (root instanceof Element && isSuitableNodeForTextMatching(root)) { + const textContent = createTextContent(root); + if (textContent.full.includes(selector)) { + yield root; + } + } +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts new file mode 100644 index 0000000000..039bfa5e54 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const xpathQuerySelectorAll = function* ( + root: Node, + selector: string, + maxResults = -1 +): Iterable<Node> { + const doc = root.ownerDocument || document; + const iterator = doc.evaluate( + selector, + root, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE + ); + const items = []; + let item; + + // Read all results upfront to avoid + // https://stackoverflow.com/questions/48235278/xpath-error-the-document-has-mutated-since-the-result-was-returned. + while ((item = iterator.iterateNext())) { + items.push(item); + if (maxResults && items.length === maxResults) { + break; + } + } + + for (let i = 0; i < items.length; i++) { + item = items[i]; + yield item as Node; + delete items[i]; + } +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts new file mode 100644 index 0000000000..e81d274290 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Deferred} from '../util/Deferred.js'; +import {createFunction} from '../util/Function.js'; + +import * as ARIAQuerySelector from './ARIAQuerySelector.js'; +import * as CustomQuerySelectors from './CustomQuerySelector.js'; +import * as PierceQuerySelector from './PierceQuerySelector.js'; +import {IntervalPoller, MutationPoller, RAFPoller} from './Poller.js'; +import * as PQuerySelector from './PQuerySelector.js'; +import { + createTextContent, + isSuitableNodeForTextMatching, +} from './TextContent.js'; +import * as TextQuerySelector from './TextQuerySelector.js'; +import * as util from './util.js'; +import * as XPathQuerySelector from './XPathQuerySelector.js'; + +/** + * @internal + */ +const PuppeteerUtil = Object.freeze({ + ...ARIAQuerySelector, + ...CustomQuerySelectors, + ...PierceQuerySelector, + ...PQuerySelector, + ...TextQuerySelector, + ...util, + ...XPathQuerySelector, + Deferred, + createFunction, + createTextContent, + IntervalPoller, + isSuitableNodeForTextMatching, + MutationPoller, + RAFPoller, +}); + +/** + * @internal + */ +type PuppeteerUtil = typeof PuppeteerUtil; + +/** + * @internal + */ +export default PuppeteerUtil; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts new file mode 100644 index 0000000000..34fe8f7748 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts @@ -0,0 +1,67 @@ +const HIDDEN_VISIBILITY_VALUES = ['hidden', 'collapse']; + +/** + * @internal + */ +export const checkVisibility = ( + node: Node | null, + visible?: boolean +): Node | boolean => { + if (!node) { + return visible === false; + } + if (visible === undefined) { + return node; + } + const element = ( + node.nodeType === Node.TEXT_NODE ? node.parentElement : node + ) as Element; + + const style = window.getComputedStyle(element); + const isVisible = + style && + !HIDDEN_VISIBILITY_VALUES.includes(style.visibility) && + !isBoundingBoxEmpty(element); + return visible === isVisible ? node : false; +}; + +function isBoundingBoxEmpty(element: Element): boolean { + const rect = element.getBoundingClientRect(); + return rect.width === 0 || rect.height === 0; +} + +const hasShadowRoot = (node: Node): node is Node & {shadowRoot: ShadowRoot} => { + return 'shadowRoot' in node && node.shadowRoot instanceof ShadowRoot; +}; + +/** + * @internal + */ +export function* pierce(root: Node): IterableIterator<Node | ShadowRoot> { + if (hasShadowRoot(root)) { + yield root.shadowRoot; + } else { + yield root; + } +} + +/** + * @internal + */ +export function* pierceAll(root: Node): IterableIterator<Node | ShadowRoot> { + root = pierce(root).next().value; + yield root; + const walkers = [document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)]; + for (const walker of walkers) { + let node: Element | null; + while ((node = walker.nextNode() as Element | null)) { + if (!node.shadowRoot) { + continue; + } + yield node.shadowRoot; + walkers.push( + document.createTreeWalker(node.shadowRoot, NodeFilter.SHOW_ELEMENT) + ); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts new file mode 100644 index 0000000000..9abd3697f7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import {getFeatures, removeMatchingFlags} from './ChromeLauncher.js'; + +describe('getFeatures', () => { + it('returns an empty array when no options are provided', () => { + const result = getFeatures('--foo'); + expect(result).toEqual([]); + }); + + it('returns an empty array when no options match the flag', () => { + const result = getFeatures('--foo', ['--bar', '--baz']); + expect(result).toEqual([]); + }); + + it('returns an array of values when options match the flag', () => { + const result = getFeatures('--foo', ['--foo=bar', '--foo=baz']); + expect(result).toEqual(['bar', 'baz']); + }); + + it('does not handle whitespace', () => { + const result = getFeatures('--foo', ['--foo bar', '--foo baz ']); + expect(result).toEqual([]); + }); + + it('handles equals sign around the flag and value', () => { + const result = getFeatures('--foo', ['--foo=bar', '--foo=baz ']); + expect(result).toEqual(['bar', 'baz']); + }); +}); + +describe('removeMatchingFlags', () => { + it('empty', () => { + const a: string[] = []; + expect(removeMatchingFlags(a, '--foo')).toEqual([]); + }); + + it('with one match', () => { + const a: string[] = ['--foo=1', '--bar=baz']; + expect(removeMatchingFlags(a, '--foo')).toEqual(['--bar=baz']); + }); + + it('with multiple matches', () => { + const a: string[] = ['--foo=1', '--foo=2', '--bar=baz']; + expect(removeMatchingFlags(a, '--foo')).toEqual(['--bar=baz']); + }); + + it('with no matches', () => { + const a: string[] = ['--foo=1', '--bar=baz']; + expect(removeMatchingFlags(a, '--baz')).toEqual(['--foo=1', '--bar=baz']); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts new file mode 100644 index 0000000000..51d5a19983 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts @@ -0,0 +1,344 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {mkdtemp} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + computeSystemExecutablePath, + Browser as SupportedBrowsers, + ChromeReleaseChannel as BrowsersChromeReleaseChannel, +} from '@puppeteer/browsers'; + +import type {Browser} from '../api/Browser.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, type ResolvedLaunchArgs} from './ProductLauncher.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; +import {rm} from './util/fs.js'; + +/** + * @internal + */ +export class ChromeLauncher extends ProductLauncher { + constructor(puppeteer: PuppeteerNode) { + super(puppeteer, 'chrome'); + } + + override launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> { + const headless = options.headless ?? true; + if ( + headless === true && + this.puppeteer.configuration.logLevel === 'warn' && + !Boolean(process.env['PUPPETEER_DISABLE_HEADLESS_WARNING']) + ) { + console.warn( + [ + '\x1B[1m\x1B[43m\x1B[30m', + 'Puppeteer old Headless deprecation warning:\x1B[0m\x1B[33m', + ' In the near future `headless: true` will default to the new Headless mode', + ' for Chrome instead of the old Headless implementation. For more', + ' information, please see https://developer.chrome.com/articles/new-headless/.', + ' Consider opting in early by passing `headless: "new"` to `puppeteer.launch()`', + ' If you encounter any bugs, please report them to https://github.com/puppeteer/puppeteer/issues/new/choose.\x1B[0m\n', + ].join('\n ') + ); + } + + if ( + this.puppeteer.configuration.logLevel === 'warn' && + process.platform === 'darwin' && + process.arch === 'x64' + ) { + const cpus = os.cpus(); + if (cpus[0]?.model.includes('Apple')) { + console.warn( + [ + '\x1B[1m\x1B[43m\x1B[30m', + 'Degraded performance warning:\x1B[0m\x1B[33m', + 'Launching Chrome on Mac Silicon (arm64) from an x64 Node installation results in', + 'Rosetta translating the Chrome binary, even if Chrome is already arm64. This would', + 'result in huge performance issues. To resolve this, you must run Puppeteer with', + 'a version of Node built for arm64.', + ].join('\n ') + ); + } + } + + return super.launch(options); + } + + /** + * @internal + */ + override async computeLaunchArguments( + options: PuppeteerNodeLaunchOptions = {} + ): Promise<ResolvedLaunchArgs> { + const { + ignoreDefaultArgs = false, + args = [], + pipe = false, + debuggingPort, + channel, + executablePath, + } = options; + + const chromeArguments = []; + if (!ignoreDefaultArgs) { + chromeArguments.push(...this.defaultArgs(options)); + } else if (Array.isArray(ignoreDefaultArgs)) { + chromeArguments.push( + ...this.defaultArgs(options).filter(arg => { + return !ignoreDefaultArgs.includes(arg); + }) + ); + } else { + chromeArguments.push(...args); + } + + if ( + !chromeArguments.some(argument => { + return argument.startsWith('--remote-debugging-'); + }) + ) { + if (pipe) { + assert( + !debuggingPort, + 'Browser should be launched with either pipe or debugging port - not both.' + ); + chromeArguments.push('--remote-debugging-pipe'); + } else { + chromeArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); + } + } + + let isTempUserDataDir = false; + + // Check for the user data dir argument, which will always be set even + // with a custom directory specified via the userDataDir option. + let userDataDirIndex = chromeArguments.findIndex(arg => { + return arg.startsWith('--user-data-dir'); + }); + if (userDataDirIndex < 0) { + isTempUserDataDir = true; + chromeArguments.push( + `--user-data-dir=${await mkdtemp(this.getProfilePath())}` + ); + userDataDirIndex = chromeArguments.length - 1; + } + + const userDataDir = chromeArguments[userDataDirIndex]!.split('=', 2)[1]; + assert(typeof userDataDir === 'string', '`--user-data-dir` is malformed'); + + let chromeExecutable = executablePath; + if (!chromeExecutable) { + assert( + channel || !this.puppeteer._isPuppeteerCore, + `An \`executablePath\` or \`channel\` must be specified for \`puppeteer-core\`` + ); + chromeExecutable = this.executablePath(channel, options.headless ?? true); + } + + return { + executablePath: chromeExecutable, + args: chromeArguments, + isTempUserDataDir, + userDataDir, + }; + } + + /** + * @internal + */ + override async cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise<void> { + if (opts.isTemp) { + try { + await rm(path); + } catch (error) { + debugError(error); + throw error; + } + } + } + + override defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + // See https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md + + const userDisabledFeatures = getFeatures( + '--disable-features', + options.args + ); + if (options.args && userDisabledFeatures.length > 0) { + removeMatchingFlags(options.args, '--disable-features'); + } + + // Merge default disabled features with user-provided ones, if any. + const disabledFeatures = [ + 'Translate', + // AcceptCHFrame disabled because of crbug.com/1348106. + 'AcceptCHFrame', + 'MediaRouter', + 'OptimizationHints', + // https://crbug.com/1492053 + 'ProcessPerSiteUpToMainFrameThreshold', + ...userDisabledFeatures, + ]; + + const userEnabledFeatures = getFeatures('--enable-features', options.args); + if (options.args && userEnabledFeatures.length > 0) { + removeMatchingFlags(options.args, '--enable-features'); + } + + // Merge default enabled features with user-provided ones, if any. + const enabledFeatures = [ + 'NetworkServiceInProcess2', + ...userEnabledFeatures, + ]; + + const chromeArguments = [ + '--allow-pre-commit-input', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-component-update', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-field-trial-config', // https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md + '--disable-hang-monitor', + '--disable-infobars', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-search-engine-choice-screen', + '--disable-sync', + '--enable-automation', + '--export-tagged-pdf', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--password-store=basic', + '--use-mock-keychain', + `--disable-features=${disabledFeatures.join(',')}`, + `--enable-features=${enabledFeatures.join(',')}`, + ]; + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir, + } = options; + if (userDataDir) { + chromeArguments.push(`--user-data-dir=${path.resolve(userDataDir)}`); + } + if (devtools) { + chromeArguments.push('--auto-open-devtools-for-tabs'); + } + if (headless) { + chromeArguments.push( + headless === 'new' ? '--headless=new' : '--headless', + '--hide-scrollbars', + '--mute-audio' + ); + } + if ( + args.every(arg => { + return arg.startsWith('-'); + }) + ) { + chromeArguments.push('about:blank'); + } + chromeArguments.push(...args); + return chromeArguments; + } + + override executablePath( + channel?: ChromeReleaseChannel, + headless?: boolean | 'new' + ): string { + if (channel) { + return computeSystemExecutablePath({ + browser: SupportedBrowsers.CHROME, + channel: convertPuppeteerChannelToBrowsersChannel(channel), + }); + } else { + return this.resolveExecutablePath(headless); + } + } +} + +function convertPuppeteerChannelToBrowsersChannel( + channel: ChromeReleaseChannel +): BrowsersChromeReleaseChannel { + switch (channel) { + case 'chrome': + return BrowsersChromeReleaseChannel.STABLE; + case 'chrome-dev': + return BrowsersChromeReleaseChannel.DEV; + case 'chrome-beta': + return BrowsersChromeReleaseChannel.BETA; + case 'chrome-canary': + return BrowsersChromeReleaseChannel.CANARY; + } +} + +/** + * Extracts all features from the given command-line flag + * (e.g. `--enable-features`, `--enable-features=`). + * + * Example input: + * ["--enable-features=NetworkService,NetworkServiceInProcess", "--enable-features=Foo"] + * + * Example output: + * ["NetworkService", "NetworkServiceInProcess", "Foo"] + * + * @internal + */ +export function getFeatures(flag: string, options: string[] = []): string[] { + return options + .filter(s => { + return s.startsWith(flag.endsWith('=') ? flag : `${flag}=`); + }) + .map(s => { + return s.split(new RegExp(`${flag}=\\s*`))[1]?.trim(); + }) + .filter(s => { + return s; + }) as string[]; +} + +/** + * Removes all elements in-place from the given string array + * that match the given command-line flag. + * + * @internal + */ +export function removeMatchingFlags(array: string[], flag: string): string[] { + const regex = new RegExp(`^${flag}=.*`); + let i = 0; + while (i < array.length) { + if (regex.test(array[i]!)) { + array.splice(i, 1); + } else { + i++; + } + } + return array; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts new file mode 100644 index 0000000000..b0b1f81249 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import {FirefoxLauncher} from './FirefoxLauncher.js'; + +describe('FirefoxLauncher', function () { + describe('getPreferences', function () { + it('should return preferences for CDP', async () => { + const prefs: Record<string, unknown> = FirefoxLauncher.getPreferences( + { + test: 1, + }, + undefined + ); + expect(prefs['test']).toBe(1); + expect(prefs['fission.bfcacheInParent']).toBe(false); + expect(prefs['fission.webContentIsolationStrategy']).toBe(0); + expect(prefs).toEqual( + FirefoxLauncher.getPreferences( + { + test: 1, + }, + 'cdp' + ) + ); + }); + + it('should return preferences for WebDriver BiDi', async () => { + const prefs: Record<string, unknown> = FirefoxLauncher.getPreferences( + { + test: 1, + }, + 'webDriverBiDi' + ); + expect(prefs['test']).toBe(1); + expect(prefs['fission.bfcacheInParent']).toBe(undefined); + expect(prefs['fission.webContentIsolationStrategy']).toBe(0); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts new file mode 100644 index 0000000000..eb4f375fc7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import {rename, unlink, mkdtemp} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + Browser as SupportedBrowsers, + createProfile, + Cache, + detectBrowserPlatform, + Browser, +} from '@puppeteer/browsers'; + +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import type { + BrowserLaunchArgumentOptions, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, type ResolvedLaunchArgs} from './ProductLauncher.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; +import {rm} from './util/fs.js'; + +/** + * @internal + */ +export class FirefoxLauncher extends ProductLauncher { + constructor(puppeteer: PuppeteerNode) { + super(puppeteer, 'firefox'); + } + + static getPreferences( + extraPrefsFirefox?: Record<string, unknown>, + protocol?: 'cdp' | 'webDriverBiDi' + ): Record<string, unknown> { + return { + ...extraPrefsFirefox, + ...(protocol === 'webDriverBiDi' + ? {} + : { + // Do not close the window when the last tab gets closed + 'browser.tabs.closeWindowWithLastTab': false, + // Temporarily force disable BFCache in parent (https://bit.ly/bug-1732263) + 'fission.bfcacheInParent': false, + }), + // Force all web content to use a single content process. TODO: remove + // this once Firefox supports mouse event dispatch from the main frame + // context. Once this happens, webContentIsolationStrategy should only + // be set for CDP. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=1773393 + 'fission.webContentIsolationStrategy': 0, + }; + } + + /** + * @internal + */ + override async computeLaunchArguments( + options: PuppeteerNodeLaunchOptions = {} + ): Promise<ResolvedLaunchArgs> { + const { + ignoreDefaultArgs = false, + args = [], + executablePath, + pipe = false, + extraPrefsFirefox = {}, + debuggingPort = null, + } = options; + + const firefoxArguments = []; + if (!ignoreDefaultArgs) { + firefoxArguments.push(...this.defaultArgs(options)); + } else if (Array.isArray(ignoreDefaultArgs)) { + firefoxArguments.push( + ...this.defaultArgs(options).filter(arg => { + return !ignoreDefaultArgs.includes(arg); + }) + ); + } else { + firefoxArguments.push(...args); + } + + if ( + !firefoxArguments.some(argument => { + return argument.startsWith('--remote-debugging-'); + }) + ) { + if (pipe) { + assert( + debuggingPort === null, + 'Browser should be launched with either pipe or debugging port - not both.' + ); + } + firefoxArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); + } + + let userDataDir: string | undefined; + let isTempUserDataDir = true; + + // Check for the profile argument, which will always be set even + // with a custom directory specified via the userDataDir option. + const profileArgIndex = firefoxArguments.findIndex(arg => { + return ['-profile', '--profile'].includes(arg); + }); + + if (profileArgIndex !== -1) { + userDataDir = firefoxArguments[profileArgIndex + 1]; + if (!userDataDir || !fs.existsSync(userDataDir)) { + throw new Error(`Firefox profile not found at '${userDataDir}'`); + } + + // When using a custom Firefox profile it needs to be populated + // with required preferences. + isTempUserDataDir = false; + } else { + userDataDir = await mkdtemp(this.getProfilePath()); + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + + await createProfile(SupportedBrowsers.FIREFOX, { + path: userDataDir, + preferences: FirefoxLauncher.getPreferences( + extraPrefsFirefox, + options.protocol + ), + }); + + let firefoxExecutable: string; + if (this.puppeteer._isPuppeteerCore || executablePath) { + assert( + executablePath, + `An \`executablePath\` must be specified for \`puppeteer-core\`` + ); + firefoxExecutable = executablePath; + } else { + firefoxExecutable = this.executablePath(); + } + + return { + isTempUserDataDir, + userDataDir, + args: firefoxArguments, + executablePath: firefoxExecutable, + }; + } + + /** + * @internal + */ + override async cleanUserDataDir( + userDataDir: string, + opts: {isTemp: boolean} + ): Promise<void> { + if (opts.isTemp) { + try { + await rm(userDataDir); + } catch (error) { + debugError(error); + throw error; + } + } else { + try { + // When an existing user profile has been used remove the user + // preferences file and restore possibly backuped preferences. + await unlink(path.join(userDataDir, 'user.js')); + + const prefsBackupPath = path.join(userDataDir, 'prefs.js.puppeteer'); + if (fs.existsSync(prefsBackupPath)) { + const prefsPath = path.join(userDataDir, 'prefs.js'); + await unlink(prefsPath); + await rename(prefsBackupPath, prefsPath); + } + } catch (error) { + debugError(error); + } + } + } + + override executablePath(): string { + // replace 'latest' placeholder with actual downloaded revision + if (this.puppeteer.browserRevision === 'latest') { + const cache = new Cache(this.puppeteer.defaultDownloadPath!); + const installedFirefox = cache.getInstalledBrowsers().find(browser => { + return ( + browser.platform === detectBrowserPlatform() && + browser.browser === Browser.FIREFOX + ); + }); + if (installedFirefox) { + this.actualBrowserRevision = installedFirefox.buildId; + } + } + return this.resolveExecutablePath(); + } + + override defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null, + } = options; + + const firefoxArguments = ['--no-remote']; + + switch (os.platform()) { + case 'darwin': + firefoxArguments.push('--foreground'); + break; + case 'win32': + firefoxArguments.push('--wait-for-browser'); + break; + } + if (userDataDir) { + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + if (headless) { + firefoxArguments.push('--headless'); + } + if (devtools) { + firefoxArguments.push('--devtools'); + } + if ( + args.every(arg => { + return arg.startsWith('-'); + }) + ) { + firefoxArguments.push('about:blank'); + } + firefoxArguments.push(...args); + return firefoxArguments; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts new file mode 100644 index 0000000000..28e0b595df --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BrowserConnectOptions} from '../common/ConnectOptions.js'; +import type {Product} from '../common/Product.js'; + +/** + * Launcher options that only apply to Chrome. + * + * @public + */ +export interface BrowserLaunchArgumentOptions { + /** + * Whether to run the browser in headless mode. + * + * @remarks + * In the future `headless: true` will be equivalent to `headless: 'new'`. + * You can read more about the change {@link https://developer.chrome.com/articles/new-headless/ | here}. + * Consider opting in early by setting the value to `"new"`. + * + * @defaultValue `true` + */ + headless?: boolean | 'new'; + /** + * Path to a user data directory. + * {@link https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/user_data_dir.md | see the Chromium docs} + * for more info. + */ + userDataDir?: string; + /** + * Whether to auto-open a DevTools panel for each tab. If this is set to + * `true`, then `headless` will be forced to `false`. + * @defaultValue `false` + */ + devtools?: boolean; + /** + * Specify the debugging port number to use + */ + debuggingPort?: number; + /** + * Additional command line arguments to pass to the browser instance. + */ + args?: string[]; +} +/** + * @public + */ +export type ChromeReleaseChannel = + | 'chrome' + | 'chrome-beta' + | 'chrome-canary' + | 'chrome-dev'; + +/** + * Generic launch options that can be passed when launching any browser. + * @public + */ +export interface LaunchOptions { + /** + * Chrome Release Channel + */ + channel?: ChromeReleaseChannel; + /** + * Path to a browser executable to use instead of the bundled Chromium. Note + * that Puppeteer is only guaranteed to work with the bundled Chromium, so use + * this setting at your own risk. + */ + executablePath?: string; + /** + * If `true`, do not use `puppeteer.defaultArgs()` when creating a browser. If + * an array is provided, these args will be filtered out. Use this with care - + * you probably want the default arguments Puppeteer uses. + * @defaultValue `false` + */ + ignoreDefaultArgs?: boolean | string[]; + /** + * Close the browser process on `Ctrl+C`. + * @defaultValue `true` + */ + handleSIGINT?: boolean; + /** + * Close the browser process on `SIGTERM`. + * @defaultValue `true` + */ + handleSIGTERM?: boolean; + /** + * Close the browser process on `SIGHUP`. + * @defaultValue `true` + */ + handleSIGHUP?: boolean; + /** + * Maximum time in milliseconds to wait for the browser to start. + * Pass `0` to disable the timeout. + * @defaultValue `30_000` (30 seconds). + */ + timeout?: number; + /** + * If true, pipes the browser process stdout and stderr to `process.stdout` + * and `process.stderr`. + * @defaultValue `false` + */ + dumpio?: boolean; + /** + * Specify environment variables that will be visible to the browser. + * @defaultValue The contents of `process.env`. + */ + env?: Record<string, string | undefined>; + /** + * Connect to a browser over a pipe instead of a WebSocket. + * @defaultValue `false` + */ + pipe?: boolean; + /** + * Which browser to launch. + * @defaultValue `chrome` + */ + product?: Product; + /** + * {@link https://searchfox.org/mozilla-release/source/modules/libpref/init/all.js | Additional preferences } that can be passed when launching with Firefox. + */ + extraPrefsFirefox?: Record<string, unknown>; + /** + * Whether to wait for the initial page to be ready. + * Useful when a user explicitly disables that (e.g. `--no-startup-window` for Chrome). + * @defaultValue `true` + */ + waitForInitialPage?: boolean; +} + +/** + * Utility type exposed to enable users to define options that can be passed to + * `puppeteer.launch` without having to list the set of all types. + * @public + */ +export type PuppeteerNodeLaunchOptions = BrowserLaunchArgumentOptions & + LaunchOptions & + BrowserConnectOptions; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts new file mode 100644 index 0000000000..f4ac592e4f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import NodeWebSocket from 'ws'; + +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {packageVersion} from '../generated/version.js'; + +/** + * @internal + */ +export class NodeWebSocketTransport implements ConnectionTransport { + static create( + url: string, + headers?: Record<string, string> + ): Promise<NodeWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new NodeWebSocket(url, [], { + followRedirects: true, + perMessageDeflate: false, + maxPayload: 256 * 1024 * 1024, // 256Mb + headers: { + 'User-Agent': `Puppeteer ${packageVersion}`, + ...headers, + }, + }); + + ws.addEventListener('open', () => { + return resolve(new NodeWebSocketTransport(ws)); + }); + ws.addEventListener('error', reject); + }); + } + + #ws: NodeWebSocket; + onmessage?: (message: NodeWebSocket.Data) => void; + onclose?: () => void; + + constructor(ws: NodeWebSocket) { + this.#ws = ws; + this.#ws.addEventListener('message', event => { + if (this.onmessage) { + this.onmessage.call(null, event.data); + } + }); + this.#ws.addEventListener('close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }); + // Silently ignore all errors - we don't know what to do with them. + this.#ws.addEventListener('error', () => {}); + } + + send(message: string): void { + this.#ws.send(message); + } + + close(): void { + this.#ws.close(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts new file mode 100644 index 0000000000..616f164d82 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {EventSubscription} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {DisposableStack} from '../util/disposable.js'; + +/** + * @internal + */ +export class PipeTransport implements ConnectionTransport { + #pipeWrite: NodeJS.WritableStream; + #subscriptions = new DisposableStack(); + + #isClosed = false; + #pendingMessage = ''; + + onclose?: () => void; + onmessage?: (value: string) => void; + + constructor( + pipeWrite: NodeJS.WritableStream, + pipeRead: NodeJS.ReadableStream + ) { + this.#pipeWrite = pipeWrite; + this.#subscriptions.use( + new EventSubscription(pipeRead, 'data', (buffer: Buffer) => { + return this.#dispatch(buffer); + }) + ); + this.#subscriptions.use( + new EventSubscription(pipeRead, 'close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }) + ); + this.#subscriptions.use( + new EventSubscription(pipeRead, 'error', debugError) + ); + this.#subscriptions.use( + new EventSubscription(pipeWrite, 'error', debugError) + ); + } + + send(message: string): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + this.#pipeWrite.write(message); + this.#pipeWrite.write('\0'); + } + + #dispatch(buffer: Buffer): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + let end = buffer.indexOf('\0'); + if (end === -1) { + this.#pendingMessage += buffer.toString(); + return; + } + const message = this.#pendingMessage + buffer.toString(undefined, 0, end); + if (this.onmessage) { + this.onmessage.call(null, message); + } + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + if (this.onmessage) { + this.onmessage.call(null, buffer.toString(undefined, start, end)); + } + start = end + 1; + end = buffer.indexOf('\0', start); + } + this.#pendingMessage = buffer.toString(undefined, start); + } + + close(): void { + this.#isClosed = true; + this.#subscriptions.dispose(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts new file mode 100644 index 0000000000..ab3432cd3a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts @@ -0,0 +1,451 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import {existsSync} from 'fs'; +import {tmpdir} from 'os'; +import {join} from 'path'; + +import { + Browser as InstalledBrowser, + CDP_WEBSOCKET_ENDPOINT_REGEX, + launch, + TimeoutError as BrowsersTimeoutError, + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, + computeExecutablePath, +} from '@puppeteer/browsers'; + +import { + firstValueFrom, + from, + map, + race, + timer, +} from '../../third_party/rxjs/rxjs.js'; +import type {Browser, BrowserCloseCallback} from '../api/Browser.js'; +import {CdpBrowser} from '../cdp/Browser.js'; +import {Connection} from '../cdp/Connection.js'; +import {TimeoutError} from '../common/Errors.js'; +import type {Product} from '../common/Product.js'; +import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; + +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {NodeWebSocketTransport as WebSocketTransport} from './NodeWebSocketTransport.js'; +import {PipeTransport} from './PipeTransport.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; + +/** + * @internal + */ +export interface ResolvedLaunchArgs { + isTempUserDataDir: boolean; + userDataDir: string; + executablePath: string; + args: string[]; +} + +/** + * Describes a launcher - a class that is able to create and launch a browser instance. + * + * @public + */ +export abstract class ProductLauncher { + #product: Product; + + /** + * @internal + */ + puppeteer: PuppeteerNode; + + /** + * @internal + */ + protected actualBrowserRevision?: string; + + /** + * @internal + */ + constructor(puppeteer: PuppeteerNode, product: Product) { + this.puppeteer = puppeteer; + this.#product = product; + } + + get product(): Product { + return this.#product; + } + + async launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> { + const { + dumpio = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = DEFAULT_VIEWPORT, + slowMo = 0, + timeout = 30000, + waitForInitialPage = true, + protocolTimeout, + protocol, + } = options; + + const launchArgs = await this.computeLaunchArguments(options); + + const usePipe = launchArgs.args.includes('--remote-debugging-pipe'); + + const onProcessExit = async () => { + await this.cleanUserDataDir(launchArgs.userDataDir, { + isTemp: launchArgs.isTempUserDataDir, + }); + }; + + const browserProcess = launch({ + executablePath: launchArgs.executablePath, + args: launchArgs.args, + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe: usePipe, + onExit: onProcessExit, + }); + + let browser: Browser; + let cdpConnection: Connection; + let closing = false; + + const browserCloseCallback: BrowserCloseCallback = async () => { + if (closing) { + return; + } + closing = true; + await this.closeBrowser(browserProcess, cdpConnection); + }; + + try { + if (this.#product === 'firefox' && protocol === 'webDriverBiDi') { + browser = await this.createBiDiBrowser( + browserProcess, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + defaultViewport, + ignoreHTTPSErrors, + } + ); + } else { + if (usePipe) { + cdpConnection = await this.createCdpPipeConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } else { + cdpConnection = await this.createCdpSocketConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } + if (protocol === 'webDriverBiDi') { + browser = await this.createBiDiOverCdpBrowser( + browserProcess, + cdpConnection, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + defaultViewport, + ignoreHTTPSErrors, + } + ); + } else { + browser = await CdpBrowser._create( + this.product, + cdpConnection, + [], + ignoreHTTPSErrors, + defaultViewport, + browserProcess.nodeProcess, + browserCloseCallback, + options.targetFilter + ); + } + } + } catch (error) { + void browserCloseCallback(); + if (error instanceof BrowsersTimeoutError) { + throw new TimeoutError(error.message); + } + throw error; + } + + if (waitForInitialPage && protocol !== 'webDriverBiDi') { + await this.waitForPageTarget(browser, timeout); + } + + return browser; + } + + abstract executablePath(channel?: ChromeReleaseChannel): string; + + abstract defaultArgs(object: BrowserLaunchArgumentOptions): string[]; + + /** + * Set only for Firefox, after the launcher resolves the `latest` revision to + * the actual revision. + * @internal + */ + getActualBrowserRevision(): string | undefined { + return this.actualBrowserRevision; + } + + /** + * @internal + */ + protected abstract computeLaunchArguments( + options: PuppeteerNodeLaunchOptions + ): Promise<ResolvedLaunchArgs>; + + /** + * @internal + */ + protected abstract cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise<void>; + + /** + * @internal + */ + protected async closeBrowser( + browserProcess: ReturnType<typeof launch>, + cdpConnection?: Connection + ): Promise<void> { + if (cdpConnection) { + // Attempt to close the browser gracefully + try { + await cdpConnection.closeBrowser(); + await browserProcess.hasClosed(); + } catch (error) { + debugError(error); + await browserProcess.close(); + } + } else { + // Wait for a possible graceful shutdown. + await firstValueFrom( + race( + from(browserProcess.hasClosed()), + timer(5000).pipe( + map(() => { + return from(browserProcess.close()); + }) + ) + ) + ); + } + } + + /** + * @internal + */ + protected async waitForPageTarget( + browser: Browser, + timeout: number + ): Promise<void> { + try { + await browser.waitForTarget( + t => { + return t.type() === 'page'; + }, + {timeout} + ); + } catch (error) { + await browser.close(); + throw error; + } + } + + /** + * @internal + */ + protected async createCdpSocketConnection( + browserProcess: ReturnType<typeof launch>, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise<Connection> { + const browserWSEndpoint = await browserProcess.waitForLineOutput( + CDP_WEBSOCKET_ENDPOINT_REGEX, + opts.timeout + ); + const transport = await WebSocketTransport.create(browserWSEndpoint); + return new Connection( + browserWSEndpoint, + transport, + opts.slowMo, + opts.protocolTimeout + ); + } + + /** + * @internal + */ + protected async createCdpPipeConnection( + browserProcess: ReturnType<typeof launch>, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise<Connection> { + // stdio was assigned during start(), and the 'pipe' option there adds the + // 4th and 5th items to stdio array + const {3: pipeWrite, 4: pipeRead} = browserProcess.nodeProcess.stdio; + const transport = new PipeTransport( + pipeWrite as NodeJS.WritableStream, + pipeRead as NodeJS.ReadableStream + ); + return new Connection('', transport, opts.slowMo, opts.protocolTimeout); + } + + /** + * @internal + */ + protected async createBiDiOverCdpBrowser( + browserProcess: ReturnType<typeof launch>, + connection: Connection, + closeCallback: BrowserCloseCallback, + opts: { + timeout: number; + protocolTimeout: number | undefined; + slowMo: number; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; + } + ): Promise<Browser> { + // TODO: use other options too. + const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js'); + const bidiConnection = await BiDi.connectBidiOverCdp(connection, { + acceptInsecureCerts: opts.ignoreHTTPSErrors ?? false, + }); + return await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + defaultViewport: opts.defaultViewport, + ignoreHTTPSErrors: opts.ignoreHTTPSErrors, + }); + } + + /** + * @internal + */ + protected async createBiDiBrowser( + browserProcess: ReturnType<typeof launch>, + closeCallback: BrowserCloseCallback, + opts: { + timeout: number; + protocolTimeout: number | undefined; + slowMo: number; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; + } + ): Promise<Browser> { + const browserWSEndpoint = + (await browserProcess.waitForLineOutput( + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, + opts.timeout + )) + '/session'; + const transport = await WebSocketTransport.create(browserWSEndpoint); + const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js'); + const bidiConnection = new BiDi.BidiConnection( + browserWSEndpoint, + transport, + opts.slowMo, + opts.protocolTimeout + ); + // TODO: use other options too. + return await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + defaultViewport: opts.defaultViewport, + ignoreHTTPSErrors: opts.ignoreHTTPSErrors, + }); + } + + /** + * @internal + */ + protected getProfilePath(): string { + return join( + this.puppeteer.configuration.temporaryDirectory ?? tmpdir(), + `puppeteer_dev_${this.product}_profile-` + ); + } + + /** + * @internal + */ + protected resolveExecutablePath(headless?: boolean | 'new'): string { + let executablePath = this.puppeteer.configuration.executablePath; + if (executablePath) { + if (!existsSync(executablePath)) { + throw new Error( + `Tried to find the browser at the configured path (${executablePath}), but no executable was found.` + ); + } + return executablePath; + } + + function productToBrowser(product?: Product, headless?: boolean | 'new') { + switch (product) { + case 'chrome': + if (headless === true) { + return InstalledBrowser.CHROMEHEADLESSSHELL; + } + return InstalledBrowser.CHROME; + case 'firefox': + return InstalledBrowser.FIREFOX; + } + return InstalledBrowser.CHROME; + } + + executablePath = computeExecutablePath({ + cacheDir: this.puppeteer.defaultDownloadPath!, + browser: productToBrowser(this.product, headless), + buildId: this.puppeteer.browserRevision, + }); + + if (!existsSync(executablePath)) { + if (this.puppeteer.configuration.browserRevision) { + throw new Error( + `Tried to find the browser at the configured path (${executablePath}) for revision ${this.puppeteer.browserRevision}, but no executable was found.` + ); + } + switch (this.product) { + case 'chrome': + throw new Error( + `Could not find Chrome (ver. ${this.puppeteer.browserRevision}). This can occur if either\n` + + ' 1. you did not perform an installation before running the script (e.g. `npx puppeteer browsers install chrome`) or\n' + + ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + + 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.' + ); + case 'firefox': + throw new Error( + `Could not find Firefox (rev. ${this.puppeteer.browserRevision}). This can occur if either\n` + + ' 1. you did not perform an installation for Firefox before running the script (e.g. `npx puppeteer browsers install firefox`) or\n' + + ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + + 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.' + ); + } + } + return executablePath; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts new file mode 100644 index 0000000000..e50e09acdb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts @@ -0,0 +1,356 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Browser as SupportedBrowser, + resolveBuildId, + detectBrowserPlatform, + getInstalledBrowsers, + uninstall, +} from '@puppeteer/browsers'; + +import type {Browser} from '../api/Browser.js'; +import type {Configuration} from '../common/Configuration.js'; +import type { + ConnectOptions, + BrowserConnectOptions, +} from '../common/ConnectOptions.js'; +import type {Product} from '../common/Product.js'; +import {type CommonPuppeteerSettings, Puppeteer} from '../common/Puppeteer.js'; +import {PUPPETEER_REVISIONS} from '../revisions.js'; + +import {ChromeLauncher} from './ChromeLauncher.js'; +import {FirefoxLauncher} from './FirefoxLauncher.js'; +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + LaunchOptions, +} from './LaunchOptions.js'; +import type {ProductLauncher} from './ProductLauncher.js'; + +/** + * @public + */ +export interface PuppeteerLaunchOptions + extends LaunchOptions, + BrowserLaunchArgumentOptions, + BrowserConnectOptions { + product?: Product; + extraPrefsFirefox?: Record<string, unknown>; +} + +/** + * Extends the main {@link Puppeteer} class with Node specific behaviour for + * fetching and downloading browsers. + * + * If you're using Puppeteer in a Node environment, this is the class you'll get + * when you run `require('puppeteer')` (or the equivalent ES `import`). + * + * @remarks + * The most common method to use is {@link PuppeteerNode.launch | launch}, which + * is used to launch and connect to a new browser instance. + * + * See {@link Puppeteer | the main Puppeteer class} for methods common to all + * environments, such as {@link Puppeteer.connect}. + * + * @example + * The following is a typical example of using Puppeteer to drive automation: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * Once you have created a `page` you have access to a large API to interact + * with the page, navigate, or find certain elements in that page. + * The {@link Page | `page` documentation} lists all the available methods. + * + * @public + */ +export class PuppeteerNode extends Puppeteer { + #_launcher?: ProductLauncher; + #lastLaunchedProduct?: Product; + + /** + * @internal + */ + defaultBrowserRevision: string; + + /** + * @internal + */ + configuration: Configuration = {}; + + /** + * @internal + */ + constructor( + settings: { + configuration?: Configuration; + } & CommonPuppeteerSettings + ) { + const {configuration, ...commonSettings} = settings; + super(commonSettings); + if (configuration) { + this.configuration = configuration; + } + switch (this.configuration.defaultProduct) { + case 'firefox': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.firefox; + break; + default: + this.configuration.defaultProduct = 'chrome'; + this.defaultBrowserRevision = PUPPETEER_REVISIONS.chrome; + break; + } + + this.connect = this.connect.bind(this); + this.launch = this.launch.bind(this); + this.executablePath = this.executablePath.bind(this); + this.defaultArgs = this.defaultArgs.bind(this); + this.trimCache = this.trimCache.bind(this); + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + override connect(options: ConnectOptions): Promise<Browser> { + return super.connect(options); + } + + /** + * Launches a browser instance with given arguments and options when + * specified. + * + * When using with `puppeteer-core`, + * {@link LaunchOptions | options.executablePath} or + * {@link LaunchOptions | options.channel} must be provided. + * + * @example + * You can use {@link LaunchOptions | options.ignoreDefaultArgs} + * to filter out `--mute-audio` from default arguments: + * + * ```ts + * const browser = await puppeteer.launch({ + * ignoreDefaultArgs: ['--mute-audio'], + * }); + * ``` + * + * @remarks + * Puppeteer can also be used to control the Chrome browser, but it works best + * with the version of Chrome for Testing downloaded by default. + * There is no guarantee it will work with any other version. If Google Chrome + * (rather than Chrome for Testing) is preferred, a + * {@link https://www.google.com/chrome/browser/canary.html | Chrome Canary} + * or + * {@link https://www.chromium.org/getting-involved/dev-channel | Dev Channel} + * build is suggested. See + * {@link https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/ | this article} + * for a description of the differences between Chromium and Chrome. + * {@link https://chromium.googlesource.com/chromium/src/+/lkgr/docs/chromium_browser_vs_google_chrome.md | This article} + * describes some differences for Linux users. See + * {@link https://developer.chrome.com/blog/chrome-for-testing/ | this doc} for the description + * of Chrome for Testing. + * + * @param options - Options to configure launching behavior. + */ + launch(options: PuppeteerLaunchOptions = {}): Promise<Browser> { + const {product = this.defaultProduct} = options; + this.#lastLaunchedProduct = product; + return this.#launcher.launch(options); + } + + /** + * @internal + */ + get #launcher(): ProductLauncher { + if ( + this.#_launcher && + this.#_launcher.product === this.lastLaunchedProduct + ) { + return this.#_launcher; + } + switch (this.lastLaunchedProduct) { + case 'chrome': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.chrome; + this.#_launcher = new ChromeLauncher(this); + break; + case 'firefox': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.firefox; + this.#_launcher = new FirefoxLauncher(this); + break; + default: + throw new Error(`Unknown product: ${this.#lastLaunchedProduct}`); + } + return this.#_launcher; + } + + /** + * The default executable path. + */ + executablePath(channel?: ChromeReleaseChannel): string { + return this.#launcher.executablePath(channel); + } + + /** + * @internal + */ + get browserRevision(): string { + return ( + this.#_launcher?.getActualBrowserRevision() ?? + this.configuration.browserRevision ?? + this.defaultBrowserRevision! + ); + } + + /** + * The default download path for puppeteer. For puppeteer-core, this + * code should never be called as it is never defined. + * + * @internal + */ + get defaultDownloadPath(): string | undefined { + return this.configuration.downloadPath ?? this.configuration.cacheDirectory; + } + + /** + * The name of the browser that was last launched. + */ + get lastLaunchedProduct(): Product { + return this.#lastLaunchedProduct ?? this.defaultProduct; + } + + /** + * The name of the browser that will be launched by default. For + * `puppeteer`, this is influenced by your configuration. Otherwise, it's + * `chrome`. + */ + get defaultProduct(): Product { + return this.configuration.defaultProduct ?? 'chrome'; + } + + /** + * @deprecated Do not use as this field as it does not take into account + * multiple browsers of different types. Use + * {@link PuppeteerNode.defaultProduct | defaultProduct} or + * {@link PuppeteerNode.lastLaunchedProduct | lastLaunchedProduct}. + * + * @returns The name of the browser that is under automation. + */ + get product(): string { + return this.#launcher.product; + } + + /** + * @param options - Set of configurable options to set on the browser. + * + * @returns The default flags that Chromium will be launched with. + */ + defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + return this.#launcher.defaultArgs(options); + } + + /** + * Removes all non-current Firefox and Chrome binaries in the cache directory + * identified by the provided Puppeteer configuration. The current browser + * version is determined by resolving PUPPETEER_REVISIONS from Puppeteer + * unless `configuration.browserRevision` is provided. + * + * @remarks + * + * Note that the method does not check if any other Puppeteer versions + * installed on the host that use the same cache directory require the + * non-current binaries. + * + * @public + */ + async trimCache(): Promise<void> { + const platform = detectBrowserPlatform(); + if (!platform) { + throw new Error('The current platform is not supported.'); + } + + const cacheDir = + this.configuration.downloadPath ?? this.configuration.cacheDirectory!; + const installedBrowsers = await getInstalledBrowsers({ + cacheDir, + }); + + const product = this.configuration.defaultProduct!; + + const puppeteerBrowsers: Array<{ + product: Product; + browser: SupportedBrowser; + currentBuildId: string; + }> = [ + { + product: 'chrome', + browser: SupportedBrowser.CHROME, + currentBuildId: '', + }, + { + product: 'firefox', + browser: SupportedBrowser.FIREFOX, + currentBuildId: '', + }, + ]; + + // Resolve current buildIds. + for (const item of puppeteerBrowsers) { + item.currentBuildId = await resolveBuildId( + item.browser, + platform, + (product === item.product + ? this.configuration.browserRevision + : null) || PUPPETEER_REVISIONS[item.product] + ); + } + + const currentBrowserBuilds = new Set( + puppeteerBrowsers.map(browser => { + return `${browser.browser}_${browser.currentBuildId}`; + }) + ); + + const currentBrowsers = new Set( + puppeteerBrowsers.map(browser => { + return browser.browser; + }) + ); + + for (const installedBrowser of installedBrowsers) { + // Don't uninstall browsers that are not managed by Puppeteer yet. + if (!currentBrowsers.has(installedBrowser.browser)) { + continue; + } + // Keep the browser build used by the current Puppeteer installation. + if ( + currentBrowserBuilds.has( + `${installedBrowser.browser}_${installedBrowser.buildId}` + ) + ) { + continue; + } + + await uninstall({ + browser: installedBrowser.browser, + platform, + cacheDir, + buildId: installedBrowser.buildId, + }); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts new file mode 100644 index 0000000000..effb2d63ba --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts @@ -0,0 +1,255 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcessWithoutNullStreams} from 'child_process'; +import {spawn, spawnSync} from 'child_process'; +import {PassThrough} from 'stream'; + +import debug from 'debug'; + +import type {OperatorFunction} from '../../third_party/rxjs/rxjs.js'; +import { + bufferCount, + concatMap, + filter, + from, + fromEvent, + lastValueFrom, + map, + takeUntil, + tap, +} from '../../third_party/rxjs/rxjs.js'; +import {CDPSessionEvent} from '../api/CDPSession.js'; +import type {BoundingBox} from '../api/ElementHandle.js'; +import type {Page} from '../api/Page.js'; +import {debugError, fromEmitterEvent} from '../common/util.js'; +import {guarded} from '../util/decorators.js'; +import {asyncDisposeSymbol} from '../util/disposable.js'; + +const CRF_VALUE = 30; +const DEFAULT_FPS = 30; + +const debugFfmpeg = debug('puppeteer:ffmpeg'); + +/** + * @internal + */ +export interface ScreenRecorderOptions { + speed?: number; + crop?: BoundingBox; + format?: 'gif' | 'webm'; + scale?: number; + path?: string; +} + +/** + * @public + */ +export class ScreenRecorder extends PassThrough { + #page: Page; + + #process: ChildProcessWithoutNullStreams; + + #controller = new AbortController(); + #lastFrame: Promise<readonly [Buffer, number]>; + + /** + * @internal + */ + constructor( + page: Page, + width: number, + height: number, + {speed, scale, crop, format, path}: ScreenRecorderOptions = {} + ) { + super({allowHalfOpen: false}); + + path ??= 'ffmpeg'; + + // Tests if `ffmpeg` exists. + const {error} = spawnSync(path); + if (error) { + throw error; + } + + this.#process = spawn( + path, + // See https://trac.ffmpeg.org/wiki/Encode/VP9 for more information on flags. + [ + ['-loglevel', 'error'], + // Reduces general buffering. + ['-avioflags', 'direct'], + // Reduces initial buffering while analyzing input fps and other stats. + [ + '-fpsprobesize', + '0', + '-probesize', + '32', + '-analyzeduration', + '0', + '-fflags', + 'nobuffer', + ], + // Forces input to be read from standard input, and forces png input + // image format. + ['-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0'], + // Overwrite output and no audio. + ['-y', '-an'], + // This drastically reduces stalling when cpu is overbooked. By default + // VP9 tries to use all available threads? + ['-threads', '1'], + // Specifies the frame rate we are giving ffmpeg. + ['-framerate', `${DEFAULT_FPS}`], + // Specifies the encoding and format we are using. + this.#getFormatArgs(format ?? 'webm'), + // Disable bitrate. + ['-b:v', '0'], + // Filters to ensure the images are piped correctly. + [ + '-vf', + `${ + speed ? `setpts=${1 / speed}*PTS,` : '' + }crop='min(${width},iw):min(${height},ih):0:0',pad=${width}:${height}:0:0${ + crop ? `,crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}` : '' + }${scale ? `,scale=iw*${scale}:-1` : ''}`, + ], + 'pipe:1', + ].flat(), + {stdio: ['pipe', 'pipe', 'pipe']} + ); + this.#process.stdout.pipe(this); + this.#process.stderr.on('data', (data: Buffer) => { + debugFfmpeg(data.toString('utf8')); + }); + + this.#page = page; + + const {client} = this.#page.mainFrame(); + client.once(CDPSessionEvent.Disconnected, () => { + void this.stop().catch(debugError); + }); + + this.#lastFrame = lastValueFrom( + fromEmitterEvent(client, 'Page.screencastFrame').pipe( + tap(event => { + void client.send('Page.screencastFrameAck', { + sessionId: event.sessionId, + }); + }), + filter(event => { + return event.metadata.timestamp !== undefined; + }), + map(event => { + return { + buffer: Buffer.from(event.data, 'base64'), + timestamp: event.metadata.timestamp!, + }; + }), + bufferCount(2, 1) as OperatorFunction< + {buffer: Buffer; timestamp: number}, + [ + {buffer: Buffer; timestamp: number}, + {buffer: Buffer; timestamp: number}, + ] + >, + concatMap(([{timestamp: previousTimestamp, buffer}, {timestamp}]) => { + return from( + Array<Buffer>( + Math.round( + DEFAULT_FPS * Math.max(timestamp - previousTimestamp, 0) + ) + ).fill(buffer) + ); + }), + map(buffer => { + void this.#writeFrame(buffer); + return [buffer, performance.now()] as const; + }), + takeUntil(fromEvent(this.#controller.signal, 'abort')) + ), + {defaultValue: [Buffer.from([]), performance.now()] as const} + ); + } + + #getFormatArgs(format: 'webm' | 'gif') { + switch (format) { + case 'webm': + return [ + // Sets the codec to use. + ['-c:v', 'vp9'], + // Sets the format + ['-f', 'webm'], + // Sets the quality. Lower the better. + ['-crf', `${CRF_VALUE}`], + // Sets the quality and how efficient the compression will be. + ['-deadline', 'realtime', '-cpu-used', '8'], + ].flat(); + case 'gif': + return [ + // Sets the frame rate and uses a custom palette generated from the + // input. + [ + '-vf', + 'fps=5,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse', + ], + // Sets the format + ['-f', 'gif'], + ].flat(); + } + } + + @guarded() + async #writeFrame(buffer: Buffer) { + const error = await new Promise<Error | null | undefined>(resolve => { + this.#process.stdin.write(buffer, resolve); + }); + if (error) { + console.log(`ffmpeg failed to write: ${error.message}.`); + } + } + + /** + * Stops the recorder. + * + * @public + */ + @guarded() + async stop(): Promise<void> { + if (this.#controller.signal.aborted) { + return; + } + // Stopping the screencast will flush the frames. + await this.#page._stopScreencast().catch(debugError); + + this.#controller.abort(); + + // Repeat the last frame for the remaining frames. + const [buffer, timestamp] = await this.#lastFrame; + await Promise.all( + Array<Buffer>( + Math.max( + 1, + Math.round((DEFAULT_FPS * (performance.now() - timestamp)) / 1000) + ) + ) + .fill(buffer) + .map(this.#writeFrame.bind(this)) + ); + + // Close stdin to notify FFmpeg we are done. + this.#process.stdin.end(); + await new Promise(resolve => { + this.#process.once('close', resolve); + }); + } + + /** + * @internal + */ + async [asyncDisposeSymbol](): Promise<void> { + await this.stop(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts new file mode 100644 index 0000000000..373449ec0f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './ChromeLauncher.js'; +export * from './FirefoxLauncher.js'; +export * from './LaunchOptions.js'; +export * from './PipeTransport.js'; +export * from './ProductLauncher.js'; +export * from './PuppeteerNode.js'; +export * from './ScreenRecorder.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts new file mode 100644 index 0000000000..d18c76d6dc --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; + +const rmOptions = { + force: true, + recursive: true, + maxRetries: 5, +}; + +/** + * @internal + */ +export async function rm(path: string): Promise<void> { + await fs.promises.rm(path, rmOptions); +} + +/** + * @internal + */ +export function rmSync(path: string): void { + fs.rmSync(path, rmOptions); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts b/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts new file mode 100644 index 0000000000..d19162b4a3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type {Protocol} from 'devtools-protocol'; + +export * from './api/api.js'; +export * from './cdp/cdp.js'; +export * from './common/common.js'; +export * from './node/node.js'; +export * from './revisions.js'; +export * from './util/util.js'; + +/** + * @deprecated Use the query handler API defined on {@link Puppeteer} + */ +export * from './common/CustomQueryHandler.js'; + +import {PuppeteerNode} from './node/PuppeteerNode.js'; + +/** + * @public + */ +const puppeteer = new PuppeteerNode({ + isPuppeteerCore: true, +}); + +export const { + /** + * @public + */ + connect, + /** + * @public + */ + defaultArgs, + /** + * @public + */ + executablePath, + /** + * @public + */ + launch, +} = puppeteer; + +export default puppeteer; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts new file mode 100644 index 0000000000..37360204d8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const PUPPETEER_REVISIONS = Object.freeze({ + chrome: '121.0.6167.85', + 'chrome-headless-shell': '121.0.6167.85', + firefox: 'latest', +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl b/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl new file mode 100644 index 0000000000..aa799e9fdb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl @@ -0,0 +1,8 @@ +/** + * JavaScript code that provides the puppeteer utilities. See the + * [README](https://github.com/puppeteer/puppeteer/blob/main/src/injected/README.md) + * for injection for more information. + * + * @internal + */ +export const source = SOURCE_CODE; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl b/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl new file mode 100644 index 0000000000..73b984d2ff --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl @@ -0,0 +1,4 @@ +/** + * @internal + */ +export const packageVersion = 'PACKAGE_VERSION'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json new file mode 100644 index 0000000000..897b1a03df --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "../lib/cjs/puppeteer" + }, + "references": [{"path": "../third_party/tsconfig.cjs.json"}] +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json new file mode 100644 index 0000000000..2cd2ab579f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../lib/esm/puppeteer" + }, + "references": [{"path": "../third_party/tsconfig.json"}] +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts new file mode 100644 index 0000000000..4d96d0cdf4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {AwaitableIterable} from '../common/types.js'; + +/** + * @internal + */ +export class AsyncIterableUtil { + static async *map<T, U>( + iterable: AwaitableIterable<T>, + map: (item: T) => Promise<U> + ): AsyncIterable<U> { + for await (const value of iterable) { + yield await map(value); + } + } + + static async *flatMap<T, U>( + iterable: AwaitableIterable<T>, + map: (item: T) => AwaitableIterable<U> + ): AsyncIterable<U> { + for await (const value of iterable) { + yield* map(value); + } + } + + static async collect<T>(iterable: AwaitableIterable<T>): Promise<T[]> { + const result = []; + for await (const value of iterable) { + result.push(value); + } + return result; + } + + static async first<T>( + iterable: AwaitableIterable<T> + ): Promise<T | undefined> { + for await (const value of iterable) { + return value; + } + return; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts new file mode 100644 index 0000000000..b989e3a888 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; +import sinon from 'sinon'; + +import {Deferred} from './Deferred.js'; + +describe('DeferredPromise', function () { + it('should catch errors', async () => { + // Async function before try/catch. + async function task() { + await new Promise(resolve => { + return setTimeout(resolve, 50); + }); + } + // Async function that fails. + function fails(): Deferred<void> { + const deferred = Deferred.create<void>(); + setTimeout(() => { + deferred.reject(new Error('test')); + }, 25); + return deferred; + } + + const expectedToFail = fails(); + await task(); + let caught = false; + try { + await expectedToFail.valueOrThrow(); + } catch (err) { + expect((err as Error).message).toEqual('test'); + caught = true; + } + expect(caught).toBeTruthy(); + }); + + it('Deferred.race should cancel timeout', async function () { + const clock = sinon.useFakeTimers(); + + try { + const deferred = Deferred.create<void>(); + const deferredTimeout = Deferred.create<void>({ + message: 'Race did not stop timer', + timeout: 100, + }); + + clock.tick(50); + + await Promise.all([ + Deferred.race([deferred, deferredTimeout]), + deferred.resolve(), + ]); + + clock.tick(150); + + expect(deferredTimeout.value()).toBeInstanceOf(Error); + expect(deferredTimeout.value()?.message).toContain('Timeout cleared'); + } finally { + clock.restore(); + } + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts new file mode 100644 index 0000000000..0dfb013bb3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts @@ -0,0 +1,122 @@ +import {TimeoutError} from '../common/Errors.js'; + +/** + * @internal + */ +export interface DeferredOptions { + message: string; + timeout: number; +} + +/** + * Creates and returns a deferred object along with the resolve/reject functions. + * + * If the deferred has not been resolved/rejected within the `timeout` period, + * the deferred gets resolves with a timeout error. `timeout` has to be greater than 0 or + * it is ignored. + * + * @internal + */ +export class Deferred<T, V extends Error = Error> { + static create<R, X extends Error = Error>( + opts?: DeferredOptions + ): Deferred<R, X> { + return new Deferred<R, X>(opts); + } + + static async race<R>( + awaitables: Array<Promise<R> | Deferred<R>> + ): Promise<R> { + const deferredWithTimeout = new Set<Deferred<R>>(); + try { + const promises = awaitables.map(value => { + if (value instanceof Deferred) { + if (value.#timeoutId) { + deferredWithTimeout.add(value); + } + + return value.valueOrThrow(); + } + + return value; + }); + // eslint-disable-next-line no-restricted-syntax + return await Promise.race(promises); + } finally { + for (const deferred of deferredWithTimeout) { + // We need to stop the timeout else + // Node.JS will keep running the event loop till the + // timer executes + deferred.reject(new Error('Timeout cleared')); + } + } + } + + #isResolved = false; + #isRejected = false; + #value: T | V | TimeoutError | undefined; + // SAFETY: This is ensured by #taskPromise. + #resolve!: (value: void) => void; + #taskPromise = new Promise<void>(resolve => { + this.#resolve = resolve; + }); + #timeoutId: ReturnType<typeof setTimeout> | undefined; + #timeoutError: TimeoutError | undefined; + + constructor(opts?: DeferredOptions) { + if (opts && opts.timeout > 0) { + this.#timeoutError = new TimeoutError(opts.message); + this.#timeoutId = setTimeout(() => { + this.reject(this.#timeoutError!); + }, opts.timeout); + } + } + + #finish(value: T | V | TimeoutError) { + clearTimeout(this.#timeoutId); + this.#value = value; + this.#resolve(); + } + + resolve(value: T): void { + if (this.#isRejected || this.#isResolved) { + return; + } + this.#isResolved = true; + this.#finish(value); + } + + reject(error: V | TimeoutError): void { + if (this.#isRejected || this.#isResolved) { + return; + } + this.#isRejected = true; + this.#finish(error); + } + + resolved(): boolean { + return this.#isResolved; + } + + finished(): boolean { + return this.#isResolved || this.#isRejected; + } + + value(): T | V | TimeoutError | undefined { + return this.#value; + } + + #promise: Promise<T> | undefined; + valueOrThrow(): Promise<T> { + if (!this.#promise) { + this.#promise = (async () => { + await this.#taskPromise; + if (this.#isRejected) { + throw this.#value; + } + return this.#value as T; + })(); + } + return this.#promise; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts new file mode 100644 index 0000000000..d4ab3044ab --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ProtocolError} from '../common/Errors.js'; + +/** + * @internal + */ +export interface ErrorLike extends Error { + name: string; + message: string; +} + +/** + * @internal + */ +export function isErrorLike(obj: unknown): obj is ErrorLike { + return ( + typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj + ); +} + +/** + * @internal + */ +export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException { + return ( + isErrorLike(obj) && + ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj) + ); +} + +/** + * @internal + */ +export function rewriteError( + error: ProtocolError, + message: string, + originalMessage?: string +): Error { + error.message = message; + error.originalMessage = originalMessage ?? error.originalMessage; + return error; +} + +/** + * @internal + */ +export function createProtocolErrorMessage(object: { + error: {message: string; data: any; code: number}; +}): string { + let message = object.error.message; + // TODO: remove the type checks when we stop connecting to BiDi with a CDP + // client. + if ( + object.error && + typeof object.error === 'object' && + 'data' in object.error + ) { + message += ` ${object.error.data}`; + } + return message; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts new file mode 100644 index 0000000000..c6da4cdf27 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import {interpolateFunction} from './Function.js'; + +describe('Function', function () { + describe('interpolateFunction', function () { + it('should work', async () => { + const test = interpolateFunction( + () => { + const test = PLACEHOLDER('test') as () => number; + return test(); + }, + {test: `() => 5`} + ); + expect(test()).toBe(5); + }); + it('should work inlined', async () => { + const test = interpolateFunction( + () => { + // Note the parenthesis will be removed by the typescript compiler. + return (PLACEHOLDER('test') as () => number)(); + }, + {test: `() => 5`} + ); + expect(test()).toBe(5); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts new file mode 100644 index 0000000000..41db98830b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +const createdFunctions = new Map<string, (...args: unknown[]) => unknown>(); + +/** + * Creates a function from a string. + * + * @internal + */ +export const createFunction = ( + functionValue: string +): ((...args: unknown[]) => unknown) => { + let fn = createdFunctions.get(functionValue); + if (fn) { + return fn; + } + fn = new Function(`return ${functionValue}`)() as ( + ...args: unknown[] + ) => unknown; + createdFunctions.set(functionValue, fn); + return fn; +}; + +/** + * @internal + */ +export function stringifyFunction(fn: (...args: never) => unknown): string { + let value = fn.toString(); + try { + new Function(`(${value})`); + } catch { + // This means we might have a function shorthand (e.g. `test(){}`). Let's + // try prefixing. + let prefix = 'function '; + if (value.startsWith('async ')) { + prefix = `async ${prefix}`; + value = value.substring('async '.length); + } + value = `${prefix}${value}`; + try { + new Function(`(${value})`); + } catch { + // We tried hard to serialize, but there's a weird beast here. + throw new Error('Passed function cannot be serialized!'); + } + } + return value; +} + +/** + * Replaces `PLACEHOLDER`s with the given replacements. + * + * All replacements must be valid JS code. + * + * @example + * + * ```ts + * interpolateFunction(() => PLACEHOLDER('test'), {test: 'void 0'}); + * // Equivalent to () => void 0 + * ``` + * + * @internal + */ +export const interpolateFunction = <T extends (...args: never[]) => unknown>( + fn: T, + replacements: Record<string, string> +): T => { + let value = stringifyFunction(fn); + for (const [name, jsValue] of Object.entries(replacements)) { + value = value.replace( + new RegExp(`PLACEHOLDER\\(\\s*(?:'${name}'|"${name}")\\s*\\)`, 'g'), + // Wrapping this ensures tersers that accidently inline PLACEHOLDER calls + // are still valid. Without, we may get calls like ()=>{...}() which is + // not valid. + `(${jsValue})` + ); + } + return createFunction(value) as unknown as T; +}; + +declare global { + /** + * Used for interpolation with {@link interpolateFunction}. + * + * @internal + */ + function PLACEHOLDER<T>(name: string): T; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts new file mode 100644 index 0000000000..9498bac306 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts @@ -0,0 +1,41 @@ +import {Deferred} from './Deferred.js'; +import {disposeSymbol} from './disposable.js'; + +/** + * @internal + */ +export class Mutex { + static Guard = class Guard { + #mutex: Mutex; + constructor(mutex: Mutex) { + this.#mutex = mutex; + } + [disposeSymbol](): void { + return this.#mutex.release(); + } + }; + + #locked = false; + #acquirers: Array<() => void> = []; + + // This is FIFO. + async acquire(): Promise<InstanceType<typeof Mutex.Guard>> { + if (!this.#locked) { + this.#locked = true; + return new Mutex.Guard(this); + } + const deferred = Deferred.create<void>(); + this.#acquirers.push(deferred.resolve.bind(deferred)); + await deferred.valueOrThrow(); + return new Mutex.Guard(this); + } + + release(): void { + const resolve = this.#acquirers.shift(); + if (!resolve) { + this.#locked = false; + return; + } + resolve(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts new file mode 100644 index 0000000000..7800b3be40 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Asserts that the given value is truthy. + * @param value - some conditional statement + * @param message - the error message to throw if the value is not truthy. + * + * @internal + */ +export const assert: (value: unknown, message?: string) => asserts value = ( + value, + message +) => { + if (!value) { + throw new Error(message); + } +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts new file mode 100644 index 0000000000..4cdaf15d5b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; +import sinon from 'sinon'; + +import {invokeAtMostOnceForArguments} from './decorators.js'; + +describe('decorators', function () { + describe('invokeAtMostOnceForArguments', () => { + it('should delegate calls', () => { + const spy = sinon.spy(); + class Test { + @invokeAtMostOnceForArguments + test(obj1: object, obj2: object) { + spy(obj1, obj2); + } + } + const t = new Test(); + expect(spy.callCount).toBe(0); + const obj1 = {}; + const obj2 = {}; + t.test(obj1, obj2); + expect(spy.callCount).toBe(1); + }); + + it('should prevent repeated calls', () => { + const spy = sinon.spy(); + class Test { + @invokeAtMostOnceForArguments + test(obj1: object, obj2: object) { + spy(obj1, obj2); + } + } + const t = new Test(); + expect(spy.callCount).toBe(0); + const obj1 = {}; + const obj2 = {}; + t.test(obj1, obj2); + expect(spy.callCount).toBe(1); + expect(spy.lastCall.calledWith(obj1, obj2)).toBeTruthy(); + t.test(obj1, obj2); + expect(spy.callCount).toBe(1); + expect(spy.lastCall.calledWith(obj1, obj2)).toBeTruthy(); + const obj3 = {}; + t.test(obj1, obj3); + expect(spy.callCount).toBe(2); + expect(spy.lastCall.calledWith(obj1, obj3)).toBeTruthy(); + }); + + it('should throw an error for dynamic argumetns', () => { + class Test { + @invokeAtMostOnceForArguments + test(..._args: unknown[]) {} + } + const t = new Test(); + t.test({}); + expect(() => { + t.test({}, {}); + }).toThrow(); + }); + + it('should throw an error for non object arguments', () => { + class Test { + @invokeAtMostOnceForArguments + test(..._args: unknown[]) {} + } + const t = new Test(); + expect(() => { + t.test(1); + }).toThrow(); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts new file mode 100644 index 0000000000..af21c5fe29 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Disposed, Moveable} from '../common/types.js'; + +import {asyncDisposeSymbol, disposeSymbol} from './disposable.js'; +import {Mutex} from './Mutex.js'; + +const instances = new WeakSet<object>(); + +export function moveable< + Class extends abstract new (...args: never[]) => Moveable, +>(Class: Class, _: ClassDecoratorContext<Class>): Class { + let hasDispose = false; + if (Class.prototype[disposeSymbol]) { + const dispose = Class.prototype[disposeSymbol]; + Class.prototype[disposeSymbol] = function (this: InstanceType<Class>) { + if (instances.has(this)) { + instances.delete(this); + return; + } + return dispose.call(this); + }; + hasDispose = true; + } + if (Class.prototype[asyncDisposeSymbol]) { + const asyncDispose = Class.prototype[asyncDisposeSymbol]; + Class.prototype[asyncDisposeSymbol] = function (this: InstanceType<Class>) { + if (instances.has(this)) { + instances.delete(this); + return; + } + return asyncDispose.call(this); + }; + hasDispose = true; + } + if (hasDispose) { + Class.prototype.move = function ( + this: InstanceType<Class> + ): InstanceType<Class> { + instances.add(this); + return this; + }; + } + return Class; +} + +export function throwIfDisposed<This extends Disposed>( + message: (value: This) => string = value => { + return `Attempted to use disposed ${value.constructor.name}.`; + } +) { + return (target: (this: This, ...args: any[]) => any, _: unknown) => { + return function (this: This, ...args: any[]): any { + if (this.disposed) { + throw new Error(message(this)); + } + return target.call(this, ...args); + }; + }; +} + +export function inertIfDisposed<This extends Disposed>( + target: (this: This, ...args: any[]) => any, + _: unknown +) { + return function (this: This, ...args: any[]): any { + if (this.disposed) { + return; + } + return target.call(this, ...args); + }; +} + +/** + * The decorator only invokes the target if the target has not been invoked with + * the same arguments before. The decorated method throws an error if it's + * invoked with a different number of elements: if you decorate a method, it + * should have the same number of arguments + * + * @internal + */ +export function invokeAtMostOnceForArguments( + target: (this: unknown, ...args: any[]) => any, + _: unknown +): typeof target { + const cache = new WeakMap(); + let cacheDepth = -1; + return function (this: unknown, ...args: unknown[]) { + if (cacheDepth === -1) { + cacheDepth = args.length; + } + if (cacheDepth !== args.length) { + throw new Error( + 'Memoized method was called with the wrong number of arguments' + ); + } + let freshArguments = false; + let cacheIterator = cache; + for (const arg of args) { + if (cacheIterator.has(arg as object)) { + cacheIterator = cacheIterator.get(arg as object)!; + } else { + freshArguments = true; + cacheIterator.set(arg as object, new WeakMap()); + cacheIterator = cacheIterator.get(arg as object)!; + } + } + if (!freshArguments) { + return; + } + return target.call(this, ...args); + }; +} + +export function guarded<T extends object>( + getKey = function (this: T): object { + return this; + } +) { + return ( + target: (this: T, ...args: any[]) => Promise<any>, + _: ClassMethodDecoratorContext<T> + ): typeof target => { + const mutexes = new WeakMap<object, Mutex>(); + return async function (...args) { + const key = getKey.call(this); + let mutex = mutexes.get(key); + if (!mutex) { + mutex = new Mutex(); + mutexes.set(key, mutex); + } + await using _ = await mutex.acquire(); + return await target.call(this, ...args); + }; + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts new file mode 100644 index 0000000000..a1848f3860 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +declare global { + interface SymbolConstructor { + /** + * A method that is used to release resources held by an object. Called by + * the semantics of the `using` statement. + */ + readonly dispose: unique symbol; + + /** + * A method that is used to asynchronously release resources held by an + * object. Called by the semantics of the `await using` statement. + */ + readonly asyncDispose: unique symbol; + } + + interface Disposable { + [Symbol.dispose](): void; + } + + interface AsyncDisposable { + [Symbol.asyncDispose](): PromiseLike<void>; + } +} + +(Symbol as any).dispose ??= Symbol('dispose'); +(Symbol as any).asyncDispose ??= Symbol('asyncDispose'); + +/** + * @internal + */ +export const disposeSymbol: typeof Symbol.dispose = Symbol.dispose; + +/** + * @internal + */ +export const asyncDisposeSymbol: typeof Symbol.asyncDispose = + Symbol.asyncDispose; + +/** + * @internal + */ +export class DisposableStack { + #disposed = false; + #stack: Disposable[] = []; + + /** + * Returns a value indicating whether this stack has been disposed. + */ + get disposed(): boolean { + return this.#disposed; + } + + /** + * Disposes each resource in the stack in the reverse order that they were added. + */ + dispose(): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + for (const resource of this.#stack.reverse()) { + resource[disposeSymbol](); + } + } + + /** + * Adds a disposable resource to the stack, returning the resource. + * + * @param value - The resource to add. `null` and `undefined` will not be added, + * but will be returned. + * @returns The provided `value`. + */ + use<T extends Disposable | null | undefined>(value: T): T { + if (value) { + this.#stack.push(value); + } + return value; + } + + /** + * Adds a value and associated disposal callback as a resource to the stack. + * + * @param value - The value to add. + * @param onDispose - The callback to use in place of a `[disposeSymbol]()` + * method. Will be invoked with `value` as the first parameter. + * @returns The provided `value`. + */ + adopt<T>(value: T, onDispose: (value: T) => void): T { + this.#stack.push({ + [disposeSymbol]() { + onDispose(value); + }, + }); + return value; + } + + /** + * Adds a callback to be invoked when the stack is disposed. + */ + defer(onDispose: () => void): void { + this.#stack.push({ + [disposeSymbol]() { + onDispose(); + }, + }); + } + + /** + * Move all resources out of this stack and into a new `DisposableStack`, and + * marks this stack as disposed. + * + * @example + * + * ```ts + * class C { + * #res1: Disposable; + * #res2: Disposable; + * #disposables: DisposableStack; + * constructor() { + * // stack will be disposed when exiting constructor for any reason + * using stack = new DisposableStack(); + * + * // get first resource + * this.#res1 = stack.use(getResource1()); + * + * // get second resource. If this fails, both `stack` and `#res1` will be disposed. + * this.#res2 = stack.use(getResource2()); + * + * // all operations succeeded, move resources out of `stack` so that + * // they aren't disposed when constructor exits + * this.#disposables = stack.move(); + * } + * + * [disposeSymbol]() { + * this.#disposables.dispose(); + * } + * } + * ``` + */ + move(): DisposableStack { + if (this.#disposed) { + throw new ReferenceError('a disposed stack can not use anything new'); // step 3 + } + const stack = new DisposableStack(); // step 4-5 + stack.#stack = this.#stack; + this.#disposed = true; + return stack; + } + + [disposeSymbol] = this.dispose; + + readonly [Symbol.toStringTag] = 'DisposableStack'; +} + +/** + * @internal + */ +export class AsyncDisposableStack { + #disposed = false; + #stack: AsyncDisposable[] = []; + + /** + * Returns a value indicating whether this stack has been disposed. + */ + get disposed(): boolean { + return this.#disposed; + } + + /** + * Disposes each resource in the stack in the reverse order that they were added. + */ + async dispose(): Promise<void> { + if (this.#disposed) { + return; + } + this.#disposed = true; + for (const resource of this.#stack.reverse()) { + await resource[asyncDisposeSymbol](); + } + } + + /** + * Adds a disposable resource to the stack, returning the resource. + * + * @param value - The resource to add. `null` and `undefined` will not be added, + * but will be returned. + * @returns The provided `value`. + */ + use<T extends AsyncDisposable | null | undefined>(value: T): T { + if (value) { + this.#stack.push(value); + } + return value; + } + + /** + * Adds a value and associated disposal callback as a resource to the stack. + * + * @param value - The value to add. + * @param onDispose - The callback to use in place of a `[disposeSymbol]()` + * method. Will be invoked with `value` as the first parameter. + * @returns The provided `value`. + */ + adopt<T>(value: T, onDispose: (value: T) => Promise<void>): T { + this.#stack.push({ + [asyncDisposeSymbol]() { + return onDispose(value); + }, + }); + return value; + } + + /** + * Adds a callback to be invoked when the stack is disposed. + */ + defer(onDispose: () => Promise<void>): void { + this.#stack.push({ + [asyncDisposeSymbol]() { + return onDispose(); + }, + }); + } + + /** + * Move all resources out of this stack and into a new `DisposableStack`, and + * marks this stack as disposed. + * + * @example + * + * ```ts + * class C { + * #res1: Disposable; + * #res2: Disposable; + * #disposables: DisposableStack; + * constructor() { + * // stack will be disposed when exiting constructor for any reason + * using stack = new DisposableStack(); + * + * // get first resource + * this.#res1 = stack.use(getResource1()); + * + * // get second resource. If this fails, both `stack` and `#res1` will be disposed. + * this.#res2 = stack.use(getResource2()); + * + * // all operations succeeded, move resources out of `stack` so that + * // they aren't disposed when constructor exits + * this.#disposables = stack.move(); + * } + * + * [disposeSymbol]() { + * this.#disposables.dispose(); + * } + * } + * ``` + */ + move(): AsyncDisposableStack { + if (this.#disposed) { + throw new ReferenceError('a disposed stack can not use anything new'); // step 3 + } + const stack = new AsyncDisposableStack(); // step 4-5 + stack.#stack = this.#stack; + this.#disposed = true; + return stack; + } + + [asyncDisposeSymbol] = this.dispose; + + readonly [Symbol.toStringTag] = 'AsyncDisposableStack'; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts new file mode 100644 index 0000000000..f55610da9e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './assert.js'; +export * from './Deferred.js'; +export * from './ErrorLike.js'; +export * from './AsyncIterableUtil.js'; +export * from './disposable.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/mitt.ts b/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/mitt.ts new file mode 100644 index 0000000000..c20aaa8342 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/mitt.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from 'mitt'; +export {default as default} from 'mitt'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/rxjs/rxjs.ts b/remote/test/puppeteer/packages/puppeteer-core/third_party/rxjs/rxjs.ts new file mode 100644 index 0000000000..b8b64788ae --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/rxjs/rxjs.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +export { + bufferCount, + catchError, + concat, + concatMap, + defaultIfEmpty, + defer, + delay, + EMPTY, + filter, + first, + firstValueFrom, + forkJoin, + from, + fromEvent, + identity, + ignoreElements, + lastValueFrom, + map, + merge, + mergeMap, + NEVER, + noop, + Observable, + of, + pipe, + race, + raceWith, + retry, + startWith, + switchMap, + takeUntil, + tap, + throwIfEmpty, + timer, + zip, +} from 'rxjs'; + +export type * from 'rxjs'; + +import {filter, from, map, mergeMap, type Observable} from 'rxjs'; + +export function filterAsync<T>( + predicate: (value: T) => boolean | PromiseLike<boolean> +) { + return mergeMap<T, Observable<T>>(value => { + return from(Promise.resolve(predicate(value))).pipe( + filter(isMatch => { + return isMatch; + }), + map(() => { + return value; + }) + ); + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.cjs.json b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.cjs.json new file mode 100644 index 0000000000..a796932cd8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "../lib/cjs/third_party", + "declarationMap": false, + "sourceMap": false + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json new file mode 100644 index 0000000000..25c438c57d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "declarationMap": false, + "outDir": "../lib/esm/third_party", + "sourceMap": false, + }, +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/tools/ensure-correct-devtools-protocol-package.ts b/remote/test/puppeteer/packages/puppeteer-core/tools/ensure-correct-devtools-protocol-package.ts new file mode 100644 index 0000000000..ca230716b3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/tools/ensure-correct-devtools-protocol-package.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * This script ensures that the pinned version of devtools-protocol in + * package.json is the right version for the current revision of Chrome that + * Puppeteer ships with. + * + * The devtools-protocol package publisher runs every hour and checks if there + * are protocol changes. If there are, it will be versioned with the revision + * number of the commit that last changed the .pdl files. + * + * Chrome branches/releases are figured out at a later point in time, so it's + * not true that each Chrome revision will have an exact matching revision + * version of devtools-protocol. To ensure we're using a devtools-protocol that + * is aligned with our revision, we want to find the largest package number + * that's \<= the revision that Puppeteer is using. + * + * This script uses npm's `view` function to list all versions in a range and + * find the one closest to our Chrome revision. + */ + +import {execSync} from 'child_process'; + +import packageJson from '../package.json' assert {type: 'json'}; +import {PUPPETEER_REVISIONS} from '../src/revisions.js'; + +async function main() { + const currentProtocolPackageInstalledVersion = + packageJson.dependencies['devtools-protocol']; + + /** + * Ensure that the devtools-protocol version is pinned. + */ + if (/^[^0-9]/.test(currentProtocolPackageInstalledVersion)) { + console.log( + `ERROR: devtools-protocol package is not pinned to a specific version.\n` + ); + process.exit(1); + } + + const chromeVersion = PUPPETEER_REVISIONS.chrome; + // find the right revision for our Chrome version. + const req = await fetch( + `https://googlechromelabs.github.io/chrome-for-testing/known-good-versions.json` + ); + const releases = await req.json(); + const chromeRevision = releases.versions.find(release => { + return release.version === chromeVersion; + }).revision; + console.log(`Revisions for ${chromeVersion}: ${chromeRevision}`); + + const command = `npm view "devtools-protocol@<=0.0.${chromeRevision}" version | tail -1`; + + console.log( + 'Checking npm for devtools-protocol revisions:\n', + `'${command}'`, + '\n' + ); + + const output = execSync(command, { + encoding: 'utf8', + }); + + const bestRevisionFromNpm = output.split(' ')[1]!.replace(/'|\n/g, ''); + + if (currentProtocolPackageInstalledVersion !== bestRevisionFromNpm) { + console.log(`ERROR: bad devtools-protocol revision detected: + + Current Puppeteer Chrome revision: ${chromeRevision} + Current devtools-protocol version in package.json: ${currentProtocolPackageInstalledVersion} + Expected devtools-protocol version: ${bestRevisionFromNpm}`); + + process.exit(1); + } + + console.log( + `Correct devtools-protocol version found (${bestRevisionFromNpm}).` + ); + process.exit(0); +} + +void main(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json b/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json new file mode 100644 index 0000000000..b662532a01 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [ + {"path": "src/tsconfig.esm.json"}, + {"path": "src/tsconfig.cjs.json"}, + ], +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/tsdoc.json b/remote/test/puppeteer/packages/puppeteer-core/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/.gitignore b/remote/test/puppeteer/packages/puppeteer/.gitignore new file mode 100644 index 0000000000..42061c01a1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/.gitignore @@ -0,0 +1 @@ +README.md
\ No newline at end of file diff --git a/remote/test/puppeteer/packages/puppeteer/CHANGELOG.md b/remote/test/puppeteer/packages/puppeteer/CHANGELOG.md new file mode 100644 index 0000000000..c3d834c5f5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/CHANGELOG.md @@ -0,0 +1,2096 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.3.0 to 0.3.1 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.1.1 to 20.1.2 + * @puppeteer/browsers bumped from 1.0.1 to 1.1.0 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.8.1 to 20.8.2 + * @puppeteer/browsers bumped from 1.4.4 to 1.4.5 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.0.2 to 21.0.3 + * @puppeteer/browsers bumped from 1.5.1 to 1.6.0 + +## [21.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.9.0...puppeteer-v21.10.0) (2024-01-29) + + +### Features + +* download chrome-headless-shell by default and use it for the old headless mode ([#11754](https://github.com/puppeteer/puppeteer/issues/11754)) ([ce894a2](https://github.com/puppeteer/puppeteer/commit/ce894a2ffce4bc44bd11f12d1f0543e003a97e02)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.9.0 to 21.10.0 + +## [21.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.8.0...puppeteer-v21.9.0) (2024-01-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.8.0 to 21.9.0 + +## [21.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.7.0...puppeteer-v21.8.0) (2024-01-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.7.0 to 21.8.0 + +## [21.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.6.1...puppeteer-v21.7.0) (2024-01-04) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.6.1 to 21.7.0 + * @puppeteer/browsers bumped from 1.9.0 to 1.9.1 + +## [21.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.6.0...puppeteer-v21.6.1) (2023-12-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.6.0 to 21.6.1 + +## [21.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.5.2...puppeteer-v21.6.0) (2023-12-05) + + +### Features + +* implement the Puppeteer CLI ([#11344](https://github.com/puppeteer/puppeteer/issues/11344)) ([53fb69b](https://github.com/puppeteer/puppeteer/commit/53fb69bf7f2bf06fa4fd7bb6d3cf21382386f6e7)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.5.2 to 21.6.0 + * @puppeteer/browsers bumped from 1.8.0 to 1.9.0 + +## [21.5.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.5.1...puppeteer-v21.5.2) (2023-11-15) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.5.1 to 21.5.2 + +## [21.5.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.5.0...puppeteer-v21.5.1) (2023-11-09) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.5.0 to 21.5.1 + +## [21.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.4.1...puppeteer-v21.5.0) (2023-11-02) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.4.1 to 21.5.0 + +## [21.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.4.0...puppeteer-v21.4.1) (2023-10-23) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.4.0 to 21.4.1 + +## [21.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.8...puppeteer-v21.4.0) (2023-10-20) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.8 to 21.4.0 + * @puppeteer/browsers bumped from 1.7.1 to 1.8.0 + +## [21.3.8](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.7...puppeteer-v21.3.8) (2023-10-06) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.7 to 21.3.8 + +## [21.3.7](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.6...puppeteer-v21.3.7) (2023-10-05) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.6 to 21.3.7 + +## [21.3.6](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.5...puppeteer-v21.3.6) (2023-09-28) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.5 to 21.3.6 + +## [21.3.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.4...puppeteer-v21.3.5) (2023-09-26) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.4 to 21.3.5 + +## [21.3.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.3...puppeteer-v21.3.4) (2023-09-22) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.3 to 21.3.4 + +## [21.3.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.2...puppeteer-v21.3.3) (2023-09-22) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.2 to 21.3.3 + +## [21.3.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.1...puppeteer-v21.3.2) (2023-09-22) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.1 to 21.3.2 + +## [21.3.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.0...puppeteer-v21.3.1) (2023-09-19) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.0 to 21.3.1 + +## [21.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.2.1...puppeteer-v21.3.0) (2023-09-19) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.2.1 to 21.3.0 + +## [21.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.2.0...puppeteer-v21.2.1) (2023-09-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.2.0 to 21.2.1 + * @puppeteer/browsers bumped from 1.7.0 to 1.7.1 + +## [21.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.1.1...puppeteer-v21.2.0) (2023-09-12) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.1.1 to 21.2.0 + +## [21.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.1.0...puppeteer-v21.1.1) (2023-08-28) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.1.0 to 21.1.1 + +## [21.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.0.3...puppeteer-v21.1.0) (2023-08-18) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.0.3 to 21.1.0 + * @puppeteer/browsers bumped from 1.6.0 to 1.7.0 + +## [21.0.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.0.1...puppeteer-v21.0.2) (2023-08-08) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.0.1 to 21.0.2 + * @puppeteer/browsers bumped from 1.5.0 to 1.5.1 + +## [21.0.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.0.0...puppeteer-v21.0.1) (2023-08-03) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.0.0 to 21.0.1 + +## [21.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.9.0...puppeteer-v21.0.0) (2023-08-02) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.9.0 to 21.0.0 + * @puppeteer/browsers bumped from 1.4.6 to 1.5.0 + +## [20.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.8.3...puppeteer-v20.9.0) (2023-07-20) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.8.3 to 20.9.0 + * @puppeteer/browsers bumped from 1.4.5 to 1.4.6 + +## [20.8.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.8.2...puppeteer-v20.8.3) (2023-07-18) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.8.2 to 20.8.3 + +## [20.8.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.8.0...puppeteer-v20.8.1) (2023-07-11) + + +### Bug Fixes + +* remove test metadata files ([#10520](https://github.com/puppeteer/puppeteer/issues/10520)) ([cbf4f2a](https://github.com/puppeteer/puppeteer/commit/cbf4f2a66912f24849ae8c88fc1423851dcc4aa7)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.8.0 to 20.8.1 + * @puppeteer/browsers bumped from 1.4.3 to 1.4.4 + +## [20.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.4...puppeteer-v20.8.0) (2023-07-06) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.4 to 20.8.0 + +## [20.7.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.3...puppeteer-v20.7.4) (2023-06-29) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.3 to 20.7.4 + * @puppeteer/browsers bumped from 1.4.2 to 1.4.3 + +## [20.7.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.2...puppeteer-v20.7.3) (2023-06-20) + + +### Bug Fixes + +* include src into published package ([#10415](https://github.com/puppeteer/puppeteer/issues/10415)) ([d1ffad0](https://github.com/puppeteer/puppeteer/commit/d1ffad059ae66104842b92dc814d362c123b9646)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.2 to 20.7.3 + * @puppeteer/browsers bumped from 1.4.1 to 1.4.2 + +## [20.7.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.1...puppeteer-v20.7.2) (2023-06-16) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.1 to 20.7.2 + +## [20.7.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.0...puppeteer-v20.7.1) (2023-06-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.0 to 20.7.1 + +## [20.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.6.0...puppeteer-v20.7.0) (2023-06-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.6.0 to 20.7.0 + +## [20.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.5.0...puppeteer-v20.6.0) (2023-06-09) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.5.0 to 20.6.0 + +## [20.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.4.0...puppeteer-v20.5.0) (2023-05-31) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.4.0 to 20.5.0 + * @puppeteer/browsers bumped from 1.4.0 to 1.4.1 + +## [20.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.3.0...puppeteer-v20.4.0) (2023-05-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.3.0 to 20.4.0 + * @puppeteer/browsers bumped from 1.3.0 to 1.4.0 + +## [20.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.2.1...puppeteer-v20.3.0) (2023-05-22) + + +### Features + +* add an ability to trim cache for Puppeteer ([#10199](https://github.com/puppeteer/puppeteer/issues/10199)) ([1ad32ec](https://github.com/puppeteer/puppeteer/commit/1ad32ec9948ca3e07e15548a562c8f3c633b3dc3)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.2.1 to 20.3.0 + +## [20.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.2.0...puppeteer-v20.2.1) (2023-05-15) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.2.0 to 20.2.1 + * @puppeteer/browsers bumped from 1.2.0 to 1.3.0 + +## [20.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.1.2...puppeteer-v20.2.0) (2023-05-11) + + +### Bug Fixes + +* downloadPath should be used by the install script ([#10163](https://github.com/puppeteer/puppeteer/issues/10163)) ([4398f66](https://github.com/puppeteer/puppeteer/commit/4398f66f281f1ffe5be81b529fc4751edfaf761d)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.1.2 to 20.2.0 + * @puppeteer/browsers bumped from 1.1.0 to 1.2.0 + +## [20.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.1.0...puppeteer-v20.1.1) (2023-05-05) + + +### Bug Fixes + +* rename PUPPETEER_DOWNLOAD_HOST to PUPPETEER_DOWNLOAD_BASE_URL ([#10130](https://github.com/puppeteer/puppeteer/issues/10130)) ([9758cae](https://github.com/puppeteer/puppeteer/commit/9758cae029f90908c4b5340561d9c51c26aa2f21)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.1.0 to 20.1.1 + * @puppeteer/browsers bumped from 1.0.0 to 1.0.1 + +## [20.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.0.0...puppeteer-v20.1.0) (2023-05-03) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.0.0 to 20.1.0 + +## [20.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.11.1...puppeteer-v20.0.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) + +### Features + +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) ([df4d60c](https://github.com/puppeteer/puppeteer/commit/df4d60c187aa11c4ad783827242e9511f4ec2aab)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.11.1 to 20.0.0 + * @puppeteer/browsers bumped from 0.5.0 to 1.0.0 + +## [19.11.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.11.0...puppeteer-v19.11.1) (2023-04-25) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.11.0 to 19.11.1 + +## [19.11.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.10.1...puppeteer-v19.11.0) (2023-04-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.10.1 to 19.11.0 + +## [19.10.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.10.0...puppeteer-v19.10.1) (2023-04-21) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.10.0 to 19.10.1 + * @puppeteer/browsers bumped from 0.4.1 to 0.5.0 + +## [19.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.9.1...puppeteer-v19.10.0) (2023-04-20) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.9.1 to 19.10.0 + +## [19.9.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.9.0...puppeteer-v19.9.1) (2023-04-17) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.9.0 to 19.9.1 + +## [19.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.5...puppeteer-v19.9.0) (2023-04-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.5 to 19.9.0 + * @puppeteer/browsers bumped from 0.4.0 to 0.4.1 + +## [19.8.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.4...puppeteer-v19.8.5) (2023-04-06) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.4 to 19.8.5 + * @puppeteer/browsers bumped from 0.3.3 to 0.4.0 + +## [19.8.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.3...puppeteer-v19.8.4) (2023-04-06) + + +### Bug Fixes + +* consider downloadHost as baseUrl ([#9973](https://github.com/puppeteer/puppeteer/issues/9973)) ([05a44af](https://github.com/puppeteer/puppeteer/commit/05a44afe5affcac9fe0f0a2e83f17807c99b2f0c)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.3 to 19.8.4 + * @puppeteer/browsers bumped from 0.3.2 to 0.3.3 + +## [19.8.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.2...puppeteer-v19.8.3) (2023-04-03) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.1 to 19.8.3 + * @puppeteer/browsers bumped from 0.3.1 to 0.3.2 + +## [19.8.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.0...puppeteer-v19.8.1) (2023-03-28) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.0 to 19.8.1 + +## [19.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.5...puppeteer-v19.8.0) (2023-03-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.5 to 19.8.0 + +## [19.7.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.4...puppeteer-v19.7.5) (2023-03-14) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.4 to 19.7.5 + +## [19.7.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.3...puppeteer-v19.7.4) (2023-03-10) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.3 to 19.7.4 + +## [19.7.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.2...puppeteer-v19.7.3) (2023-03-06) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.2 to 19.7.3 + +## [19.7.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.1...puppeteer-v19.7.2) (2023-02-20) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.1 to 19.7.2 + +## [19.7.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.0...puppeteer-v19.7.1) (2023-02-15) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.0 to 19.7.1 + +## [19.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.6.3...puppeteer-v19.7.0) (2023-02-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.6.3 to 19.7.0 + +## [19.6.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.6.2...puppeteer-v19.6.3) (2023-02-01) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.6.2 to 19.6.3 + +## [19.6.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.6.1...puppeteer-v19.6.2) (2023-01-27) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.6.1 to 19.6.2 + +## [19.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.6.0...puppeteer-v19.6.1) (2023-01-26) + + +### Bug Fixes + +* don't clean up previous browser versions ([#9568](https://github.com/puppeteer/puppeteer/issues/9568)) ([344bc2a](https://github.com/puppeteer/puppeteer/commit/344bc2af62e4068fe2cb8162d4b6c8242aac843b)), closes [#9533](https://github.com/puppeteer/puppeteer/issues/9533) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.6.0 to 19.6.1 + +## [19.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.5.2...puppeteer-v19.6.0) (2023-01-23) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.5.2 to 19.6.0 + +## [19.5.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.5.1...puppeteer-v19.5.2) (2023-01-11) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.5.1 to 19.5.2 + +## [19.5.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.5.0...puppeteer-v19.5.1) (2023-01-11) + + +### Bug Fixes + +* use puppeteer node for installation script ([#9489](https://github.com/puppeteer/puppeteer/issues/9489)) ([9bf90d9](https://github.com/puppeteer/puppeteer/commit/9bf90d9f4b5aeab06f8b433714712cad3259d36e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.5.0 to 19.5.1 + +## [19.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.4.1...puppeteer-v19.5.0) (2023-01-05) + + +### Features + +* Default to not downloading if explicit browser path is set ([#9440](https://github.com/puppeteer/puppeteer/issues/9440)) ([d2536d7](https://github.com/puppeteer/puppeteer/commit/d2536d7cf5fa731250bbfd0d18959cacc8afffac)), closes [#9419](https://github.com/puppeteer/puppeteer/issues/9419) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.4.1 to 19.5.0 + +## [19.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.4.0...puppeteer-v19.4.1) (2022-12-16) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.4.0 to 19.4.1 + +## [19.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.3.0...puppeteer-v19.4.0) (2022-12-07) + + +### Features + +* **chromium:** roll to Chromium 109.0.5412.0 (r1069273) ([#9364](https://github.com/puppeteer/puppeteer/issues/9364)) ([1875da6](https://github.com/puppeteer/puppeteer/commit/1875da61916df1fbcf98047858c01075bd9af189)), closes [#9233](https://github.com/puppeteer/puppeteer/issues/9233) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.3.0 to 19.4.0 + +## [19.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.2.2...puppeteer-v19.3.0) (2022-11-23) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.2.2 to 19.3.0 + +## [19.2.2](https://github.com/puppeteer/puppeteer/compare/v19.2.1...v19.2.2) (2022-11-03) + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.2.1 to ^19.2.2 + +## [19.2.1](https://github.com/puppeteer/puppeteer/compare/v19.2.0...v19.2.1) (2022-10-28) + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.2.0 to ^19.2.1 + +## [19.2.0](https://github.com/puppeteer/puppeteer/compare/v19.1.2...v19.2.0) (2022-10-26) + + +### Features + +* **chromium:** roll to Chromium 108.0.5351.0 (r1056772) ([#9153](https://github.com/puppeteer/puppeteer/issues/9153)) ([e78a4e8](https://github.com/puppeteer/puppeteer/commit/e78a4e89c22bb1180e72d180c16b39673ff9125e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.1.1 to ^19.2.0 + +## [19.1.2](https://github.com/puppeteer/puppeteer/compare/v19.1.1...v19.1.2) (2022-10-25) + + +### Bug Fixes + +* skip browser download ([#9160](https://github.com/puppeteer/puppeteer/issues/9160)) ([2245d7d](https://github.com/puppeteer/puppeteer/commit/2245d7d6ed0630ee1ad985dcbd48354772924750)) + +## [19.1.1](https://github.com/puppeteer/puppeteer/compare/v19.1.0...v19.1.1) (2022-10-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.1.0 to ^19.1.1 + +## [19.1.0](https://github.com/puppeteer/puppeteer/compare/v19.0.0...v19.1.0) (2022-10-21) + + +### Features + +* use configuration files ([#9140](https://github.com/puppeteer/puppeteer/issues/9140)) ([ec20174](https://github.com/puppeteer/puppeteer/commit/ec201744f077987b288e3dff52c0906fe700f6fb)), closes [#9128](https://github.com/puppeteer/puppeteer/issues/9128) + + +### Bug Fixes + +* update `BrowserFetcher` deprecation message ([#9141](https://github.com/puppeteer/puppeteer/issues/9141)) ([efcbc97](https://github.com/puppeteer/puppeteer/commit/efcbc97c60e4cfd49a9ed25a900f6133d06b290b)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.0.0 to ^19.1.0 + +## [19.0.0](https://github.com/puppeteer/puppeteer/compare/v18.2.1...v19.0.0) (2022-10-14) + + +### ⚠ BREAKING CHANGES + +* use `~/.cache/puppeteer` for browser downloads (#9095) +* deprecate `createBrowserFetcher` in favor of `BrowserFetcher` (#9079) +* refactor custom query handler API (#9078) +* remove `puppeteer.devices` in favor of `KnownDevices` (#9075) +* deprecate indirect network condition imports (#9074) + +### Features + +* deprecate `createBrowserFetcher` in favor of `BrowserFetcher` ([#9079](https://github.com/puppeteer/puppeteer/issues/9079)) ([7294dfe](https://github.com/puppeteer/puppeteer/commit/7294dfe9c6c3b224f95ba6d59b5ef33d379fd09a)), closes [#8999](https://github.com/puppeteer/puppeteer/issues/8999) +* use `~/.cache/puppeteer` for browser downloads ([#9095](https://github.com/puppeteer/puppeteer/issues/9095)) ([3df375b](https://github.com/puppeteer/puppeteer/commit/3df375baedad64b8773bb1e1e6f81b604ed18989)) + + +### Bug Fixes + +* deprecate indirect network condition imports ([#9074](https://github.com/puppeteer/puppeteer/issues/9074)) ([41d0122](https://github.com/puppeteer/puppeteer/commit/41d0122b94f41b308536c48ced345dec8c272a49)) +* refactor custom query handler API ([#9078](https://github.com/puppeteer/puppeteer/issues/9078)) ([1847704](https://github.com/puppeteer/puppeteer/commit/1847704789e2888c755de8c739d567364b8ad645)) +* remove `puppeteer.devices` in favor of `KnownDevices` ([#9075](https://github.com/puppeteer/puppeteer/issues/9075)) ([87c08fd](https://github.com/puppeteer/puppeteer/commit/87c08fd86a79b63308ad8d46c5f7acd1927505f8)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 18.2.1 to ^19.0.0 + +## [18.2.1](https://github.com/puppeteer/puppeteer/compare/v18.2.0...v18.2.1) (2022-10-06) + + +### Bug Fixes + +* add README to package during prepack ([#9057](https://github.com/puppeteer/puppeteer/issues/9057)) ([9374e23](https://github.com/puppeteer/puppeteer/commit/9374e23d3da5e40378461ed08db24649730a445a)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 18.2.0 to ^18.2.1 + +## [18.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v18.1.0...puppeteer-v18.2.0) (2022-10-05) + + +### Features + +* separate puppeteer and puppeteer-core ([#9023](https://github.com/puppeteer/puppeteer/issues/9023)) ([f42336c](https://github.com/puppeteer/puppeteer/commit/f42336cf83982332829ca7e14ee48d8676e11545)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 18.1.0 to ^18.2.0 + +## [18.1.0](https://github.com/puppeteer/puppeteer/compare/v18.0.5...v18.1.0) (2022-10-05) + + +### Features + +* **chromium:** roll to Chromium 107.0.5296.0 (r1045629) ([#9039](https://github.com/puppeteer/puppeteer/issues/9039)) ([022fbde](https://github.com/puppeteer/puppeteer/commit/022fbde85e067e8c419cf42dd571f9a1187c343c)) + +## [18.0.5](https://github.com/puppeteer/puppeteer/compare/v18.0.4...v18.0.5) (2022-09-22) + + +### Bug Fixes + +* add missing npm config environment variable ([#8996](https://github.com/puppeteer/puppeteer/issues/8996)) ([7c1be20](https://github.com/puppeteer/puppeteer/commit/7c1be20aef46aaf5029732a580ec65aa8008aa9c)) + +## [18.0.4](https://github.com/puppeteer/puppeteer/compare/v18.0.3...v18.0.4) (2022-09-21) + + +### Bug Fixes + +* hardcode binding names ([#8993](https://github.com/puppeteer/puppeteer/issues/8993)) ([7e20554](https://github.com/puppeteer/puppeteer/commit/7e2055433e79ef20f6dcdf02f92e1d64564b7d33)) + +## [18.0.3](https://github.com/puppeteer/puppeteer/compare/v18.0.2...v18.0.3) (2022-09-20) + + +### Bug Fixes + +* change injected.ts imports ([#8987](https://github.com/puppeteer/puppeteer/issues/8987)) ([10a114d](https://github.com/puppeteer/puppeteer/commit/10a114d36f2add90860950f61b3f8b93258edb5c)) + +## [18.0.2](https://github.com/puppeteer/puppeteer/compare/v18.0.1...v18.0.2) (2022-09-19) + + +### Bug Fixes + +* mark internal objects ([#8984](https://github.com/puppeteer/puppeteer/issues/8984)) ([181a148](https://github.com/puppeteer/puppeteer/commit/181a148269fce1575f5e37056929ecdec0517586)) + +## [18.0.1](https://github.com/puppeteer/puppeteer/compare/v18.0.0...v18.0.1) (2022-09-19) + + +### Bug Fixes + +* internal lazy params ([#8982](https://github.com/puppeteer/puppeteer/issues/8982)) ([d504597](https://github.com/puppeteer/puppeteer/commit/d5045976a6dd321bbd265b84c2474ff1ad5d0b77)) + +## [18.0.0](https://github.com/puppeteer/puppeteer/compare/v17.1.3...v18.0.0) (2022-09-19) + + +### ⚠ BREAKING CHANGES + +* fix bounding box visibility conditions (#8954) + +### Features + +* add text query handler ([#8956](https://github.com/puppeteer/puppeteer/issues/8956)) ([633e7cf](https://github.com/puppeteer/puppeteer/commit/633e7cfdf99d42f420d0af381394bd1f6ac7bcd1)) + + +### Bug Fixes + +* fix bounding box visibility conditions ([#8954](https://github.com/puppeteer/puppeteer/issues/8954)) ([ac9929d](https://github.com/puppeteer/puppeteer/commit/ac9929d80f6f7d4905a39183ae235500e29b4f53)) +* suppress init errors if the target is closed ([#8947](https://github.com/puppeteer/puppeteer/issues/8947)) ([cfaaa5e](https://github.com/puppeteer/puppeteer/commit/cfaaa5e2c07e5f98baeb7de99e303aa840a351e8)) +* use win64 version of chromium when on arm64 windows ([#8927](https://github.com/puppeteer/puppeteer/issues/8927)) ([64843b8](https://github.com/puppeteer/puppeteer/commit/64843b88853210314677ab1b434729513ce615a7)) + +## [17.1.3](https://github.com/puppeteer/puppeteer/compare/v17.1.2...v17.1.3) (2022-09-08) + + +### Bug Fixes + +* FirefoxLauncher should not use BrowserFetcher in puppeteer-core ([#8920](https://github.com/puppeteer/puppeteer/issues/8920)) ([f2e8de7](https://github.com/puppeteer/puppeteer/commit/f2e8de777fc5d547778fdc6cac658add84ed4082)), closes [#8919](https://github.com/puppeteer/puppeteer/issues/8919) +* linux arm64 check on windows arm ([#8917](https://github.com/puppeteer/puppeteer/issues/8917)) ([f02b926](https://github.com/puppeteer/puppeteer/commit/f02b926245e28b5671087c051dbdbb3165696f08)), closes [#8915](https://github.com/puppeteer/puppeteer/issues/8915) + +## [17.1.2](https://github.com/puppeteer/puppeteer/compare/v17.1.1...v17.1.2) (2022-09-07) + + +### Bug Fixes + +* add missing code coverage ranges that span only a single character ([#8911](https://github.com/puppeteer/puppeteer/issues/8911)) ([0c577b9](https://github.com/puppeteer/puppeteer/commit/0c577b9bf8855dc0ccb6098cd43a25c528f6d7f5)) +* add Page.getDefaultTimeout getter ([#8903](https://github.com/puppeteer/puppeteer/issues/8903)) ([3240095](https://github.com/puppeteer/puppeteer/commit/32400954c50cbddc48468ad118c3f8a47653b9d3)), closes [#8901](https://github.com/puppeteer/puppeteer/issues/8901) +* don't detect project root for puppeteer-core ([#8907](https://github.com/puppeteer/puppeteer/issues/8907)) ([b4f5ea1](https://github.com/puppeteer/puppeteer/commit/b4f5ea1167a60c870194c70d22f5372ada5b7c4c)), closes [#8896](https://github.com/puppeteer/puppeteer/issues/8896) +* support scale for screenshot clips ([#8908](https://github.com/puppeteer/puppeteer/issues/8908)) ([260e428](https://github.com/puppeteer/puppeteer/commit/260e4282275ab1d05c86e5643e2a02c01f269a9c)), closes [#5329](https://github.com/puppeteer/puppeteer/issues/5329) +* work around a race in waitForFileChooser ([#8905](https://github.com/puppeteer/puppeteer/issues/8905)) ([053d960](https://github.com/puppeteer/puppeteer/commit/053d960fb593e514e7914d7da9af436afc39a12f)), closes [#6040](https://github.com/puppeteer/puppeteer/issues/6040) + +## [17.1.1](https://github.com/puppeteer/puppeteer/compare/v17.1.0...v17.1.1) (2022-09-05) + + +### Bug Fixes + +* restore deferred promise debugging ([#8895](https://github.com/puppeteer/puppeteer/issues/8895)) ([7b42250](https://github.com/puppeteer/puppeteer/commit/7b42250c7bb91ac873307acda493726ffc4c54a8)) + +## [17.1.0](https://github.com/puppeteer/puppeteer/compare/v17.0.0...v17.1.0) (2022-09-02) + + +### Features + +* **chromium:** roll to Chromium 106.0.5249.0 (r1036745) ([#8869](https://github.com/puppeteer/puppeteer/issues/8869)) ([6e9a47a](https://github.com/puppeteer/puppeteer/commit/6e9a47a6faa06d241dec0bcf7bcdf49370517008)) + + +### Bug Fixes + +* allow getting a frame from an elementhandle ([#8875](https://github.com/puppeteer/puppeteer/issues/8875)) ([3732757](https://github.com/puppeteer/puppeteer/commit/3732757450b4363041ccbacc3b236289a156abb0)) +* typos in documentation ([#8858](https://github.com/puppeteer/puppeteer/issues/8858)) ([8d95a9b](https://github.com/puppeteer/puppeteer/commit/8d95a9bc920b98820aa655ad4eb2d8fd9b2b893a)) +* use the timeout setting in waitForFileChooser ([#8856](https://github.com/puppeteer/puppeteer/issues/8856)) ([f477b46](https://github.com/puppeteer/puppeteer/commit/f477b46f212da9206102da695697760eea539f05)) + +## [17.0.0](https://github.com/puppeteer/puppeteer/compare/v16.2.0...v17.0.0) (2022-08-26) + + +### ⚠ BREAKING CHANGES + +* remove `root` from `WaitForSelectorOptions` (#8848) +* internalize execution context (#8844) + +### Bug Fixes + +* allow multiple navigations to happen in LifecycleWatcher ([#8826](https://github.com/puppeteer/puppeteer/issues/8826)) ([341b669](https://github.com/puppeteer/puppeteer/commit/341b669a5e45ecbb9ffb0f28c45b520660f27ad2)), closes [#8811](https://github.com/puppeteer/puppeteer/issues/8811) +* internalize execution context ([#8844](https://github.com/puppeteer/puppeteer/issues/8844)) ([2f33237](https://github.com/puppeteer/puppeteer/commit/2f33237d0443de77d58dca4454b0c9a1d2b57d03)) +* remove `root` from `WaitForSelectorOptions` ([#8848](https://github.com/puppeteer/puppeteer/issues/8848)) ([1155c8e](https://github.com/puppeteer/puppeteer/commit/1155c8eac85b176c3334cc3d98adfe7d943dfbe6)) +* remove deferred promise timeouts ([#8835](https://github.com/puppeteer/puppeteer/issues/8835)) ([202ffce](https://github.com/puppeteer/puppeteer/commit/202ffce0aa4f34dba35fbb8e7d740af16efee35f)), closes [#8832](https://github.com/puppeteer/puppeteer/issues/8832) + +## [16.2.0](https://github.com/puppeteer/puppeteer/compare/v16.1.1...v16.2.0) (2022-08-18) + + +### Features + +* add Khmer (Cambodian) language support ([#8809](https://github.com/puppeteer/puppeteer/issues/8809)) ([34f8737](https://github.com/puppeteer/puppeteer/commit/34f873721804d57a5faf3eab8ef50340c69ed180)) + + +### Bug Fixes + +* handle service workers in extensions ([#8807](https://github.com/puppeteer/puppeteer/issues/8807)) ([2a0eefb](https://github.com/puppeteer/puppeteer/commit/2a0eefb99f0ae00dacc9e768a253308c0d18a4c3)), closes [#8800](https://github.com/puppeteer/puppeteer/issues/8800) + +## [16.1.1](https://github.com/puppeteer/puppeteer/compare/v16.1.0...v16.1.1) (2022-08-16) + + +### Bug Fixes + +* custom sessions should not emit targetcreated events ([#8788](https://github.com/puppeteer/puppeteer/issues/8788)) ([3fad05d](https://github.com/puppeteer/puppeteer/commit/3fad05d333b79f41a7b58582c4ca493200bb5a79)), closes [#8787](https://github.com/puppeteer/puppeteer/issues/8787) +* deprecate `ExecutionContext` ([#8792](https://github.com/puppeteer/puppeteer/issues/8792)) ([b5da718](https://github.com/puppeteer/puppeteer/commit/b5da718e2e4a2004a36cf23cad555e1fc3b50333)) +* deprecate `root` in `WaitForSelectorOptions` ([#8795](https://github.com/puppeteer/puppeteer/issues/8795)) ([65a5ce8](https://github.com/puppeteer/puppeteer/commit/65a5ce8464c56fcc55e5ac3ed490f31311bbe32a)) +* deprecate `waitForTimeout` ([#8793](https://github.com/puppeteer/puppeteer/issues/8793)) ([8f612d5](https://github.com/puppeteer/puppeteer/commit/8f612d5ff855d48ae4b38bdaacf2a8fbda8e9ce8)) +* make sure there is a check for targets when timeout=0 ([#8765](https://github.com/puppeteer/puppeteer/issues/8765)) ([c23cdb7](https://github.com/puppeteer/puppeteer/commit/c23cdb73a7b113c1dd29f7e4a7a61326422c4080)), closes [#8763](https://github.com/puppeteer/puppeteer/issues/8763) +* resolve navigation flakiness ([#8768](https://github.com/puppeteer/puppeteer/issues/8768)) ([2580347](https://github.com/puppeteer/puppeteer/commit/2580347b50091d172b2a5591138a2e41ede072fe)), closes [#8644](https://github.com/puppeteer/puppeteer/issues/8644) +* specify Puppeteer version for Chromium 105.0.5173.0 ([#8766](https://github.com/puppeteer/puppeteer/issues/8766)) ([b5064b7](https://github.com/puppeteer/puppeteer/commit/b5064b7b8bd3bd9eb481b6807c65d9d06d23b9dd)) +* use targetFilter in puppeteer.launch ([#8774](https://github.com/puppeteer/puppeteer/issues/8774)) ([ee2540b](https://github.com/puppeteer/puppeteer/commit/ee2540baefeced44f6b336f2b979af5c3a4cb040)), closes [#8772](https://github.com/puppeteer/puppeteer/issues/8772) + +## [16.1.0](https://github.com/puppeteer/puppeteer/compare/v16.0.0...v16.1.0) (2022-08-06) + + +### Features + +* use an `xpath` query handler ([#8730](https://github.com/puppeteer/puppeteer/issues/8730)) ([5cf9b4d](https://github.com/puppeteer/puppeteer/commit/5cf9b4de8d50bd056db82bcaa23279b72c9313c5)) + + +### Bug Fixes + +* resolve target manager init if no existing targets detected ([#8748](https://github.com/puppeteer/puppeteer/issues/8748)) ([8cb5043](https://github.com/puppeteer/puppeteer/commit/8cb5043868f69cdff7f34f1cfe0c003ff09e281b)), closes [#8747](https://github.com/puppeteer/puppeteer/issues/8747) +* specify the target filter in setDiscoverTargets ([#8742](https://github.com/puppeteer/puppeteer/issues/8742)) ([49193cb](https://github.com/puppeteer/puppeteer/commit/49193cbf1c17f16f0ca59a9fd2ebf306f812f52b)) + +## [16.0.0](https://github.com/puppeteer/puppeteer/compare/v15.5.0...v16.0.0) (2022-08-02) + + +### ⚠ BREAKING CHANGES + +* With Chromium, Puppeteer will now attach to page/iframe targets immediately to allow reliable configuration of targets. + +### Features + +* add Dockerfile ([#8315](https://github.com/puppeteer/puppeteer/issues/8315)) ([936ed86](https://github.com/puppeteer/puppeteer/commit/936ed8607ec0c3798d2b22b590d0be0ad361a888)) +* detect Firefox in connect() automatically ([#8718](https://github.com/puppeteer/puppeteer/issues/8718)) ([2abd772](https://github.com/puppeteer/puppeteer/commit/2abd772c9c3d2b86deb71541eaac41aceef94356)) +* use CDP's auto-attach mechanism ([#8520](https://github.com/puppeteer/puppeteer/issues/8520)) ([2cbfdeb](https://github.com/puppeteer/puppeteer/commit/2cbfdeb0ca388a45cedfae865266230e1291bd29)) + + +### Bug Fixes + +* address flakiness in frame handling ([#8688](https://github.com/puppeteer/puppeteer/issues/8688)) ([6f81b23](https://github.com/puppeteer/puppeteer/commit/6f81b23728a511f7b89eaa2b8f850b22d6c4ab24)) +* disable AcceptCHFrame ([#8706](https://github.com/puppeteer/puppeteer/issues/8706)) ([96d9608](https://github.com/puppeteer/puppeteer/commit/96d9608d1de17877414a649a0737661894dd96c8)), closes [#8479](https://github.com/puppeteer/puppeteer/issues/8479) +* use loaderId to reduce test flakiness ([#8717](https://github.com/puppeteer/puppeteer/issues/8717)) ([d2f6db2](https://github.com/puppeteer/puppeteer/commit/d2f6db20735342bb3f419e85adbd51ed10470044)) + +## [15.5.0](https://github.com/puppeteer/puppeteer/compare/v15.4.2...v15.5.0) (2022-07-21) + + +### Features + +* **chromium:** roll to Chromium 105.0.5173.0 (r1022525) ([#8682](https://github.com/puppeteer/puppeteer/issues/8682)) ([f1b8ad3](https://github.com/puppeteer/puppeteer/commit/f1b8ad3269286800d31818ea4b6b3ee23f7437c3)) + +## [15.4.2](https://github.com/puppeteer/puppeteer/compare/v15.4.1...v15.4.2) (2022-07-21) + + +### Bug Fixes + +* taking a screenshot with null viewport should be possible ([#8680](https://github.com/puppeteer/puppeteer/issues/8680)) ([2abb9f0](https://github.com/puppeteer/puppeteer/commit/2abb9f0c144779d555ecbf337a759440d0282cba)), closes [#8673](https://github.com/puppeteer/puppeteer/issues/8673) + +## [15.4.1](https://github.com/puppeteer/puppeteer/compare/v15.4.0...v15.4.1) (2022-07-21) + + +### Bug Fixes + +* import URL ([#8670](https://github.com/puppeteer/puppeteer/issues/8670)) ([34ab5ca](https://github.com/puppeteer/puppeteer/commit/34ab5ca50353ffb6a6345a8984b724a6f42fb726)) + +## [15.4.0](https://github.com/puppeteer/puppeteer/compare/v15.3.2...v15.4.0) (2022-07-13) + + +### Features + +* expose the page getter on Frame ([#8657](https://github.com/puppeteer/puppeteer/issues/8657)) ([af08c5c](https://github.com/puppeteer/puppeteer/commit/af08c5c90380c853e8257a51298bfed4b0635779)) + + +### Bug Fixes + +* ignore *.tsbuildinfo ([#8662](https://github.com/puppeteer/puppeteer/issues/8662)) ([edcdf21](https://github.com/puppeteer/puppeteer/commit/edcdf217cefbf31aee5a2f571abac429dd81f3a0)) + +## [15.3.2](https://github.com/puppeteer/puppeteer/compare/v15.3.1...v15.3.2) (2022-07-08) + + +### Bug Fixes + +* cache dynamic imports ([#8652](https://github.com/puppeteer/puppeteer/issues/8652)) ([1de0383](https://github.com/puppeteer/puppeteer/commit/1de0383abf6be31cf06faede3e59b087a2958227)) +* expose a RemoteObject getter ([#8642](https://github.com/puppeteer/puppeteer/issues/8642)) ([d0c4291](https://github.com/puppeteer/puppeteer/commit/d0c42919956bd36ad7993a0fc1de86e886e39f62)), closes [#8639](https://github.com/puppeteer/puppeteer/issues/8639) +* **page:** fix page.#scrollIntoViewIfNeeded method ([#8631](https://github.com/puppeteer/puppeteer/issues/8631)) ([b47f066](https://github.com/puppeteer/puppeteer/commit/b47f066c2c068825e3b65cfe17b6923c77ad30b9)) + +## [15.3.1](https://github.com/puppeteer/puppeteer/compare/v15.3.0...v15.3.1) (2022-07-06) + + +### Bug Fixes + +* extends `ElementHandle` to `Node`s ([#8552](https://github.com/puppeteer/puppeteer/issues/8552)) ([5ff205d](https://github.com/puppeteer/puppeteer/commit/5ff205dc8b659eb8864b4b1862105d21dd334c8f)) + +## [15.3.0](https://github.com/puppeteer/puppeteer/compare/v15.2.0...v15.3.0) (2022-07-01) + + +### Features + +* add documentation ([#8593](https://github.com/puppeteer/puppeteer/issues/8593)) ([066f440](https://github.com/puppeteer/puppeteer/commit/066f440ba7bdc9aca9423d7205adf36f2858bd78)) + + +### Bug Fixes + +* remove unused imports ([#8613](https://github.com/puppeteer/puppeteer/issues/8613)) ([0cf4832](https://github.com/puppeteer/puppeteer/commit/0cf4832878731ffcfc84570315f326eb851d7629)) + +## [15.2.0](https://github.com/puppeteer/puppeteer/compare/v15.1.1...v15.2.0) (2022-06-29) + + +### Features + +* add fromSurface option to page.screenshot ([#8496](https://github.com/puppeteer/puppeteer/issues/8496)) ([79e1198](https://github.com/puppeteer/puppeteer/commit/79e11985ba44b72b1ad6b8cd861fe316f1945e64)) +* export public types only ([#8584](https://github.com/puppeteer/puppeteer/issues/8584)) ([7001322](https://github.com/puppeteer/puppeteer/commit/7001322cd1cf9f77ee2c370d50a6707e7aaad72d)) + + +### Bug Fixes + +* clean up tmp profile dirs when browser is closed ([#8580](https://github.com/puppeteer/puppeteer/issues/8580)) ([9787a1d](https://github.com/puppeteer/puppeteer/commit/9787a1d8df7768017b36d42327faab402695c4bb)) + +## [15.1.1](https://github.com/puppeteer/puppeteer/compare/v15.1.0...v15.1.1) (2022-06-25) + + +### Bug Fixes + +* export `ElementHandle` ([e0198a7](https://github.com/puppeteer/puppeteer/commit/e0198a79e06c8bb72dde554db0246a3db5fec4c2)) + +## [15.1.0](https://github.com/puppeteer/puppeteer/compare/v15.0.2...v15.1.0) (2022-06-24) + + +### Features + +* **chromium:** roll to Chromium 104.0.5109.0 (r1011831) ([#8569](https://github.com/puppeteer/puppeteer/issues/8569)) ([fb7d31e](https://github.com/puppeteer/puppeteer/commit/fb7d31e3698428560e1f654d33782d241192f48f)) + +## [15.0.2](https://github.com/puppeteer/puppeteer/compare/v15.0.1...v15.0.2) (2022-06-24) + + +### Bug Fixes + +* CSS coverage should work with empty stylesheets ([#8570](https://github.com/puppeteer/puppeteer/issues/8570)) ([383e855](https://github.com/puppeteer/puppeteer/commit/383e8558477fae7708734ab2160ef50f385e2983)), closes [#8535](https://github.com/puppeteer/puppeteer/issues/8535) + +## [15.0.1](https://github.com/puppeteer/puppeteer/compare/v15.0.0...v15.0.1) (2022-06-24) + + +### Bug Fixes + +* infer unioned handles ([#8562](https://github.com/puppeteer/puppeteer/issues/8562)) ([8100cbb](https://github.com/puppeteer/puppeteer/commit/8100cbb29569541541f61001983efb9a80d89890)) + +## [15.0.0](https://github.com/puppeteer/puppeteer/compare/v14.4.1...v15.0.0) (2022-06-23) + + +### ⚠ BREAKING CHANGES + +* type inference for evaluation types (#8547) + +### Features + +* add experimental `client` to `HTTPRequest` ([#8556](https://github.com/puppeteer/puppeteer/issues/8556)) ([ec79f3a](https://github.com/puppeteer/puppeteer/commit/ec79f3a58a44c9ea60a82f9cd2df4c8f19e82ab8)) +* type inference for evaluation types ([#8547](https://github.com/puppeteer/puppeteer/issues/8547)) ([26c3acb](https://github.com/puppeteer/puppeteer/commit/26c3acbb0795eb66f29479f442e156832f794f01)) + +## [14.4.1](https://github.com/puppeteer/puppeteer/compare/v14.4.0...v14.4.1) (2022-06-17) + + +### Bug Fixes + +* avoid `instanceof Object` check in `isErrorLike` ([#8527](https://github.com/puppeteer/puppeteer/issues/8527)) ([6cd5cd0](https://github.com/puppeteer/puppeteer/commit/6cd5cd043997699edca6e3458f90adc1118cf4a5)) +* export `devices`, `errors`, and more ([cba58a1](https://github.com/puppeteer/puppeteer/commit/cba58a12c4e2043f6a5acf7d4754e4a7b7f6e198)) + +## [14.4.0](https://github.com/puppeteer/puppeteer/compare/v14.3.0...v14.4.0) (2022-06-13) + + +### Features + +* export puppeteer methods ([#8493](https://github.com/puppeteer/puppeteer/issues/8493)) ([465a7c4](https://github.com/puppeteer/puppeteer/commit/465a7c405f01fcef99380ffa69d86042a1f5618f)) +* support node-like environments ([#8490](https://github.com/puppeteer/puppeteer/issues/8490)) ([f64ec20](https://github.com/puppeteer/puppeteer/commit/f64ec2051b9b2d12225abba6ffe9551da9751bf7)) + + +### Bug Fixes + +* parse empty options in \<select\> ([#8489](https://github.com/puppeteer/puppeteer/issues/8489)) ([b30f3f4](https://github.com/puppeteer/puppeteer/commit/b30f3f44cdabd9545c4661cd755b9d49e5c144cd)) +* use error-like ([#8504](https://github.com/puppeteer/puppeteer/issues/8504)) ([4d35990](https://github.com/puppeteer/puppeteer/commit/4d359906a44e4ddd5ec54a523cfd9076048d3433)) +* use OS-independent abs. path check ([#8505](https://github.com/puppeteer/puppeteer/issues/8505)) ([bfd4e68](https://github.com/puppeteer/puppeteer/commit/bfd4e68f25bec6e00fd5cbf261813f8297d362ee)) + +## [14.3.0](https://github.com/puppeteer/puppeteer/compare/v14.2.1...v14.3.0) (2022-06-07) + + +### Features + +* use absolute URL for EVALUATION_SCRIPT_URL ([#8481](https://github.com/puppeteer/puppeteer/issues/8481)) ([e142560](https://github.com/puppeteer/puppeteer/commit/e14256010d2d84d613cd3c6e7999b0705115d4bf)), closes [#8424](https://github.com/puppeteer/puppeteer/issues/8424) + + +### Bug Fixes + +* don't throw on bad access ([#8472](https://github.com/puppeteer/puppeteer/issues/8472)) ([e837866](https://github.com/puppeteer/puppeteer/commit/e8378666c671e5703aec4f52912de2aac94e1828)) +* Kill browser process when killing process group fails ([#8477](https://github.com/puppeteer/puppeteer/issues/8477)) ([7dc8e37](https://github.com/puppeteer/puppeteer/commit/7dc8e37a23d025bb2c31efb9c060c7f6e00179b4)) +* only lookup `localhost` for DNS lookups ([1b025b4](https://github.com/puppeteer/puppeteer/commit/1b025b4c8466fe64da0fa2050eaa02b7764770b1)) +* robustly check for launch executable ([#8468](https://github.com/puppeteer/puppeteer/issues/8468)) ([b54dc55](https://github.com/puppeteer/puppeteer/commit/b54dc55f7622ee2b75afd3bd9fe118dd2f144f40)) + +## [14.2.1](https://github.com/puppeteer/puppeteer/compare/v14.2.0...v14.2.1) (2022-06-02) + + +### Bug Fixes + +* use isPageTargetCallback in Browser::pages() ([#8460](https://github.com/puppeteer/puppeteer/issues/8460)) ([5c9050a](https://github.com/puppeteer/puppeteer/commit/5c9050aea0fe8d57114130fe38bd33ed2b4955d6)) + +## [14.2.0](https://github.com/puppeteer/puppeteer/compare/v14.1.2...v14.2.0) (2022-06-01) + + +### Features + +* **chromium:** roll to Chromium 103.0.5059.0 (r1002410) ([#8410](https://github.com/puppeteer/puppeteer/issues/8410)) ([54efc2c](https://github.com/puppeteer/puppeteer/commit/54efc2c949be1d6ef22f4d2630620e33d14d2597)) +* support node 18 ([#8447](https://github.com/puppeteer/puppeteer/issues/8447)) ([f2d8276](https://github.com/puppeteer/puppeteer/commit/f2d8276d6e745a7547b8ce54c3f50934bb70de0b)) +* use strict typescript ([#8401](https://github.com/puppeteer/puppeteer/issues/8401)) ([b4e751f](https://github.com/puppeteer/puppeteer/commit/b4e751f29cb6fd4c3cc41fe702de83721f0eb6dc)) + + +### Bug Fixes + +* multiple same request event listener ([#8404](https://github.com/puppeteer/puppeteer/issues/8404)) ([9211015](https://github.com/puppeteer/puppeteer/commit/92110151d9a33f26abc07bc805f4f2f3943697a0)) +* NodeNext incompatibility in package.json ([#8445](https://github.com/puppeteer/puppeteer/issues/8445)) ([c4898a7](https://github.com/puppeteer/puppeteer/commit/c4898a7a2e69681baac55366848da6688f0d8790)) +* process documentation during publishing ([#8433](https://github.com/puppeteer/puppeteer/issues/8433)) ([d111d19](https://github.com/puppeteer/puppeteer/commit/d111d19f788d88d984dcf4ad7542f59acd2f4c1e)) + +## [14.1.2](https://github.com/puppeteer/puppeteer/compare/v14.1.1...v14.1.2) (2022-05-30) + + +### Bug Fixes + +* do not use loaderId for lifecycle events ([#8395](https://github.com/puppeteer/puppeteer/issues/8395)) ([c96c915](https://github.com/puppeteer/puppeteer/commit/c96c915b535dcf414038677bd3d3ed6b980a4901)) +* fix release-please bot ([#8400](https://github.com/puppeteer/puppeteer/issues/8400)) ([5c235c7](https://github.com/puppeteer/puppeteer/commit/5c235c701fc55380f09d09ac2cf63f2c94b60e3d)) +* use strict TS in Input.ts ([#8392](https://github.com/puppeteer/puppeteer/issues/8392)) ([af92a24](https://github.com/puppeteer/puppeteer/commit/af92a24ba9fc8efea1ba41f96d87515cf760da65)) + +### [14.1.1](https://github.com/puppeteer/puppeteer/compare/v14.1.0...v14.1.1) (2022-05-19) + + +### Bug Fixes + +* kill browser process when 'taskkill' fails on Windows ([#8352](https://github.com/puppeteer/puppeteer/issues/8352)) ([dccfadb](https://github.com/puppeteer/puppeteer/commit/dccfadb90e8947cae3f33d7a209b6f5752f97b46)) +* only check loading iframe in lifecycling ([#8348](https://github.com/puppeteer/puppeteer/issues/8348)) ([7438030](https://github.com/puppeteer/puppeteer/commit/74380303ac6cc6e2d84948a10920d56e665ccebe)) +* recompile before funit and unit commands ([#8363](https://github.com/puppeteer/puppeteer/issues/8363)) ([8735b78](https://github.com/puppeteer/puppeteer/commit/8735b784ba7838c1002b521a7f9f23bb27263d03)), closes [#8362](https://github.com/puppeteer/puppeteer/issues/8362) + +## [14.1.0](https://github.com/puppeteer/puppeteer/compare/v14.0.0...v14.1.0) (2022-05-13) + + +### Features + +* add waitForXPath to ElementHandle ([#8329](https://github.com/puppeteer/puppeteer/issues/8329)) ([7eaadaf](https://github.com/puppeteer/puppeteer/commit/7eaadafe197279a7d1753e7274d2e24dfc11abdf)) +* allow handling other targets as pages internally ([#8336](https://github.com/puppeteer/puppeteer/issues/8336)) ([3b66a2c](https://github.com/puppeteer/puppeteer/commit/3b66a2c47ee36785a6a72c9afedd768fab3d040a)) + + +### Bug Fixes + +* disable AvoidUnnecessaryBeforeUnloadCheckSync to fix navigations ([#8330](https://github.com/puppeteer/puppeteer/issues/8330)) ([4854ad5](https://github.com/puppeteer/puppeteer/commit/4854ad5b15c9bdf93c06dcb758393e7cbacd7469)) +* If currentNode and root are the same, do not include them in the result ([#8332](https://github.com/puppeteer/puppeteer/issues/8332)) ([a61144d](https://github.com/puppeteer/puppeteer/commit/a61144d43780b5c32197427d7682b9b6c433f2bb)) + +## [14.0.0](https://github.com/puppeteer/puppeteer/compare/v13.7.0...v14.0.0) (2022-05-09) + + +### ⚠ BREAKING CHANGES + +* strict mode fixes for HTTPRequest/Response classes (#8297) +* Node 12 is no longer supported. + +### Features + +* add support for Apple Silicon chromium builds ([#7546](https://github.com/puppeteer/puppeteer/issues/7546)) ([baa017d](https://github.com/puppeteer/puppeteer/commit/baa017db92b1fecf2e3584d5b3161371ae60f55b)), closes [#6622](https://github.com/puppeteer/puppeteer/issues/6622) +* **chromium:** roll to Chromium 102.0.5002.0 (r991974) ([#8319](https://github.com/puppeteer/puppeteer/issues/8319)) ([be4c930](https://github.com/puppeteer/puppeteer/commit/be4c930c60164f681a966d0f8cb745f6c263fe2b)) +* support ES modules ([#8306](https://github.com/puppeteer/puppeteer/issues/8306)) ([6841bd6](https://github.com/puppeteer/puppeteer/commit/6841bd68d85e3b3952c5e7ce454ac4d23f84262d)) + + +### Bug Fixes + +* apparent typo SUPPORTER_PLATFORMS ([#8294](https://github.com/puppeteer/puppeteer/issues/8294)) ([e09287f](https://github.com/puppeteer/puppeteer/commit/e09287f4e9a1ff3c637dd165d65f221394970e2c)) +* make sure inner OOPIFs can be attached to ([#8304](https://github.com/puppeteer/puppeteer/issues/8304)) ([5539598](https://github.com/puppeteer/puppeteer/commit/553959884f4edb4deab760fa8ca38fc1c85c05c5)) +* strict mode fixes for HTTPRequest/Response classes ([#8297](https://github.com/puppeteer/puppeteer/issues/8297)) ([2804ae8](https://github.com/puppeteer/puppeteer/commit/2804ae8cdbc4c90bf942510bce656275a2d409e1)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) +* tests failing in headful ([#8273](https://github.com/puppeteer/puppeteer/issues/8273)) ([e841d7f](https://github.com/puppeteer/puppeteer/commit/e841d7f9f3f407c02dbc48e107b545b91db104e6)) + + +* drop Node 12 support ([#8299](https://github.com/puppeteer/puppeteer/issues/8299)) ([274bd6b](https://github.com/puppeteer/puppeteer/commit/274bd6b3b98c305ed014909d8053e4c54187971b)) + +## [13.7.0](https://github.com/puppeteer/puppeteer/compare/v13.6.0...v13.7.0) (2022-04-28) + + +### Features + +* add `back` and `forward` mouse buttons ([#8284](https://github.com/puppeteer/puppeteer/issues/8284)) ([7a51bff](https://github.com/puppeteer/puppeteer/commit/7a51bff47f6436fc29d0df7eb74f12f69102ca5b)) +* support chrome headless mode ([#8260](https://github.com/puppeteer/puppeteer/issues/8260)) ([1308d9a](https://github.com/puppeteer/puppeteer/commit/1308d9aa6a5920b20da02dca8db03c63e43c8b84)) + + +### Bug Fixes + +* doc typo ([#8263](https://github.com/puppeteer/puppeteer/issues/8263)) ([952a2ae](https://github.com/puppeteer/puppeteer/commit/952a2ae0bc4f059f8e8b4d1de809d0a486a74551)) +* use different test names for browser specific tests in launcher.spec.ts ([#8250](https://github.com/puppeteer/puppeteer/issues/8250)) ([c6cf1a9](https://github.com/puppeteer/puppeteer/commit/c6cf1a9f27621c8a619cfbdc9d0821541768ac94)) + +## [13.6.0](https://github.com/puppeteer/puppeteer/compare/v13.5.2...v13.6.0) (2022-04-19) + + +### Features + +* **chromium:** roll to Chromium 101.0.4950.0 (r982053) ([#8213](https://github.com/puppeteer/puppeteer/issues/8213)) ([ec74bd8](https://github.com/puppeteer/puppeteer/commit/ec74bd811d9b7fbaf600068e86f13a63d7b0bc6f)) +* respond multiple headers with same key ([#8183](https://github.com/puppeteer/puppeteer/issues/8183)) ([c1dcd85](https://github.com/puppeteer/puppeteer/commit/c1dcd857e3bc17769f02474a41bbedee01f471dc)) + + +### Bug Fixes + +* also kill Firefox when temporary profile is used ([#8233](https://github.com/puppeteer/puppeteer/issues/8233)) ([b6504d7](https://github.com/puppeteer/puppeteer/commit/b6504d7186336a2fc0b41c3878c843b7409ba5fb)) +* consider existing frames when waiting for a frame ([#8200](https://github.com/puppeteer/puppeteer/issues/8200)) ([0955225](https://github.com/puppeteer/puppeteer/commit/0955225b51421663288523a3dfb63103b51775b4)) +* disable bfcache in the launcher ([#8196](https://github.com/puppeteer/puppeteer/issues/8196)) ([9ac7318](https://github.com/puppeteer/puppeteer/commit/9ac7318506ac858b3465e9b4ede8ad75fbbcee11)), closes [#8182](https://github.com/puppeteer/puppeteer/issues/8182) +* enable page.spec event handler test for firefox ([#8214](https://github.com/puppeteer/puppeteer/issues/8214)) ([2b45027](https://github.com/puppeteer/puppeteer/commit/2b45027d256f85f21a0c824183696b237e00ad33)) +* forget queuedEventGroup when emitting response in responseReceivedExtraInfo ([#8234](https://github.com/puppeteer/puppeteer/issues/8234)) ([#8239](https://github.com/puppeteer/puppeteer/issues/8239)) ([91a8e73](https://github.com/puppeteer/puppeteer/commit/91a8e73b1196e4128b1e7c25e08080f2faaf3cf7)) +* forget request will be sent from the _requestWillBeSentMap list. ([#8226](https://github.com/puppeteer/puppeteer/issues/8226)) ([4b786c9](https://github.com/puppeteer/puppeteer/commit/4b786c904cbfe3f059322292f3b788b8a5ebd9bf)) +* ignore favicon requests in page.spec event handler tests ([#8208](https://github.com/puppeteer/puppeteer/issues/8208)) ([04e5c88](https://github.com/puppeteer/puppeteer/commit/04e5c889973432c6163a8539cdec23c0e8726bff)) +* **network.spec.ts:** typo in the word should ([#8223](https://github.com/puppeteer/puppeteer/issues/8223)) ([e93faad](https://github.com/puppeteer/puppeteer/commit/e93faadc21b7fcb1e03b69c451c28b769f9cde51)) + +### [13.5.2](https://github.com/puppeteer/puppeteer/compare/v13.5.1...v13.5.2) (2022-03-31) + + +### Bug Fixes + +* chromium downloading hung at 99% ([#8169](https://github.com/puppeteer/puppeteer/issues/8169)) ([8f13470](https://github.com/puppeteer/puppeteer/commit/8f13470af06045857f32496f03e77b14f3ecff98)) +* get extra headers from Fetch.requestPaused event ([#8162](https://github.com/puppeteer/puppeteer/issues/8162)) ([37ede68](https://github.com/puppeteer/puppeteer/commit/37ede6877017a8dc6c946a3dff4ec6d79c3ebc59)) + +### [13.5.1](https://github.com/puppeteer/puppeteer/compare/v13.5.0...v13.5.1) (2022-03-09) + + +### Bug Fixes + +* waitForNavigation in OOPIFs ([#8117](https://github.com/puppeteer/puppeteer/issues/8117)) ([34775e5](https://github.com/puppeteer/puppeteer/commit/34775e58316be49d8bc5a13209a1f570bc66b448)) + +## [13.5.0](https://github.com/puppeteer/puppeteer/compare/v13.4.1...v13.5.0) (2022-03-07) + + +### Features + +* **chromium:** roll to Chromium 100.0.4889.0 (r970485) ([#8108](https://github.com/puppeteer/puppeteer/issues/8108)) ([d12f427](https://github.com/puppeteer/puppeteer/commit/d12f42754f7013b5ec0a2198cf2d9cf945d3cb38)) + + +### Bug Fixes + +* Inherit browser-level proxy settings from incognito context ([#7770](https://github.com/puppeteer/puppeteer/issues/7770)) ([3feca32](https://github.com/puppeteer/puppeteer/commit/3feca325a9472ee36f7e866ebe375c7f083e0e36)) +* **page:** page.createIsolatedWorld error catching has been added ([#7848](https://github.com/puppeteer/puppeteer/issues/7848)) ([309e8b8](https://github.com/puppeteer/puppeteer/commit/309e8b80da0519327bc37b44a3ebb6f2e2d357a7)) +* **tests:** ensure all tests honour BINARY envvar ([#8092](https://github.com/puppeteer/puppeteer/issues/8092)) ([3b8b9ad](https://github.com/puppeteer/puppeteer/commit/3b8b9adde5d18892af96329b6f9303979f9c04f5)) + +### [13.4.1](https://github.com/puppeteer/puppeteer/compare/v13.4.0...v13.4.1) (2022-03-01) + + +### Bug Fixes + +* regression in --user-data-dir handling ([#8060](https://github.com/puppeteer/puppeteer/issues/8060)) ([85decdc](https://github.com/puppeteer/puppeteer/commit/85decdc28d7d2128e6d2946a72f4d99dd5dbb48a)) + +## [13.4.0](https://github.com/puppeteer/puppeteer/compare/v13.3.2...v13.4.0) (2022-02-22) + + +### Features + +* add support for async waitForTarget ([#7885](https://github.com/puppeteer/puppeteer/issues/7885)) ([dbf0639](https://github.com/puppeteer/puppeteer/commit/dbf0639822d0b2736993de52c0bfe1dbf4e58f25)) +* export `Frame._client` through getter ([#8041](https://github.com/puppeteer/puppeteer/issues/8041)) ([e9278fc](https://github.com/puppeteer/puppeteer/commit/e9278fcfcffe2558de63ce7542483445bcb6e74f)) +* **HTTPResponse:** expose timing information ([#8025](https://github.com/puppeteer/puppeteer/issues/8025)) ([30b3d49](https://github.com/puppeteer/puppeteer/commit/30b3d49b0de46d812b7485e708174a07c73dbdd0)) + + +### Bug Fixes + +* change kill to signal the whole process group to terminate ([#6859](https://github.com/puppeteer/puppeteer/issues/6859)) ([0eb9c78](https://github.com/puppeteer/puppeteer/commit/0eb9c7861717ebba7012c03e76b7a46063e4e5dd)) +* element screenshot issue in headful mode ([#8018](https://github.com/puppeteer/puppeteer/issues/8018)) ([5346e70](https://github.com/puppeteer/puppeteer/commit/5346e70ffc15b33c1949657cf1b465f1acc5d84d)), closes [#7999](https://github.com/puppeteer/puppeteer/issues/7999) +* ensure dom binding is not called after detach ([#8024](https://github.com/puppeteer/puppeteer/issues/8024)) ([5c308b0](https://github.com/puppeteer/puppeteer/commit/5c308b0704123736ddb085f97596c201ea18cf4a)), closes [#7814](https://github.com/puppeteer/puppeteer/issues/7814) +* use both __dirname and require.resolve to support different bundlers ([#8046](https://github.com/puppeteer/puppeteer/issues/8046)) ([e6a6295](https://github.com/puppeteer/puppeteer/commit/e6a6295d9a7480bb59ee58a2cc7785171fa0fa2c)), closes [#8044](https://github.com/puppeteer/puppeteer/issues/8044) + +### [13.3.2](https://github.com/puppeteer/puppeteer/compare/v13.3.1...v13.3.2) (2022-02-14) + + +### Bug Fixes + +* always use ENV executable path when present ([#7985](https://github.com/puppeteer/puppeteer/issues/7985)) ([6d6ea9b](https://github.com/puppeteer/puppeteer/commit/6d6ea9bf59daa3fb851b3da8baa27887e0aa2c28)) +* use require.resolve instead of __dirname ([#8003](https://github.com/puppeteer/puppeteer/issues/8003)) ([bbb186d](https://github.com/puppeteer/puppeteer/commit/bbb186d88cb99e4914299c983c822fa41a80f356)) + +### [13.3.1](https://github.com/puppeteer/puppeteer/compare/v13.3.0...v13.3.1) (2022-02-10) + + +### Bug Fixes + +* **puppeteer:** revert: esm modules ([#7986](https://github.com/puppeteer/puppeteer/issues/7986)) ([179eded](https://github.com/puppeteer/puppeteer/commit/179ededa1400c35c1f2edc015548e0f2a1bcee14)) + +## [13.3.0](https://github.com/puppeteer/puppeteer/compare/v13.2.0...v13.3.0) (2022-02-09) + + +### Features + +* **puppeteer:** export esm modules in package.json ([#7964](https://github.com/puppeteer/puppeteer/issues/7964)) ([523b487](https://github.com/puppeteer/puppeteer/commit/523b487e8802824cecff86d256b4f7dbc4c47c8a)) + +## [13.2.0](https://github.com/puppeteer/puppeteer/compare/v13.1.3...v13.2.0) (2022-02-07) + + +### Features + +* add more models to DeviceDescriptors ([#7904](https://github.com/puppeteer/puppeteer/issues/7904)) ([6a655cb](https://github.com/puppeteer/puppeteer/commit/6a655cb647e12eaf1055be0b298908d83bebac25)) +* **chromium:** roll to Chromium 99.0.4844.16 (r961656) ([#7960](https://github.com/puppeteer/puppeteer/issues/7960)) ([96c3f94](https://github.com/puppeteer/puppeteer/commit/96c3f943b2f6e26bd871ecfcce71b6a33e214ebf)) + + +### Bug Fixes + +* make projectRoot optional in Puppeteer and launchers ([#7967](https://github.com/puppeteer/puppeteer/issues/7967)) ([9afdc63](https://github.com/puppeteer/puppeteer/commit/9afdc6300b80f01091dc4cb42d4ebe952c7d60f0)) +* migrate more files to strict-mode TypeScript ([#7950](https://github.com/puppeteer/puppeteer/issues/7950)) ([aaac8d9](https://github.com/puppeteer/puppeteer/commit/aaac8d9c44327a2c503ffd6c97b7f21e8010c3e4)) +* typos in documentation ([#7968](https://github.com/puppeteer/puppeteer/issues/7968)) ([41ab4e9](https://github.com/puppeteer/puppeteer/commit/41ab4e9127df64baa6c43ecde2f7ddd702ba7b0c)) + +### [13.1.3](https://github.com/puppeteer/puppeteer/compare/v13.1.2...v13.1.3) (2022-01-31) + + +### Bug Fixes + +* issue with reading versions.js in doclint ([#7940](https://github.com/puppeteer/puppeteer/issues/7940)) ([06ba963](https://github.com/puppeteer/puppeteer/commit/06ba9632a4c63859244068d32c312817d90daf63)) +* make more files work in strict-mode TypeScript ([#7936](https://github.com/puppeteer/puppeteer/issues/7936)) ([0636513](https://github.com/puppeteer/puppeteer/commit/0636513e34046f4d40b5e88beb2b18b16dab80aa)) +* page.pdf producing an invalid pdf ([#7868](https://github.com/puppeteer/puppeteer/issues/7868)) ([afea509](https://github.com/puppeteer/puppeteer/commit/afea509544fb99bfffe5b0bebe6f3575c53802f0)), closes [#7757](https://github.com/puppeteer/puppeteer/issues/7757) + +### [13.1.2](https://github.com/puppeteer/puppeteer/compare/v13.1.1...v13.1.2) (2022-01-25) + + +### Bug Fixes + +* **package.json:** update node-fetch package ([#7924](https://github.com/puppeteer/puppeteer/issues/7924)) ([e4c48d3](https://github.com/puppeteer/puppeteer/commit/e4c48d3b8c2a812752094ed8163e4f2f32c4b6cb)) +* types in Browser.ts to be compatible with strict mode Typescript ([#7918](https://github.com/puppeteer/puppeteer/issues/7918)) ([a8ec0aa](https://github.com/puppeteer/puppeteer/commit/a8ec0aadc9c90d224d568d9e418d14261e6e85b1)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) +* types in Connection.ts to be compatible with strict mode Typescript ([#7919](https://github.com/puppeteer/puppeteer/issues/7919)) ([d80d602](https://github.com/puppeteer/puppeteer/commit/d80d6027ea8e1b7fcdaf045398629cf8e6512658)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) + +### [13.1.1](https://github.com/puppeteer/puppeteer/compare/v13.1.0...v13.1.1) (2022-01-18) + + +### Bug Fixes + +* use content box for OOPIF offset calculations ([#7911](https://github.com/puppeteer/puppeteer/issues/7911)) ([344feb5](https://github.com/puppeteer/puppeteer/commit/344feb53c28ce018a4c600d408468f6d9d741eee)) + +## [13.1.0](https://github.com/puppeteer/puppeteer/compare/v13.0.1...v13.1.0) (2022-01-17) + + +### Features + +* **chromium:** roll to Chromium 98.0.4758.0 (r950341) ([#7907](https://github.com/puppeteer/puppeteer/issues/7907)) ([a55c86f](https://github.com/puppeteer/puppeteer/commit/a55c86fac504b5e89ba23735fb3a1b1d54a4e1e5)) + + +### Bug Fixes + +* apply OOPIF offsets to bounding box and box model calls ([#7906](https://github.com/puppeteer/puppeteer/issues/7906)) ([a566263](https://github.com/puppeteer/puppeteer/commit/a566263ba28e58ff648bffbdb628606f75d5876f)) +* correctly compute clickable points for elements inside OOPIFs ([#7900](https://github.com/puppeteer/puppeteer/issues/7900)) ([486bbe0](https://github.com/puppeteer/puppeteer/commit/486bbe010d5ee5c446d9e8daf61a080232379c3f)), closes [#7849](https://github.com/puppeteer/puppeteer/issues/7849) +* error for pre-existing OOPIFs ([#7899](https://github.com/puppeteer/puppeteer/issues/7899)) ([d7937b8](https://github.com/puppeteer/puppeteer/commit/d7937b806d331bf16c2016aaf16e932b1334eac8)), closes [#7844](https://github.com/puppeteer/puppeteer/issues/7844) [#7896](https://github.com/puppeteer/puppeteer/issues/7896) + +### [13.0.1](https://github.com/puppeteer/puppeteer/compare/v13.0.0...v13.0.1) (2021-12-22) + + +### Bug Fixes + +* disable a test failing on Firefox ([#7846](https://github.com/puppeteer/puppeteer/issues/7846)) ([36207c5](https://github.com/puppeteer/puppeteer/commit/36207c5efe8ca21f4b3fc5b00212700326a701d2)) +* make sure ElementHandle.waitForSelector is evaluated in the right context ([#7843](https://github.com/puppeteer/puppeteer/issues/7843)) ([8d8e874](https://github.com/puppeteer/puppeteer/commit/8d8e874b072b17fc763f33d08e51c046b7435244)) +* predicate arguments for waitForFunction ([#7845](https://github.com/puppeteer/puppeteer/issues/7845)) ([1c44551](https://github.com/puppeteer/puppeteer/commit/1c44551f1b5bb19455b4a1eb7061715717ec880e)), closes [#7836](https://github.com/puppeteer/puppeteer/issues/7836) + +## [13.0.0](https://github.com/puppeteer/puppeteer/compare/v12.0.1...v13.0.0) (2021-12-10) + + +### ⚠ BREAKING CHANGES + +* typo in 'already-handled' constant of the request interception API (#7813) + +### Features + +* expose HTTPRequest intercept resolution state and clarify docs ([#7796](https://github.com/puppeteer/puppeteer/issues/7796)) ([dc23b75](https://github.com/puppeteer/puppeteer/commit/dc23b7535cb958c00d1eecfe85b4ee26e52e2e39)) +* implement Element.waitForSelector ([#7825](https://github.com/puppeteer/puppeteer/issues/7825)) ([c034294](https://github.com/puppeteer/puppeteer/commit/c03429444d05b39549489ad3da67d93b2be59f51)) + + +### Bug Fixes + +* handle multiple/duplicate Fetch.requestPaused events ([#7802](https://github.com/puppeteer/puppeteer/issues/7802)) ([636b086](https://github.com/puppeteer/puppeteer/commit/636b0863a169da132e333eb53b17eb2601daabe6)), closes [#7475](https://github.com/puppeteer/puppeteer/issues/7475) [#6696](https://github.com/puppeteer/puppeteer/issues/6696) [#7225](https://github.com/puppeteer/puppeteer/issues/7225) +* revert "feat(typescript): allow using puppeteer without dom lib" ([02c9af6](https://github.com/puppeteer/puppeteer/commit/02c9af62d64060a83f53368640f343ae2e30e38a)), closes [#6998](https://github.com/puppeteer/puppeteer/issues/6998) +* typo in 'already-handled' constant of the request interception API ([#7813](https://github.com/puppeteer/puppeteer/issues/7813)) ([8242422](https://github.com/puppeteer/puppeteer/commit/824242246de9e158aacb85f71350a79cb386ed92)), closes [#7745](https://github.com/puppeteer/puppeteer/issues/7745) [#7747](https://github.com/puppeteer/puppeteer/issues/7747) [#7780](https://github.com/puppeteer/puppeteer/issues/7780) + +### [12.0.1](https://github.com/puppeteer/puppeteer/compare/v12.0.0...v12.0.1) (2021-11-29) + + +### Bug Fixes + +* handle extraInfo events even if event.hasExtraInfo === false ([#7808](https://github.com/puppeteer/puppeteer/issues/7808)) ([6ee2feb](https://github.com/puppeteer/puppeteer/commit/6ee2feb1eafdd399f0af50cdc4517f21bcb55121)), closes [#7805](https://github.com/puppeteer/puppeteer/issues/7805) + +## [12.0.0](https://github.com/puppeteer/puppeteer/compare/v11.0.0...v12.0.0) (2021-11-26) + + +### ⚠ BREAKING CHANGES + +* **chromium:** roll to Chromium 97.0.4692.0 (r938248) + +### Features + +* **chromium:** roll to Chromium 97.0.4692.0 (r938248) ([ac162c5](https://github.com/puppeteer/puppeteer/commit/ac162c561ee43dd69eff38e1b354a41bb42c9eba)), closes [#7458](https://github.com/puppeteer/puppeteer/issues/7458) +* support for custom user data (profile) directory for Firefox ([#7684](https://github.com/puppeteer/puppeteer/issues/7684)) ([790c7a0](https://github.com/puppeteer/puppeteer/commit/790c7a0eb92291efebaa37e80c72f5cb5f46bbdb)) + + +### Bug Fixes + +* **ariaqueryhandler:** allow single quotes in aria attribute selector ([#7750](https://github.com/puppeteer/puppeteer/issues/7750)) ([b0319ec](https://github.com/puppeteer/puppeteer/commit/b0319ecc89f8ea3d31ab9aee5e1cd33d2a4e62be)), closes [#7721](https://github.com/puppeteer/puppeteer/issues/7721) +* clearer jsdoc for behavior of `headless` when `devtools` is true ([#7748](https://github.com/puppeteer/puppeteer/issues/7748)) ([9f9b4ed](https://github.com/puppeteer/puppeteer/commit/9f9b4ed72ab0bb43d002a0024122d6f5eab231aa)) +* null check for frame in FrameManager ([#7773](https://github.com/puppeteer/puppeteer/issues/7773)) ([23ee295](https://github.com/puppeteer/puppeteer/commit/23ee295f348d114617f2a86d0bb792936f413ac5)), closes [#7749](https://github.com/puppeteer/puppeteer/issues/7749) +* only kill the process when there is no browser instance available ([#7762](https://github.com/puppeteer/puppeteer/issues/7762)) ([51e6169](https://github.com/puppeteer/puppeteer/commit/51e61696c1c20cc09bd4fc068ae1dfa259c41745)), closes [#7668](https://github.com/puppeteer/puppeteer/issues/7668) +* parse statusText from the extraInfo event ([#7798](https://github.com/puppeteer/puppeteer/issues/7798)) ([a26b12b](https://github.com/puppeteer/puppeteer/commit/a26b12b7c775c36271cd4c98e39bbd59f4356320)), closes [#7458](https://github.com/puppeteer/puppeteer/issues/7458) +* try to remove the temporary user data directory after the process has been killed ([#7761](https://github.com/puppeteer/puppeteer/issues/7761)) ([fc94a28](https://github.com/puppeteer/puppeteer/commit/fc94a28778cfdb3cb8bcd882af3ebcdacf85c94e)) + +## [11.0.0](https://github.com/puppeteer/puppeteer/compare/v10.4.0...v11.0.0) (2021-11-02) + + +### ⚠ BREAKING CHANGES + +* **oop iframes:** integrate OOP iframes with the frame manager (#7556) + +### Features + +* improve error message for response.buffer() ([#7669](https://github.com/puppeteer/puppeteer/issues/7669)) ([03c9ecc](https://github.com/puppeteer/puppeteer/commit/03c9ecca400a02684cd60229550dbad1190a5b6e)) +* **oop iframes:** integrate OOP iframes with the frame manager ([#7556](https://github.com/puppeteer/puppeteer/issues/7556)) ([4d9dc8c](https://github.com/puppeteer/puppeteer/commit/4d9dc8c0e613f22d4cdf237e8bd0b0da3c588edb)), closes [#2548](https://github.com/puppeteer/puppeteer/issues/2548) +* add custom debugging port option ([#4993](https://github.com/puppeteer/puppeteer/issues/4993)) ([26145e9](https://github.com/puppeteer/puppeteer/commit/26145e9a24af7caed6ece61031f2cafa6abd505f)) +* add initiator to HTTPRequest ([#7614](https://github.com/puppeteer/puppeteer/issues/7614)) ([a271145](https://github.com/puppeteer/puppeteer/commit/a271145b0663ef9de1903dd0eb9fd5366465bed7)) +* allow to customize tmpdir ([#7243](https://github.com/puppeteer/puppeteer/issues/7243)) ([b1f6e86](https://github.com/puppeteer/puppeteer/commit/b1f6e8692b0bc7e8551b2a78169c830cd80a7acb)) +* handle unhandled promise rejections in tests ([#7722](https://github.com/puppeteer/puppeteer/issues/7722)) ([07febca](https://github.com/puppeteer/puppeteer/commit/07febca04b391893cfc872250e4391da142d4fe2)) + + +### Bug Fixes + +* add support for relative install paths to BrowserFetcher ([#7613](https://github.com/puppeteer/puppeteer/issues/7613)) ([eebf452](https://github.com/puppeteer/puppeteer/commit/eebf452d38b79bb2ea1a1ba84c3d2ea6f2f9f899)), closes [#7592](https://github.com/puppeteer/puppeteer/issues/7592) +* add webp to screenshot quality option allow list ([#7631](https://github.com/puppeteer/puppeteer/issues/7631)) ([b20c2bf](https://github.com/puppeteer/puppeteer/commit/b20c2bfa24cbdd4a1b9cefca2e0a9407e442baf5)) +* prevent Target closed errors on streams ([#7728](https://github.com/puppeteer/puppeteer/issues/7728)) ([5b792de](https://github.com/puppeteer/puppeteer/commit/5b792de7a97611441777d1ac99cb95516301d7dc)) +* request an animation frame to fix flaky clickablePoint test ([#7587](https://github.com/puppeteer/puppeteer/issues/7587)) ([7341d9f](https://github.com/puppeteer/puppeteer/commit/7341d9fadd1466a5b2f2bde8631f3b02cf9a7d8a)) +* setup husky properly ([#7727](https://github.com/puppeteer/puppeteer/issues/7727)) ([8b712e7](https://github.com/puppeteer/puppeteer/commit/8b712e7b642b58193437f26d4e104a9e412f388d)), closes [#7726](https://github.com/puppeteer/puppeteer/issues/7726) +* updated troubleshooting.md to meet latest dependencies changes ([#7656](https://github.com/puppeteer/puppeteer/issues/7656)) ([edb0197](https://github.com/puppeteer/puppeteer/commit/edb01972b9606d8b05b979a588eda0d622315981)) +* **launcher:** launcher.launch() should pass 'timeout' option [#5180](https://github.com/puppeteer/puppeteer/issues/5180) ([#7596](https://github.com/puppeteer/puppeteer/issues/7596)) ([113489d](https://github.com/puppeteer/puppeteer/commit/113489d3b58e2907374a4e6e5133bf46630695d1)) +* **page:** fallback to default in exposeFunction when using imported module ([#6365](https://github.com/puppeteer/puppeteer/issues/6365)) ([44c9ec6](https://github.com/puppeteer/puppeteer/commit/44c9ec67c57dccf3e186c86f14f3a8da9a8eb971)) +* **page:** fix page.off method for request event ([#7624](https://github.com/puppeteer/puppeteer/issues/7624)) ([d0cb943](https://github.com/puppeteer/puppeteer/commit/d0cb9436a302418086f6763e0e58ae3732a20b62)), closes [#7572](https://github.com/puppeteer/puppeteer/issues/7572) + +## [10.4.0](https://github.com/puppeteer/puppeteer/compare/v10.2.0...v10.4.0) (2021-09-21) + + +### Features + +* add webp to screenshot options ([#7565](https://github.com/puppeteer/puppeteer/issues/7565)) ([43a9268](https://github.com/puppeteer/puppeteer/commit/43a926832505a57922016907a264165676424557)) +* **page:** expose page.client() ([#7582](https://github.com/puppeteer/puppeteer/issues/7582)) ([99ca842](https://github.com/puppeteer/puppeteer/commit/99ca842124a1edef5e66426621885141a9feaca5)) +* **page:** mark page.client() as internal ([#7585](https://github.com/puppeteer/puppeteer/issues/7585)) ([8451951](https://github.com/puppeteer/puppeteer/commit/84519514831f304f9076ca235fe474f797616b2c)) +* add ability to specify offsets for JSHandle.click ([#7573](https://github.com/puppeteer/puppeteer/issues/7573)) ([2b5c001](https://github.com/puppeteer/puppeteer/commit/2b5c0019dc3744196c5858edeaa901dff9973ef5)) +* add durableStorage to allowed permissions ([#5295](https://github.com/puppeteer/puppeteer/issues/5295)) ([eda5171](https://github.com/puppeteer/puppeteer/commit/eda51712790b9260626dc53cfb58a72805c45582)) +* add id option to addScriptTag ([#5477](https://github.com/puppeteer/puppeteer/issues/5477)) ([300be5d](https://github.com/puppeteer/puppeteer/commit/300be5d167b6e7e532e725fdb86966081a5d0093)) +* add more Android models to DeviceDescriptors ([#7210](https://github.com/puppeteer/puppeteer/issues/7210)) ([b5020dc](https://github.com/puppeteer/puppeteer/commit/b5020dc04121b265c77662237dfb177d6de06053)), closes [/github.com/aerokube/moon-deploy/blob/master/moon-local.yaml#L199](https://github.com/puppeteer//github.com/aerokube/moon-deploy/blob/master/moon-local.yaml/issues/L199) +* add proxy and bypass list parameters to createIncognitoBrowserContext ([#7516](https://github.com/puppeteer/puppeteer/issues/7516)) ([8e45a1c](https://github.com/puppeteer/puppeteer/commit/8e45a1c882207cc36e87be2a917b661eb841c4bf)), closes [#678](https://github.com/puppeteer/puppeteer/issues/678) +* add threshold to Page.isIntersectingViewport ([#6497](https://github.com/puppeteer/puppeteer/issues/6497)) ([54c4318](https://github.com/puppeteer/puppeteer/commit/54c43180161c3c512e4698e7f2e85ce3c6f0ab50)) +* add unit test support for bisect ([#7553](https://github.com/puppeteer/puppeteer/issues/7553)) ([a0b1f6b](https://github.com/puppeteer/puppeteer/commit/a0b1f6b401abae2fbc5a8987061644adfaa7b482)) +* add User-Agent with Puppeteer version to WebSocket request ([#5614](https://github.com/puppeteer/puppeteer/issues/5614)) ([6a2bf0a](https://github.com/puppeteer/puppeteer/commit/6a2bf0aabaa4df72c7838f5a6cd742e8f9c72be6)) +* extend husky checks ([#7574](https://github.com/puppeteer/puppeteer/issues/7574)) ([7316086](https://github.com/puppeteer/puppeteer/commit/73160869417275200be19bd37372b6218dbc5f63)) +* **api:** implement `Page.waitForNetworkIdle()` ([#5140](https://github.com/puppeteer/puppeteer/issues/5140)) ([3c6029c](https://github.com/puppeteer/puppeteer/commit/3c6029c702291ca7ef637b66e78d72e03156fe58)) +* **coverage:** option for raw V8 script coverage ([#6454](https://github.com/puppeteer/puppeteer/issues/6454)) ([cb4470a](https://github.com/puppeteer/puppeteer/commit/cb4470a6d9b0a7f73836458bb3d5779eb85ac5f2)) +* support timeout for page.pdf() call ([#7508](https://github.com/puppeteer/puppeteer/issues/7508)) ([f90af66](https://github.com/puppeteer/puppeteer/commit/f90af6639d801e764bdb479b9543b7f8f2b926df)) +* **typescript:** allow using puppeteer without dom lib ([#6998](https://github.com/puppeteer/puppeteer/issues/6998)) ([723052d](https://github.com/puppeteer/puppeteer/commit/723052d5bb3c3d1d3908508467512bea4d8fdc80)), closes [#6989](https://github.com/puppeteer/puppeteer/issues/6989) + + +### Bug Fixes + +* **docs:** deploy includes website documentation ([#7469](https://github.com/puppeteer/puppeteer/issues/7469)) ([6fde41c](https://github.com/puppeteer/puppeteer/commit/6fde41c6b6657986df1bbce3f2e0f7aa499f2be4)) +* **docs:** names in version 9.1.1 ([#7517](https://github.com/puppeteer/puppeteer/issues/7517)) ([44b22bb](https://github.com/puppeteer/puppeteer/commit/44b22bbc2629e3c75c1494b299a66790b371fb0a)) +* **frame:** fix Frame.waitFor's XPath pattern detection ([#5184](https://github.com/puppeteer/puppeteer/issues/5184)) ([caa2b73](https://github.com/puppeteer/puppeteer/commit/caa2b732fe58f32ec03f2a9fa8568f20188203c5)) +* **install:** respect environment proxy config when downloading Firef… ([#6577](https://github.com/puppeteer/puppeteer/issues/6577)) ([9399c97](https://github.com/puppeteer/puppeteer/commit/9399c9786fba4e45e1c5485ddbb197d2d4f1735f)), closes [#6573](https://github.com/puppeteer/puppeteer/issues/6573) +* added names in V9.1.1 ([#7547](https://github.com/puppeteer/puppeteer/issues/7547)) ([d132b8b](https://github.com/puppeteer/puppeteer/commit/d132b8b041696e6d5b9a99d0be1acf1cf943efef)) +* **test:** tweak waitForNetworkIdle delay in test between downloads ([#7564](https://github.com/puppeteer/puppeteer/issues/7564)) ([a21b737](https://github.com/puppeteer/puppeteer/commit/a21b7376e7feaf23066d67948d52480516f42496)) +* **types:** allow evaluate functions to take a readonly array as an argument ([#7072](https://github.com/puppeteer/puppeteer/issues/7072)) ([491614c](https://github.com/puppeteer/puppeteer/commit/491614c7f8cfa50b902d0275064e611c2a48c3b2)) +* update firefox prefs documentation link ([#7539](https://github.com/puppeteer/puppeteer/issues/7539)) ([2aec355](https://github.com/puppeteer/puppeteer/commit/2aec35553bc6e0305f40837bb3665ddbd02aa889)) +* use non-deprecated tracing categories api ([#7413](https://github.com/puppeteer/puppeteer/issues/7413)) ([040a0e5](https://github.com/puppeteer/puppeteer/commit/040a0e561b4f623f7929130b90be129f94ebb642)) + +## [10.2.0](https://github.com/puppeteer/puppeteer/compare/v10.1.0...v10.2.0) (2021-08-04) + + +### Features + +* **api:** make `page.isDragInterceptionEnabled` a method ([#7419](https://github.com/puppeteer/puppeteer/issues/7419)) ([dd470c7](https://github.com/puppeteer/puppeteer/commit/dd470c7a226a8422a938a7b0fffa58ffc6b78512)), closes [#7150](https://github.com/puppeteer/puppeteer/issues/7150) +* **chromium:** roll to Chromium 93.0.4577.0 (r901912) ([#7387](https://github.com/puppeteer/puppeteer/issues/7387)) ([e10faad](https://github.com/puppeteer/puppeteer/commit/e10faad4f239b1120491bb54fcba0216acd3a646)) +* add channel parameter for puppeteer.launch ([#7389](https://github.com/puppeteer/puppeteer/issues/7389)) ([d70f60e](https://github.com/puppeteer/puppeteer/commit/d70f60e0619b8659d191fa492e3db4bc221ae982)) +* add cooperative request intercepts ([#6735](https://github.com/puppeteer/puppeteer/issues/6735)) ([b5e6474](https://github.com/puppeteer/puppeteer/commit/b5e6474374ae6a88fc73cdb1a9906764c2ac5d70)) +* add support for useragentdata ([#7378](https://github.com/puppeteer/puppeteer/issues/7378)) ([7200b1a](https://github.com/puppeteer/puppeteer/commit/7200b1a6fb9dfdfb65d50f0000339333e71b1b2a)) + + +### Bug Fixes + +* **browser-runner:** reject promise on error ([#7338](https://github.com/puppeteer/puppeteer/issues/7338)) ([5eb20e2](https://github.com/puppeteer/puppeteer/commit/5eb20e29a21ea0e0368fa8937ef38f7c7693ab34)) +* add script to remove html comments from docs markdown ([#7394](https://github.com/puppeteer/puppeteer/issues/7394)) ([ea3df80](https://github.com/puppeteer/puppeteer/commit/ea3df80ed136a03d7698d2319106af5df8d48b58)) + +## [10.1.0](https://github.com/puppeteer/puppeteer/compare/v10.0.0...v10.1.0) (2021-06-29) + + +### Features + +* add a streaming version for page.pdf ([e3699e2](https://github.com/puppeteer/puppeteer/commit/e3699e248bc9c1f7a6ead9a07d68ae8b65905443)) +* add drag-and-drop support ([#7150](https://github.com/puppeteer/puppeteer/issues/7150)) ([a91b8ac](https://github.com/puppeteer/puppeteer/commit/a91b8aca3728b2c2e310e9446897d729bf983377)) +* add page.emulateCPUThrottling ([#7343](https://github.com/puppeteer/puppeteer/issues/7343)) ([4ce4110](https://github.com/puppeteer/puppeteer/commit/4ce41106288938b9d366c550e7a424812920683d)) + + +### Bug Fixes + +* remove redundant await while fetching target ([#7351](https://github.com/puppeteer/puppeteer/issues/7351)) ([083b297](https://github.com/puppeteer/puppeteer/commit/083b297a6741c6b1dd23867f441130655fac8f7d)) + +## [10.0.0](https://github.com/puppeteer/puppeteer/compare/v9.1.1...v10.0.0) (2021-05-31) + + +### ⚠ BREAKING CHANGES + +* Node.js 10 is no longer supported. + +### Features + +* **chromium:** roll to Chromium 92.0.4512.0 (r884014) ([#7288](https://github.com/puppeteer/puppeteer/issues/7288)) ([f863f4b](https://github.com/puppeteer/puppeteer/commit/f863f4bfe015e57ea1f9fbb322f1cedee468b857)) +* **requestinterception:** remove cacheSafe flag ([#7217](https://github.com/puppeteer/puppeteer/issues/7217)) ([d01aa6c](https://github.com/puppeteer/puppeteer/commit/d01aa6c84a1e41f15ffed3a8d36ad26a404a7187)) +* expose other sessions from connection ([#6863](https://github.com/puppeteer/puppeteer/issues/6863)) ([cb285a2](https://github.com/puppeteer/puppeteer/commit/cb285a237921259eac99ade1d8b5550e068a55eb)) +* **launcher:** add new launcher option `waitForInitialPage` ([#7105](https://github.com/puppeteer/puppeteer/issues/7105)) ([2605309](https://github.com/puppeteer/puppeteer/commit/2605309f74b43da160cda4d214016e4422bf7676)), closes [#3630](https://github.com/puppeteer/puppeteer/issues/3630) + + +### Bug Fixes + +* added comments for browsercontext, startCSSCoverage, and startJSCoverage. ([#7264](https://github.com/puppeteer/puppeteer/issues/7264)) ([b750397](https://github.com/puppeteer/puppeteer/commit/b75039746ac6bddf1411538242b5e70b0f2e6e8a)) +* modified comment for method product, platform and newPage ([#7262](https://github.com/puppeteer/puppeteer/issues/7262)) ([159d283](https://github.com/puppeteer/puppeteer/commit/159d2835450697dabea6f9adf6e67d158b5b8ae3)) +* **requestinterception:** fix font loading issue ([#7060](https://github.com/puppeteer/puppeteer/issues/7060)) ([c9978d2](https://github.com/puppeteer/puppeteer/commit/c9978d20d5584c9fd2dc902e4b4ac86ed8ea5d6e)), closes [/github.com/puppeteer/puppeteer/pull/6996#issuecomment-811546501](https://github.com/puppeteer//github.com/puppeteer/puppeteer/pull/6996/issues/issuecomment-811546501) [/github.com/puppeteer/puppeteer/pull/6996#issuecomment-813797393](https://github.com/puppeteer//github.com/puppeteer/puppeteer/pull/6996/issues/issuecomment-813797393) [#7038](https://github.com/puppeteer/puppeteer/issues/7038) + + +* drop support for Node.js 10 ([#7200](https://github.com/puppeteer/puppeteer/issues/7200)) ([97c9fe2](https://github.com/puppeteer/puppeteer/commit/97c9fe2520723d45a5a86da06b888ae888d400be)), closes [#6753](https://github.com/puppeteer/puppeteer/issues/6753) + +### [9.1.1](https://github.com/puppeteer/puppeteer/compare/v9.1.0...v9.1.1) (2021-05-05) + + +### Bug Fixes + +* make targetFilter synchronous ([#7203](https://github.com/puppeteer/puppeteer/issues/7203)) ([bcc85a0](https://github.com/puppeteer/puppeteer/commit/bcc85a0969077d122e5d8d2fb5c1061999a8ae48)) + +## [9.1.0](https://github.com/puppeteer/puppeteer/compare/v9.0.0...v9.1.0) (2021-05-03) + + +### Features + +* add option to filter targets ([#7192](https://github.com/puppeteer/puppeteer/issues/7192)) ([ec3fc2e](https://github.com/puppeteer/puppeteer/commit/ec3fc2e035bb5ca14a576180fff612e1ecf6bad7)) + + +### Bug Fixes + +* change rm -rf to rimraf ([#7168](https://github.com/puppeteer/puppeteer/issues/7168)) ([ad6b736](https://github.com/puppeteer/puppeteer/commit/ad6b736039436fcc5c0a262e5b575aa041427be3)) + +## [9.0.0](https://github.com/puppeteer/puppeteer/compare/v8.0.0...v9.0.0) (2021-04-21) + + +### ⚠ BREAKING CHANGES + +* **filechooser:** FileChooser.cancel() is now synchronous. + +### Features + +* **chromium:** roll to Chromium 91.0.4469.0 (r869685) ([#7110](https://github.com/puppeteer/puppeteer/issues/7110)) ([715e7a8](https://github.com/puppeteer/puppeteer/commit/715e7a8d62901d1c7ec602425c2fce8d8148b742)) +* **launcher:** fix installation error on Apple M1 chips ([#7099](https://github.com/puppeteer/puppeteer/issues/7099)) ([c239d9e](https://github.com/puppeteer/puppeteer/commit/c239d9edc72d85697b4875c98fff3ec592848082)), closes [#6622](https://github.com/puppeteer/puppeteer/issues/6622) +* **network:** request interception and caching compatibility ([#6996](https://github.com/puppeteer/puppeteer/issues/6996)) ([8695759](https://github.com/puppeteer/puppeteer/commit/8695759a223bc1bd31baecb00dc28721216e4c6f)) +* **page:** emit the event after removing the Worker ([#7080](https://github.com/puppeteer/puppeteer/issues/7080)) ([e34a6d5](https://github.com/puppeteer/puppeteer/commit/e34a6d53183c3e1f63a375ba6a26bee0dcfcf542)) +* **types:** improve type of predicate function ([#6997](https://github.com/puppeteer/puppeteer/issues/6997)) ([943477c](https://github.com/puppeteer/puppeteer/commit/943477cc1eb4b129870142873b3554737d5ef252)), closes [/github.com/DefinitelyTyped/DefinitelyTyped/blob/c43191a8f7a7d2a47bbff0bc3a7d95ecc64d2269/types/puppeteer/index.d.ts#L1883-L1885](https://github.com/puppeteer//github.com/DefinitelyTyped/DefinitelyTyped/blob/c43191a8f7a7d2a47bbff0bc3a7d95ecc64d2269/types/puppeteer/index.d.ts/issues/L1883-L1885) +* accept captureBeyondViewport as optional screenshot param ([#7063](https://github.com/puppeteer/puppeteer/issues/7063)) ([0e092d2](https://github.com/puppeteer/puppeteer/commit/0e092d2ea0ec18ad7f07ad3507deb80f96086e7a)) +* **page:** add omitBackground option for page.pdf method ([#6981](https://github.com/puppeteer/puppeteer/issues/6981)) ([dc8ab6d](https://github.com/puppeteer/puppeteer/commit/dc8ab6d8ca1661f8e56d329e6d9c49c891e8b975)) + + +### Bug Fixes + +* **aria:** fix parsing of ARIA selectors ([#7037](https://github.com/puppeteer/puppeteer/issues/7037)) ([4426135](https://github.com/puppeteer/puppeteer/commit/4426135692ae3ee7ed2841569dd9375e7ca8286c)) +* **page:** fix mouse.click method ([#7097](https://github.com/puppeteer/puppeteer/issues/7097)) ([ba7c367](https://github.com/puppeteer/puppeteer/commit/ba7c367de33ace7753fd9d8b8cc894b2c14ab6c2)), closes [#6462](https://github.com/puppeteer/puppeteer/issues/6462) [#3347](https://github.com/puppeteer/puppeteer/issues/3347) +* make `$` and `$$` selectors generic ([#6883](https://github.com/puppeteer/puppeteer/issues/6883)) ([b349c91](https://github.com/puppeteer/puppeteer/commit/b349c91e7df76630b7411d6645e649945c4609bd)) +* type page event listeners correctly ([#6891](https://github.com/puppeteer/puppeteer/issues/6891)) ([866d34e](https://github.com/puppeteer/puppeteer/commit/866d34ee1122e89eab00743246676845bb065968)) +* **typescript:** allow defaultViewport to be 'null' ([#6942](https://github.com/puppeteer/puppeteer/issues/6942)) ([e31e68d](https://github.com/puppeteer/puppeteer/commit/e31e68dfa12dd50482b700472bc98876b9031829)), closes [#6885](https://github.com/puppeteer/puppeteer/issues/6885) +* make screenshots work in puppeteer-web ([#6936](https://github.com/puppeteer/puppeteer/issues/6936)) ([5f24f60](https://github.com/puppeteer/puppeteer/commit/5f24f608194fd4252da7b288461427cabc9dabb3)) +* **filechooser:** cancel is sync ([#6937](https://github.com/puppeteer/puppeteer/issues/6937)) ([2ba61e0](https://github.com/puppeteer/puppeteer/commit/2ba61e04e923edaac09c92315212552f2d4ce676)) +* **network:** don't disable cache for auth challenge ([#6962](https://github.com/puppeteer/puppeteer/issues/6962)) ([1c2479a](https://github.com/puppeteer/puppeteer/commit/1c2479a6cd4bd09a577175ffd31c40ca6f4279b8)) + +## [8.0.0](https://github.com/puppeteer/puppeteer/compare/v7.1.0...v8.0.0) (2021-02-26) + + +### ⚠ BREAKING CHANGES + +* renamed type `ChromeArgOptions` to `BrowserLaunchArgumentOptions` +* renamed type `BrowserOptions` to `BrowserConnectOptions` + +### Features + +* **chromium:** roll Chromium to r856583 ([#6927](https://github.com/puppeteer/puppeteer/issues/6927)) ([0c688bd](https://github.com/puppeteer/puppeteer/commit/0c688bd75ef1d1fc3afd14cbe8966757ecda68fb)) + + +### Bug Fixes + +* explicit HTTPRequest.resourceType type defs ([#6882](https://github.com/puppeteer/puppeteer/issues/6882)) ([ff26c62](https://github.com/puppeteer/puppeteer/commit/ff26c62647b60cd0d8d7ea66ee998adaadc3fcc2)), closes [#6854](https://github.com/puppeteer/puppeteer/issues/6854) +* expose `Viewport` type ([#6881](https://github.com/puppeteer/puppeteer/issues/6881)) ([be7c229](https://github.com/puppeteer/puppeteer/commit/be7c22933c1dcf5eee797d61463171bd0ef44582)) +* improve TS types for launching browsers ([#6888](https://github.com/puppeteer/puppeteer/issues/6888)) ([98c8145](https://github.com/puppeteer/puppeteer/commit/98c81458c27f378eb66c38e1620e79e2ffde418e)) +* move CI npm config out of .npmrc ([#6901](https://github.com/puppeteer/puppeteer/issues/6901)) ([f7de60b](https://github.com/puppeteer/puppeteer/commit/f7de60be22d9bc6433ada7bfefeaa7f6f6f62047)) + +## [7.1.0](https://github.com/puppeteer/puppeteer/compare/v7.0.4...v7.1.0) (2021-02-12) + + +### Features + +* **page:** add color-gamut support to Page.emulateMediaFeatures ([#6857](https://github.com/puppeteer/puppeteer/issues/6857)) ([ad59357](https://github.com/puppeteer/puppeteer/commit/ad5935738d869cfce386a0d28b4bc6131457f962)), closes [#6761](https://github.com/puppeteer/puppeteer/issues/6761) + + +### Bug Fixes + +* add favicon test asset ([#6868](https://github.com/puppeteer/puppeteer/issues/6868)) ([a63f53c](https://github.com/puppeteer/puppeteer/commit/a63f53c9380545550503f5539494c72c607e19ac)) +* expose `ScreenshotOptions` type in type defs ([#6869](https://github.com/puppeteer/puppeteer/issues/6869)) ([63d48b2](https://github.com/puppeteer/puppeteer/commit/63d48b2ecba317b6c0a3acad87a7a3671c769dbc)), closes [#6866](https://github.com/puppeteer/puppeteer/issues/6866) +* expose puppeteer.Permission type ([#6856](https://github.com/puppeteer/puppeteer/issues/6856)) ([a5e174f](https://github.com/puppeteer/puppeteer/commit/a5e174f696eb192c541db64a603ea5cdf385a643)) +* jsonValue() type is generic ([#6865](https://github.com/puppeteer/puppeteer/issues/6865)) ([bdaba78](https://github.com/puppeteer/puppeteer/commit/bdaba7829da366aabbc81885d84bb2401ab3eaff)) +* wider compat TS types and CI checks to ensure correct type defs ([#6855](https://github.com/puppeteer/puppeteer/issues/6855)) ([6a0eb78](https://github.com/puppeteer/puppeteer/commit/6a0eb7841fd82493903b0b9fa153d2de181350eb)) + +### [7.0.4](https://github.com/puppeteer/puppeteer/compare/v7.0.3...v7.0.4) (2021-02-09) + + +### Bug Fixes + +* make publish bot run full build, not just tsc ([#6848](https://github.com/puppeteer/puppeteer/issues/6848)) ([f718b14](https://github.com/puppeteer/puppeteer/commit/f718b14b64df8be492d344ddd35e40961ff750c5)) + +### [7.0.3](https://github.com/puppeteer/puppeteer/compare/v7.0.2...v7.0.3) (2021-02-09) + + +### Bug Fixes + +* include lib/types.d.ts in files list ([#6844](https://github.com/puppeteer/puppeteer/issues/6844)) ([e34f317](https://github.com/puppeteer/puppeteer/commit/e34f317b37533256a063c1238609b488d263b998)) + +### [7.0.2](https://github.com/puppeteer/puppeteer/compare/v7.0.1...v7.0.2) (2021-02-09) + + +### Bug Fixes + +* much better TypeScript definitions ([#6837](https://github.com/puppeteer/puppeteer/issues/6837)) ([f1b46ab](https://github.com/puppeteer/puppeteer/commit/f1b46ab5faa262f893c17923579d0cf52268a764)) +* **domworld:** reset bindings when context changes ([#6766](https://github.com/puppeteer/puppeteer/issues/6766)) ([#6836](https://github.com/puppeteer/puppeteer/issues/6836)) ([4e8d074](https://github.com/puppeteer/puppeteer/commit/4e8d074c2f8384a2f283f5edf9ef69c40bd8464f)) +* **launcher:** output correct error message for browser ([#6815](https://github.com/puppeteer/puppeteer/issues/6815)) ([6c61874](https://github.com/puppeteer/puppeteer/commit/6c618747979c3a08f2727e9e22fe45cade8c926a)) + +### [7.0.1](https://github.com/puppeteer/puppeteer/compare/v7.0.0...v7.0.1) (2021-02-04) + + +### Bug Fixes + +* **typescript:** ship .d.ts file in npm package ([#6811](https://github.com/puppeteer/puppeteer/issues/6811)) ([a7e3c2e](https://github.com/puppeteer/puppeteer/commit/a7e3c2e09e9163eee2f15221aafa4400e6a75f91)) + +## [7.0.0](https://github.com/puppeteer/puppeteer/compare/v6.0.0...v7.0.0) (2021-02-03) + + +### ⚠ BREAKING CHANGES + +* - `page.screenshot` makes a screenshot with the clip dimensions, not cutting it by the ViewPort size. +* **chromium:** - `page.screenshot` cuts screenshot content by the ViewPort size, not ViewPort position. + +### Features + +* use `captureBeyondViewport` in `Page.captureScreenshot` ([#6805](https://github.com/puppeteer/puppeteer/issues/6805)) ([401d84e](https://github.com/puppeteer/puppeteer/commit/401d84e4a3508f9ca5c24dbfcad2a71571b1b8eb)) +* **chromium:** roll Chromium to r848005 ([#6801](https://github.com/puppeteer/puppeteer/issues/6801)) ([890d5c2](https://github.com/puppeteer/puppeteer/commit/890d5c2e57cdee7d73915a878bda86b72e26b608)) + +## [6.0.0](https://github.com/puppeteer/puppeteer/compare/v5.5.0...v6.0.0) (2021-02-02) + + +### ⚠ BREAKING CHANGES + +* **chromium:** The built-in `aria/` selector query handler doesn’t return ignored elements anymore. + +### Features + +* **chromium:** roll Chromium to r843427 ([#6797](https://github.com/puppeteer/puppeteer/issues/6797)) ([8f9fbdb](https://github.com/puppeteer/puppeteer/commit/8f9fbdbae68254600a9c73ab05f36146c975dba6)), closes [#6758](https://github.com/puppeteer/puppeteer/issues/6758) +* add page.emulateNetworkConditions ([#6759](https://github.com/puppeteer/puppeteer/issues/6759)) ([5ea76e9](https://github.com/puppeteer/puppeteer/commit/5ea76e9333c42ab5a751ca01aa5676a662f6c063)) +* **types:** expose typedefs to consumers ([#6745](https://github.com/puppeteer/puppeteer/issues/6745)) ([ebd087a](https://github.com/puppeteer/puppeteer/commit/ebd087a31661a1b701650d0be3e123cc5a813bd8)) +* add iPhone 11 models to DeviceDescriptors ([#6467](https://github.com/puppeteer/puppeteer/issues/6467)) ([50b810d](https://github.com/puppeteer/puppeteer/commit/50b810dab7fae5950ba086295462788f91ff1e6f)) +* support fetching and launching on Apple M1 ([9a8479a](https://github.com/puppeteer/puppeteer/commit/9a8479a52a7d8b51690b0732b2a10816cd1b8aef)), closes [#6495](https://github.com/puppeteer/puppeteer/issues/6495) [#6634](https://github.com/puppeteer/puppeteer/issues/6634) [#6641](https://github.com/puppeteer/puppeteer/issues/6641) [#6614](https://github.com/puppeteer/puppeteer/issues/6614) +* support promise as return value for page.waitForResponse predicate ([#6624](https://github.com/puppeteer/puppeteer/issues/6624)) ([b57f3fc](https://github.com/puppeteer/puppeteer/commit/b57f3fcd5393c68f51d82e670b004f5b116dcbc3)) + + +### Bug Fixes + +* **domworld:** fix waitfor bindings ([#6766](https://github.com/puppeteer/puppeteer/issues/6766)) ([#6775](https://github.com/puppeteer/puppeteer/issues/6775)) ([cac540b](https://github.com/puppeteer/puppeteer/commit/cac540be3ab8799a1d77b0951b16bc22ea1c2adb)) +* **launcher:** rename TranslateUI to Translate to match Chrome ([#6692](https://github.com/puppeteer/puppeteer/issues/6692)) ([d901696](https://github.com/puppeteer/puppeteer/commit/d901696e0d8901bcb23cf676a5e5ac562f821a0d)) +* do not use old utility world ([#6528](https://github.com/puppeteer/puppeteer/issues/6528)) ([fb85911](https://github.com/puppeteer/puppeteer/commit/fb859115c0e2829bae1d1b32edbf642988e2ef76)), closes [#6527](https://github.com/puppeteer/puppeteer/issues/6527) +* update to https-proxy-agent@^5.0.0 to fix `ERR_INVALID_PROTOCOL` ([#6555](https://github.com/puppeteer/puppeteer/issues/6555)) ([3bf5a55](https://github.com/puppeteer/puppeteer/commit/3bf5a552890ee80cc4326b1e430424b0fdad4363)) + +## [5.5.0](https://github.com/puppeteer/puppeteer/compare/v5.4.1...v5.5.0) (2020-11-16) + + +### Features + +* **chromium:** roll Chromium to r818858 ([#6526](https://github.com/puppeteer/puppeteer/issues/6526)) ([b549256](https://github.com/puppeteer/puppeteer/commit/b54925695200cad32f470f8eb407259606447a85)) + + +### Bug Fixes + +* **common:** fix generic type of `_isClosedPromise` ([#6579](https://github.com/puppeteer/puppeteer/issues/6579)) ([122f074](https://github.com/puppeteer/puppeteer/commit/122f074f92f47a7b9aa08091851e51a07632d23b)) +* **domworld:** fix missing binding for waittasks ([#6562](https://github.com/puppeteer/puppeteer/issues/6562)) ([67da1cf](https://github.com/puppeteer/puppeteer/commit/67da1cf866703f5f581c9cce4923697ac38129ef)) diff --git a/remote/test/puppeteer/packages/puppeteer/api-extractor.docs.json b/remote/test/puppeteer/packages/puppeteer/api-extractor.docs.json new file mode 100644 index 0000000000..88fcdbfd38 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/api-extractor.docs.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/puppeteer/puppeteer.d.ts", + + "extends": "./api-extractor.json", + + "dtsRollup": { + "enabled": false + }, + + "docModel": { + "enabled": true, + "apiJsonFilePath": "<projectFolder>/../../docs/<unscopedPackageName>.api.json" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/api-extractor.json b/remote/test/puppeteer/packages/puppeteer/api-extractor.json new file mode 100644 index 0000000000..486b3929e7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/api-extractor.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/puppeteer/puppeteer.d.ts", + "bundledPackages": ["puppeteer-core"], + + "apiReport": { + "enabled": false + }, + + "docModel": { + "enabled": false + }, + + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "", + "alphaTrimmedFilePath": "lib/types.d.ts" + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + + "extractorMessageReporting": { + "ae-wrong-input-file-type": { + "logLevel": "none" + }, + "ae-internal-missing-underscore": { + "logLevel": "none" + }, + "default": { + "logLevel": "warning" + } + }, + + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/install.mjs b/remote/test/puppeteer/packages/puppeteer/install.mjs new file mode 100755 index 0000000000..2724e129d9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/install.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * This file is part of public API. + * + * By default, the `puppeteer` package runs this script during the installation + * process unless one of the env flags is provided. + * `puppeteer-core` package doesn't include this step at all. However, it's + * still possible to install a supported browser using this script when + * necessary. + */ + +async function importInstaller() { + try { + return await import('puppeteer/internal/node/install.js'); + } catch { + console.warn( + 'Skipping browser installation because the Puppeteer build is not available. Run `npm install` again after you have re-built Puppeteer.' + ); + process.exit(0); + } +} + +try { + const {downloadBrowser} = await importInstaller(); + downloadBrowser(); +} catch (error) { + console.warn('Browser download failed', error); +} diff --git a/remote/test/puppeteer/packages/puppeteer/package.json b/remote/test/puppeteer/packages/puppeteer/package.json new file mode 100644 index 0000000000..0419e4b459 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/package.json @@ -0,0 +1,133 @@ +{ + "name": "puppeteer", + "version": "21.10.0", + "description": "A high-level API to control headless Chrome over the DevTools Protocol", + "keywords": [ + "puppeteer", + "chrome", + "headless", + "automation" + ], + "type": "commonjs", + "bin": "./lib/esm/puppeteer/node/cli.js", + "main": "./lib/cjs/puppeteer/puppeteer.js", + "types": "./lib/types.d.ts", + "exports": { + ".": { + "types": "./lib/types.d.ts", + "import": "./lib/esm/puppeteer/puppeteer.js", + "require": "./lib/cjs/puppeteer/puppeteer.js" + }, + "./internal/*": { + "import": "./lib/esm/puppeteer/*", + "require": "./lib/cjs/puppeteer/*" + }, + "./*": { + "import": "./*", + "require": "./*" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/puppeteer" + }, + "engines": { + "node": ">=16.13.2" + }, + "scripts": { + "build:docs": "wireit", + "build": "wireit", + "clean": "../../tools/clean.js", + "postinstall": "node install.mjs", + "prepack": "wireit" + }, + "wireit": { + "prepack": { + "command": "tsx ../../tools/cp.ts ../../README.md README.md", + "files": [ + "../../README.md" + ], + "output": [ + "README.md" + ] + }, + "build": { + "dependencies": [ + "build:tsc", + "build:types" + ] + }, + "generate:package-json": { + "command": "tsx ../../tools/generate_module_package_json.ts lib/esm/package.json", + "files": [ + "../../tools/generate_module_package_json.ts" + ], + "output": [ + "lib/esm/package.json" + ] + }, + "build:docs": { + "command": "api-extractor run --local --config \"./api-extractor.docs.json\"", + "files": [ + "api-extractor.docs.json", + "lib/esm/puppeteer/puppeteer-core.d.ts", + "tsconfig.json" + ], + "dependencies": [ + "build:tsc" + ] + }, + "build:tsc": { + "command": "tsc -b && tsx ../../tools/chmod.ts 755 lib/cjs/puppeteer/node/cli.js lib/esm/puppeteer/node/cli.js", + "clean": "if-file-deleted", + "dependencies": [ + "../puppeteer-core:build", + "../browsers:build", + "generate:package-json" + ], + "files": [ + "src/**" + ], + "output": [ + "lib/{cjs,esm}/**", + "!lib/esm/package.json" + ] + }, + "build:types": { + "command": "api-extractor run --local && eslint --cache-location .eslintcache --cache --ext=ts --no-ignore --no-eslintrc -c=../../.eslintrc.types.cjs --fix lib/types.d.ts", + "files": [ + "../../.eslintrc.types.cjs", + "api-extractor.json", + "lib/esm/puppeteer/types.d.ts", + "tsconfig.json" + ], + "output": [ + "lib/types.d.ts" + ], + "dependencies": [ + "build:tsc" + ] + } + }, + "files": [ + "lib", + "src", + "install.mjs", + "!*.test.ts", + "!*.test.js", + "!*.test.d.ts", + "!*.test.js.map", + "!*.test.d.ts.map", + "!*.tsbuildinfo" + ], + "author": "The Chromium Authors", + "license": "Apache-2.0", + "dependencies": { + "cosmiconfig": "9.0.0", + "puppeteer-core": "21.10.0", + "@puppeteer/browsers": "1.9.1" + }, + "devDependencies": { + "@types/node": "18.17.15" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/src/getConfiguration.ts b/remote/test/puppeteer/packages/puppeteer/src/getConfiguration.ts new file mode 100644 index 0000000000..28cf026eb7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/getConfiguration.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {homedir} from 'os'; +import {join} from 'path'; + +import {cosmiconfigSync} from 'cosmiconfig'; +import type {Configuration, Product} from 'puppeteer-core'; + +/** + * @internal + */ +function isSupportedProduct(product: unknown): product is Product { + switch (product) { + case 'chrome': + case 'firefox': + return true; + default: + return false; + } +} + +/** + * @internal + */ +export const getConfiguration = (): Configuration => { + const result = cosmiconfigSync('puppeteer', { + searchStrategy: 'global', + }).search(); + const configuration: Configuration = result ? result.config : {}; + + configuration.logLevel = (process.env['PUPPETEER_LOGLEVEL'] ?? + process.env['npm_config_LOGLEVEL'] ?? + process.env['npm_package_config_LOGLEVEL'] ?? + configuration.logLevel ?? + 'warn') as 'silent' | 'error' | 'warn'; + + // Merging environment variables. + configuration.defaultProduct = (process.env['PUPPETEER_PRODUCT'] ?? + process.env['npm_config_puppeteer_product'] ?? + process.env['npm_package_config_puppeteer_product'] ?? + configuration.defaultProduct ?? + 'chrome') as Product; + + configuration.executablePath = + process.env['PUPPETEER_EXECUTABLE_PATH'] ?? + process.env['npm_config_puppeteer_executable_path'] ?? + process.env['npm_package_config_puppeteer_executable_path'] ?? + configuration.executablePath; + + // Default to skipDownload if executablePath is set + if (configuration.executablePath) { + configuration.skipDownload = true; + } + + // Set skipDownload explicitly or from default + configuration.skipDownload = Boolean( + process.env['PUPPETEER_SKIP_DOWNLOAD'] ?? + process.env['npm_config_puppeteer_skip_download'] ?? + process.env['npm_package_config_puppeteer_skip_download'] ?? + configuration.skipDownload + ); + + // Set skipChromeDownload explicitly or from default + configuration.skipChromeDownload = Boolean( + process.env['PUPPETEER_SKIP_CHROME_DOWNLOAD'] ?? + process.env['npm_config_puppeteer_skip_chrome_download'] ?? + process.env['npm_package_config_puppeteer_skip_chrome_download'] ?? + configuration.skipChromeDownload + ); + + // Set skipChromeDownload explicitly or from default + configuration.skipChromeHeadlessShellDownload = Boolean( + process.env['PUPPETEER_SKIP_CHROME_HEADLESS_SHELL_DOWNLOAD'] ?? + process.env['npm_config_puppeteer_skip_chrome_headless_shell_download'] ?? + process.env[ + 'npm_package_config_puppeteer_skip_chrome_headless_shell_download' + ] ?? + configuration.skipChromeHeadlessShellDownload + ); + + // Prepare variables used in browser downloading + if (!configuration.skipDownload) { + configuration.browserRevision = + process.env['PUPPETEER_BROWSER_REVISION'] ?? + process.env['npm_config_puppeteer_browser_revision'] ?? + process.env['npm_package_config_puppeteer_browser_revision'] ?? + configuration.browserRevision; + + const downloadHost = + process.env['PUPPETEER_DOWNLOAD_HOST'] ?? + process.env['npm_config_puppeteer_download_host'] ?? + process.env['npm_package_config_puppeteer_download_host']; + + if (downloadHost && configuration.logLevel === 'warn') { + console.warn( + `PUPPETEER_DOWNLOAD_HOST is deprecated. Use PUPPETEER_DOWNLOAD_BASE_URL instead.` + ); + } + + configuration.downloadBaseUrl = + process.env['PUPPETEER_DOWNLOAD_BASE_URL'] ?? + process.env['npm_config_puppeteer_download_base_url'] ?? + process.env['npm_package_config_puppeteer_download_base_url'] ?? + configuration.downloadBaseUrl ?? + downloadHost; + + configuration.downloadPath = + process.env['PUPPETEER_DOWNLOAD_PATH'] ?? + process.env['npm_config_puppeteer_download_path'] ?? + process.env['npm_package_config_puppeteer_download_path'] ?? + configuration.downloadPath; + } + + configuration.cacheDirectory = + process.env['PUPPETEER_CACHE_DIR'] ?? + process.env['npm_config_puppeteer_cache_dir'] ?? + process.env['npm_package_config_puppeteer_cache_dir'] ?? + configuration.cacheDirectory ?? + join(homedir(), '.cache', 'puppeteer'); + configuration.temporaryDirectory = + process.env['PUPPETEER_TMP_DIR'] ?? + process.env['npm_config_puppeteer_tmp_dir'] ?? + process.env['npm_package_config_puppeteer_tmp_dir'] ?? + configuration.temporaryDirectory; + + configuration.experiments ??= {}; + + // Validate configuration. + if (!isSupportedProduct(configuration.defaultProduct)) { + throw new Error(`Unsupported product ${configuration.defaultProduct}`); + } + + return configuration; +}; diff --git a/remote/test/puppeteer/packages/puppeteer/src/node/cli.ts b/remote/test/puppeteer/packages/puppeteer/src/node/cli.ts new file mode 100644 index 0000000000..9a25c59327 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/node/cli.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CLI, Browser} from '@puppeteer/browsers'; +import {PUPPETEER_REVISIONS} from 'puppeteer-core/internal/revisions.js'; + +import puppeteer from '../puppeteer.js'; + +// TODO: deprecate downloadPath in favour of cacheDirectory. +const cacheDir = + puppeteer.configuration.downloadPath ?? + puppeteer.configuration.cacheDirectory!; + +void new CLI({ + cachePath: cacheDir, + scriptName: 'puppeteer', + prefixCommand: { + cmd: 'browsers', + description: 'Manage browsers of this Puppeteer installation', + }, + allowCachePathOverride: false, + pinnedBrowsers: { + [Browser.CHROME]: PUPPETEER_REVISIONS.chrome, + [Browser.FIREFOX]: PUPPETEER_REVISIONS.firefox, + [Browser.CHROMEHEADLESSSHELL]: PUPPETEER_REVISIONS['chrome-headless-shell'], + }, +}).run(process.argv); diff --git a/remote/test/puppeteer/packages/puppeteer/src/node/install.ts b/remote/test/puppeteer/packages/puppeteer/src/node/install.ts new file mode 100644 index 0000000000..76bad868b8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/node/install.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + install, + Browser, + resolveBuildId, + makeProgressCallback, + detectBrowserPlatform, +} from '@puppeteer/browsers'; +import type {Product} from 'puppeteer-core'; +import {PUPPETEER_REVISIONS} from 'puppeteer-core/internal/revisions.js'; + +import {getConfiguration} from '../getConfiguration.js'; + +/** + * @internal + */ +const supportedProducts = { + chrome: 'Chrome', + firefox: 'Firefox Nightly', +} as const; + +/** + * @internal + */ +export async function downloadBrowser(): Promise<void> { + overrideProxy(); + + const configuration = getConfiguration(); + if (configuration.skipDownload) { + logPolitely('**INFO** Skipping browser download as instructed.'); + return; + } + + const downloadBaseUrl = configuration.downloadBaseUrl; + + const platform = detectBrowserPlatform(); + if (!platform) { + throw new Error('The current platform is not supported.'); + } + + const product = configuration.defaultProduct!; + const browser = productToBrowser(product); + + const unresolvedBuildId = + configuration.browserRevision || PUPPETEER_REVISIONS[product] || 'latest'; + const unresolvedShellBuildId = + configuration.browserRevision || + PUPPETEER_REVISIONS['chrome-headless-shell'] || + 'latest'; + + // TODO: deprecate downloadPath in favour of cacheDirectory. + const cacheDir = configuration.downloadPath ?? configuration.cacheDirectory!; + + try { + const installationJobs = []; + + if (configuration.skipChromeDownload) { + logPolitely('**INFO** Skipping Chrome download as instructed.'); + } else { + const buildId = await resolveBuildId( + browser, + platform, + unresolvedBuildId + ); + installationJobs.push( + install({ + browser, + cacheDir, + platform, + buildId, + downloadProgressCallback: makeProgressCallback(browser, buildId), + baseUrl: downloadBaseUrl, + }) + .then(result => { + logPolitely( + `${supportedProducts[product]} (${result.buildId}) downloaded to ${result.path}` + ); + }) + .catch(error => { + throw new Error( + `ERROR: Failed to set up ${supportedProducts[product]} v${buildId}! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.`, + { + cause: error, + } + ); + }) + ); + } + + if (browser === Browser.CHROME) { + if (configuration.skipChromeHeadlessShellDownload) { + logPolitely('**INFO** Skipping Chrome download as instructed.'); + } else { + const shellBuildId = await resolveBuildId( + browser, + platform, + unresolvedShellBuildId + ); + + installationJobs.push( + install({ + browser: Browser.CHROMEHEADLESSSHELL, + cacheDir, + platform, + buildId: shellBuildId, + downloadProgressCallback: makeProgressCallback( + browser, + shellBuildId + ), + baseUrl: downloadBaseUrl, + }) + .then(result => { + logPolitely( + `${Browser.CHROMEHEADLESSSHELL} (${result.buildId}) downloaded to ${result.path}` + ); + }) + .catch(error => { + throw new Error( + `ERROR: Failed to set up ${Browser.CHROMEHEADLESSSHELL} v${shellBuildId}! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.`, + { + cause: error, + } + ); + }) + ); + } + } + + await Promise.all(installationJobs); + } catch (error) { + console.error(error); + process.exit(1); + } +} + +function productToBrowser(product?: Product) { + switch (product) { + case 'chrome': + return Browser.CHROME; + case 'firefox': + return Browser.FIREFOX; + } + return Browser.CHROME; +} + +/** + * @internal + */ +function logPolitely(toBeLogged: unknown): void { + const logLevel = process.env['npm_config_loglevel'] || ''; + const logLevelDisplay = ['silent', 'error', 'warn'].indexOf(logLevel) > -1; + + // eslint-disable-next-line no-console + if (!logLevelDisplay) { + console.log(toBeLogged); + } +} + +/** + * @internal + */ +function overrideProxy() { + // Override current environment proxy settings with npm configuration, if any. + const NPM_HTTPS_PROXY = + process.env['npm_config_https_proxy'] || process.env['npm_config_proxy']; + const NPM_HTTP_PROXY = + process.env['npm_config_http_proxy'] || process.env['npm_config_proxy']; + const NPM_NO_PROXY = process.env['npm_config_no_proxy']; + + if (NPM_HTTPS_PROXY) { + process.env['HTTPS_PROXY'] = NPM_HTTPS_PROXY; + } + if (NPM_HTTP_PROXY) { + process.env['HTTP_PROXY'] = NPM_HTTP_PROXY; + } + if (NPM_NO_PROXY) { + process.env['NO_PROXY'] = NPM_NO_PROXY; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/src/puppeteer.ts b/remote/test/puppeteer/packages/puppeteer/src/puppeteer.ts new file mode 100644 index 0000000000..4f4321bc6c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/puppeteer.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type {Protocol} from 'puppeteer-core'; + +export * from 'puppeteer-core/internal/puppeteer-core.js'; + +import {PuppeteerNode} from 'puppeteer-core/internal/node/PuppeteerNode.js'; + +import {getConfiguration} from './getConfiguration.js'; + +const configuration = getConfiguration(); + +/** + * @public + */ +const puppeteer = new PuppeteerNode({ + isPuppeteerCore: false, + configuration, +}); + +export const { + /** + * @public + */ + connect, + /** + * @public + */ + defaultArgs, + /** + * @public + */ + executablePath, + /** + * @public + */ + launch, + /** + * @public + */ + trimCache, +} = puppeteer; + +export default puppeteer; diff --git a/remote/test/puppeteer/packages/puppeteer/src/tsconfig.cjs.json b/remote/test/puppeteer/packages/puppeteer/src/tsconfig.cjs.json new file mode 100644 index 0000000000..0cb78dca7f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "../lib/cjs/puppeteer" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/src/tsconfig.esm.json b/remote/test/puppeteer/packages/puppeteer/src/tsconfig.esm.json new file mode 100644 index 0000000000..a848929f4f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/tsconfig.esm.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../lib/esm/puppeteer" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/tsconfig.json b/remote/test/puppeteer/packages/puppeteer/tsconfig.json new file mode 100644 index 0000000000..11314a80e3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "compilerOptions": { + // API extractor doesn't work well with NodeNext module resolution, so we + // just stick with ol'fashion path resolution. + "baseUrl": ".", + "paths": { + "puppeteer-core/internal/*": ["../puppeteer-core/lib/esm/puppeteer/*"], + }, + }, + "references": [ + {"path": "src/tsconfig.esm.json"}, + {"path": "src/tsconfig.cjs.json"}, + ], +} diff --git a/remote/test/puppeteer/packages/puppeteer/tsdoc.json b/remote/test/puppeteer/packages/puppeteer/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/testserver/CHANGELOG.md b/remote/test/puppeteer/packages/testserver/CHANGELOG.md new file mode 100644 index 0000000000..bb971ef46d --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [0.6.0](https://github.com/puppeteer/puppeteer/compare/testserver-v0.5.0...testserver-v0.6.0) (2022-10-05) + + +### Features + +* separate puppeteer and puppeteer-core ([#9023](https://github.com/puppeteer/puppeteer/issues/9023)) ([f42336c](https://github.com/puppeteer/puppeteer/commit/f42336cf83982332829ca7e14ee48d8676e11545)) diff --git a/remote/test/puppeteer/packages/testserver/LICENSE b/remote/test/puppeteer/packages/testserver/LICENSE new file mode 100644 index 0000000000..afdfe50e72 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Google Inc. + + Licensed 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. diff --git a/remote/test/puppeteer/packages/testserver/README.md b/remote/test/puppeteer/packages/testserver/README.md new file mode 100644 index 0000000000..d22b2da449 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/README.md @@ -0,0 +1,18 @@ +# TestServer + +This test server is used internally by Puppeteer to test Puppeteer itself. + +### Example + +```ts +const {TestServer} = require('@pptr/testserver'); + +(async(() => { + const httpServer = await TestServer.create(__dirname, 8000), + const httpsServer = await TestServer.createHTTPS(__dirname, 8001) + httpServer.setRoute('/hello', (req, res) => { + res.end('Hello, world!'); + }); + console.log('HTTP and HTTPS servers are running!'); +})(); +``` diff --git a/remote/test/puppeteer/packages/testserver/cert.pem b/remote/test/puppeteer/packages/testserver/cert.pem new file mode 100644 index 0000000000..fd3838535a --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWDCCAkCgAwIBAgIUM8Tmw+D1j+eVz9x9So4zRVqFsKowDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPcHVwcGV0ZWVyLXRlc3RzMB4XDTIwMDUxMzA4MDQyOVoX +DTMwMDUxMTA4MDQyOVowGjEYMBYGA1UEAwwPcHVwcGV0ZWVyLXRlc3RzMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApWbbhgc6CnWywd8xGETT1mfLi3wi +KIbpAUHghLF4sj0jXz8vLh/4oicpQ12d6bsz+IAi7qrdXNh11P5nEej6/Gx4fWzB +gGdrJFGPqsvXuhYdzZAmy6xOaWcLIJeQ543bXv3YeST7EGRXJBc/ocTo2jIGTGjq +hksFaid910VQlX3KGOLTDMUCk00TeEYBTTUx47PWoIsxVqbl2RzVXRSWL5hlPWlW +29/BQtBGmsXxZyWtqqHudiUulGBSr4LcPyicZLI8nqCqD0ioS0TEmGh61nRBuwBa +xmLCvPmpt0+sDuOU+1bme3w8juvTVToBIFxGB86rADd3ys+8NeZzXqi+bQIDAQAB +o4GVMIGSMB0GA1UdDgQWBBT/m3vdkZpQyVQFdYrKHVoAHXDFODAfBgNVHSMEGDAW +gBT/m3vdkZpQyVQFdYrKHVoAHXDFODAPBgNVHRMBAf8EBTADAQH/MD8GA1UdEQQ4 +MDaCGHd3dy5wdXBwZXRlZXItdGVzdHMudGVzdIIad3d3LnB1cHBldGVlci10ZXN0 +cy0xLnRlc3QwDQYJKoZIhvcNAQELBQADggEBAI1qp5ZppV1R3e8XxzwwkFDPFN8W +Pe3AoqhAKyJnJl1NUn9q3sroEeSQRhODWUHCd7lENzhsT+3mzonNNkN9B/hq0rpK +KHHczXILDqdyuxH3LxQ1VHGE8VN2NbdkfobtzAsA3woiJxOuGeusXJnKB4kJQeIP +V+BMEZWeaSDC2PREkG7GOezmE1/WDUCYaorPw2whdCA5wJvTW3zXpJjYhfsld+5z +KuErx4OCxRJij73/BD9SpLxDEY1cdl819F1IvxsRGhmTIaSly2hQLrhOgo1jgZtV +FGCa6DSlXnQGLaV+N+ssR0lkCksNrNBVDfA1bP5bT/4VCcwUWwm9TUeF0Qo= +-----END CERTIFICATE----- diff --git a/remote/test/puppeteer/packages/testserver/key.pem b/remote/test/puppeteer/packages/testserver/key.pem new file mode 100644 index 0000000000..cbc3acb229 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQClZtuGBzoKdbLB +3zEYRNPWZ8uLfCIohukBQeCEsXiyPSNfPy8uH/iiJylDXZ3puzP4gCLuqt1c2HXU +/mcR6Pr8bHh9bMGAZ2skUY+qy9e6Fh3NkCbLrE5pZwsgl5Dnjdte/dh5JPsQZFck +Fz+hxOjaMgZMaOqGSwVqJ33XRVCVfcoY4tMMxQKTTRN4RgFNNTHjs9agizFWpuXZ +HNVdFJYvmGU9aVbb38FC0EaaxfFnJa2qoe52JS6UYFKvgtw/KJxksjyeoKoPSKhL +RMSYaHrWdEG7AFrGYsK8+am3T6wO45T7VuZ7fDyO69NVOgEgXEYHzqsAN3fKz7w1 +5nNeqL5tAgMBAAECggEAKPveo0xBHnxhidZzBM9xKixX7D0a/a3IKI6ZQmfzPz8U +97HhT+2OHyfS+qVEzribPRULEtZ1uV7Ne7R5958iKc/63yFGpTl6++nVzn1p++sl +AV2Zr1gHqehlgnLr7eRhmh0OOZ5nM32ZdhDorH3tMLu6gc5xZktKkS4t6Vx8hj3a +Docx+rbawp8GRd0p7I6vzIE3bsDab8hC+RTRO63q2G0BqgKwV9ZNtJxQgcDJ5L8N +6gtM2z5nKXAIOCbCQYa1PsrDh3IRA/ZNxEeA9G3YQjwlZYCWmdRRplgDraYxcTBO +oQGjaLwICNdcprMacPD6cCSgrI+PadzyMsAuk9SgpQKBgQDO9PT4gK40Pm+Damxv ++tWYBFmvn3vasmyolc1zVDltsxQbQTjKhVpTLXTTGmrIhDXEIIV9I4rg164WptQs +6Brp2EwYR7ZJIrjvXs/9i2QTW1ZXvhdiWpB3s+RXD5VHGovHUadcI6wOgw2Cl+Jk +zXjSIgyXKM99N1MAonuR7DyzTwKBgQDMmPX+9vWZMpS/gc6JLQiPPoGszE6tYjXg +W3LpRUNqmO0/bDDjslbebDgrGAmhlkJlxzH6gz96VmGm1evEGPEet3euy8S9zuM3 +LCgEM9Ulqa3JbInwtKupmKv76Im+XWLLSxAXbfiel1zFRRwxI99A3ad0QRZ6Bov5 +3cHJBwvzgwKBgAU5HW2gIcVjxgC1EOOKmxVpFrJd/gw48JEYpsTAXWqtWFaPwNUr +pGnw/b/OLN++pnS6tWPBH+Ioz1X3A+fWO8enE9SRCsKxw6UW6XzmpbHvXjB8ta5f +xsGeoqan2AahXuG659RlehQrro2bM7WDkgcLoPG3r/TjDo83ipLWOXn1AoGAKWiL +4R56dpcWI+xRsNG8ecFc3Ww8QDswTEg16aBrFJf+7GcpPexKSJn+hDpJOLsAlTjL +lLgbkNcKzIlfPkEOC/l175quJvxIYFI/hxo2eXjuA2ZERMNMOvb7V/CocC7WX+7B +Qvyu5OodjI+ANTHdbXNvAMhrlCbfDaMkJVuXv6ECgYBzvY4aYmVoFsr+72/EfLls +Dz9pi55tUUWc61w6ovd+iliawvXeGi4wibtTH4iGj/C2sJIaMmOD99NQ7Oi/x89D +oMgSUemkoFL8FGsZGyZ7szqxyON1jP42Bm2MQrW5kIf7Y4yaIGhoak5JNxn2JUyV +gupVbY1mQ1GTPByxHeLh1w== +-----END PRIVATE KEY----- diff --git a/remote/test/puppeteer/packages/testserver/package.json b/remote/test/puppeteer/packages/testserver/package.json new file mode 100644 index 0000000000..3a9ecf9c65 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/package.json @@ -0,0 +1,36 @@ +{ + "name": "@pptr/testserver", + "version": "0.6.0", + "description": "testing server", + "main": "lib/index.js", + "scripts": { + "build": "wireit", + "clean": "../../tools/clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "lib/**", + "tsconfig.tsbuildinfo" + ] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/testserver" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "dependencies": { + "mime": "3.0.0", + "ws": "8.16.0" + }, + "devDependencies": { + "@types/mime": "3.0.4" + } +} diff --git a/remote/test/puppeteer/packages/testserver/src/index.ts b/remote/test/puppeteer/packages/testserver/src/index.ts new file mode 100644 index 0000000000..2618fd4d0d --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/src/index.ts @@ -0,0 +1,311 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {readFile, readFileSync} from 'fs'; +import { + createServer as createHttpServer, + type IncomingMessage, + type RequestListener, + type Server as HttpServer, + type ServerResponse, +} from 'http'; +import { + createServer as createHttpsServer, + type Server as HttpsServer, + type ServerOptions as HttpsServerOptions, +} from 'https'; +import type {AddressInfo} from 'net'; +import {join} from 'path'; +import type {Duplex} from 'stream'; +import {gzip} from 'zlib'; + +import {getType as getMimeType} from 'mime'; +import {Server as WebSocketServer, type WebSocket} from 'ws'; + +interface Subscriber { + resolve: (msg: IncomingMessage) => void; + reject: (err?: Error) => void; + promise: Promise<IncomingMessage>; +} + +type TestIncomingMessage = IncomingMessage & {postBody?: Promise<string>}; + +export class TestServer { + PORT!: number; + PREFIX!: string; + CROSS_PROCESS_PREFIX!: string; + EMPTY_PAGE!: string; + + #dirPath: string; + #server: HttpsServer | HttpServer; + #wsServer: WebSocketServer; + + #startTime = new Date(); + #cachedPathPrefix?: string; + + #connections = new Set<Duplex>(); + #routes = new Map< + string, + (msg: IncomingMessage, res: ServerResponse) => void + >(); + #auths = new Map<string, {username: string; password: string}>(); + #csp = new Map<string, string>(); + #gzipRoutes = new Set<string>(); + #requestSubscribers = new Map<string, Subscriber>(); + #requests = new Set<ServerResponse>(); + + static async create(dirPath: string): Promise<TestServer> { + let res!: (value: unknown) => void; + const promise = new Promise(resolve => { + res = resolve; + }); + const server = new TestServer(dirPath); + server.#server.once('listening', res); + server.#server.listen(0); + await promise; + return server; + } + + static async createHTTPS(dirPath: string): Promise<TestServer> { + let res!: (value: unknown) => void; + const promise = new Promise(resolve => { + res = resolve; + }); + const server = new TestServer(dirPath, { + key: readFileSync(join(__dirname, '..', 'key.pem')), + cert: readFileSync(join(__dirname, '..', 'cert.pem')), + passphrase: 'aaaa', + }); + server.#server.once('listening', res); + server.#server.listen(0); + await promise; + return server; + } + + constructor(dirPath: string, sslOptions?: HttpsServerOptions) { + this.#dirPath = dirPath; + + if (sslOptions) { + this.#server = createHttpsServer(sslOptions, this.#onRequest); + } else { + this.#server = createHttpServer(this.#onRequest); + } + this.#server.on('connection', this.#onServerConnection); + // Disable this as sometimes the socket will timeout + // We rely on the fact that we will close the server at the end + this.#server.keepAliveTimeout = 0; + this.#wsServer = new WebSocketServer({server: this.#server}); + this.#wsServer.on('connection', this.#onWebSocketConnection); + } + + #onServerConnection = (connection: Duplex): void => { + this.#connections.add(connection); + // ECONNRESET is a legit error given + // that tab closing simply kills process. + connection.on('error', error => { + if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') { + throw error; + } + }); + connection.once('close', () => { + return this.#connections.delete(connection); + }); + }; + + get port(): number { + return (this.#server.address() as AddressInfo).port; + } + + enableHTTPCache(pathPrefix: string): void { + this.#cachedPathPrefix = pathPrefix; + } + + setAuth(path: string, username: string, password: string): void { + this.#auths.set(path, {username, password}); + } + + enableGzip(path: string): void { + this.#gzipRoutes.add(path); + } + + setCSP(path: string, csp: string): void { + this.#csp.set(path, csp); + } + + async stop(): Promise<void> { + this.reset(); + for (const socket of this.#connections) { + socket.destroy(); + } + this.#connections.clear(); + await new Promise(x => { + return this.#server.close(x); + }); + } + + setRoute( + path: string, + handler: (req: IncomingMessage, res: ServerResponse) => void + ): void { + this.#routes.set(path, handler); + } + + setRedirect(from: string, to: string): void { + this.setRoute(from, (_, res) => { + res.writeHead(302, {location: to}); + res.end(); + }); + } + + waitForRequest(path: string): Promise<TestIncomingMessage> { + const subscriber = this.#requestSubscribers.get(path); + if (subscriber) { + return subscriber.promise; + } + let resolve!: (value: IncomingMessage) => void; + let reject!: (reason?: Error) => void; + const promise = new Promise<IncomingMessage>((res, rej) => { + resolve = res; + reject = rej; + }); + this.#requestSubscribers.set(path, {resolve, reject, promise}); + return promise; + } + + reset(): void { + this.#routes.clear(); + this.#auths.clear(); + this.#csp.clear(); + this.#gzipRoutes.clear(); + const error = new Error('Static Server has been reset'); + for (const subscriber of this.#requestSubscribers.values()) { + subscriber.reject.call(undefined, error); + } + this.#requestSubscribers.clear(); + for (const request of this.#requests.values()) { + if (!request.writableEnded) { + request.end(); + } + } + this.#requests.clear(); + } + + #onRequest: RequestListener = ( + request: TestIncomingMessage, + response + ): void => { + this.#requests.add(response); + + request.on('error', (error: {code: string}) => { + if (error.code === 'ECONNRESET') { + response.end(); + } else { + throw error; + } + }); + request.postBody = new Promise(resolve => { + let body = ''; + request.on('data', (chunk: string) => { + return (body += chunk); + }); + request.on('end', () => { + return resolve(body); + }); + }); + assert(request.url); + const url = new URL(request.url, `https://${request.headers.host}`); + const path = url.pathname + url.search; + const auth = this.#auths.get(path); + if (auth) { + const credentials = Buffer.from( + (request.headers.authorization || '').split(' ')[1] || '', + 'base64' + ).toString(); + if (credentials !== `${auth.username}:${auth.password}`) { + response.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="Secure Area"', + }); + response.end('HTTP Error 401 Unauthorized: Access is denied'); + return; + } + } + const subscriber = this.#requestSubscribers.get(path); + if (subscriber) { + subscriber.resolve.call(undefined, request); + this.#requestSubscribers.delete(path); + } + const handler = this.#routes.get(path); + if (handler) { + handler.call(undefined, request, response); + } else { + this.serveFile(request, response, path); + } + }; + + serveFile( + request: IncomingMessage, + response: ServerResponse, + pathName: string + ): void { + if (pathName === '/') { + pathName = '/index.html'; + } + const filePath = join(this.#dirPath, pathName.substring(1)); + + if (this.#cachedPathPrefix && filePath.startsWith(this.#cachedPathPrefix)) { + if (request.headers['if-modified-since']) { + response.statusCode = 304; // not modified + response.end(); + return; + } + response.setHeader('Cache-Control', 'public, max-age=31536000'); + response.setHeader('Last-Modified', this.#startTime.toISOString()); + } else { + response.setHeader('Cache-Control', 'no-cache, no-store'); + } + const csp = this.#csp.get(pathName); + if (csp) { + response.setHeader('Content-Security-Policy', csp); + } + + readFile(filePath, (err, data) => { + // This can happen if the request is not awaited but started + // in the test and get clean via `reset()` + if (response.writableEnded) { + return; + } + + if (err) { + response.statusCode = 404; + response.end(`File not found: ${filePath}`); + return; + } + const mimeType = getMimeType(filePath); + if (mimeType) { + const isTextEncoding = /^text\/|^application\/(javascript|json)/.test( + mimeType + ); + const contentType = isTextEncoding + ? `${mimeType}; charset=utf-8` + : mimeType; + response.setHeader('Content-Type', contentType); + } + if (this.#gzipRoutes.has(pathName)) { + response.setHeader('Content-Encoding', 'gzip'); + gzip(data, (_, result) => { + response.end(result); + }); + } else { + response.end(data); + } + }); + } + + #onWebSocketConnection = (socket: WebSocket): void => { + socket.send('opened'); + }; +} diff --git a/remote/test/puppeteer/packages/testserver/tsconfig.json b/remote/test/puppeteer/packages/testserver/tsconfig.json new file mode 100644 index 0000000000..08e6681481 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "composite": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib", + "rootDir": "src", + }, + "include": ["src"], +} diff --git a/remote/test/puppeteer/packages/testserver/tsdoc.json b/remote/test/puppeteer/packages/testserver/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/release-please-config.json b/remote/test/puppeteer/release-please-config.json new file mode 100644 index 0000000000..9438312bbe --- /dev/null +++ b/remote/test/puppeteer/release-please-config.json @@ -0,0 +1,29 @@ +{ + "last-release-sha": "30e5b1a58edb8b1d94acdff00d64c76e76cf02a3", + "packages": { + "packages/puppeteer": { + "component": "puppeteer" + }, + "packages/puppeteer-core": { + "component": "puppeteer-core" + }, + "packages/testserver": {}, + "packages/ng-schematics": { + "bump-minor-pre-major": true, + "separate-pull-requests": true + }, + "packages/browsers": {} + }, + "plugins": [ + { + "type": "node-workspace", + "merge": false + }, + { + "type": "linked-versions", + "group-name": "puppeteer", + "groupName": "puppeteer", + "components": ["puppeteer", "puppeteer-core"] + } + ] +} diff --git a/remote/test/puppeteer/test-d/CommonEventEmitter.test-d.ts b/remote/test/puppeteer/test-d/CommonEventEmitter.test-d.ts new file mode 100644 index 0000000000..581b248b8a --- /dev/null +++ b/remote/test/puppeteer/test-d/CommonEventEmitter.test-d.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line no-restricted-imports +import {EventEmitter as NodeEventEmitter} from 'node:events'; + +import {expectAssignable} from 'tsd'; + +import type {CommonEventEmitter, EventEmitter, EventType} from 'puppeteer'; + +declare const emitter: EventEmitter<Record<EventType, any>>; + +{ + { + expectAssignable<CommonEventEmitter<Record<EventType, any>>>( + new NodeEventEmitter() + ); + } + { + expectAssignable<CommonEventEmitter<Record<EventType, any>>>(emitter); + } +} diff --git a/remote/test/puppeteer/test-d/ElementHandle.test-d.ts b/remote/test/puppeteer/test-d/ElementHandle.test-d.ts new file mode 100644 index 0000000000..469150933b --- /dev/null +++ b/remote/test/puppeteer/test-d/ElementHandle.test-d.ts @@ -0,0 +1,1025 @@ +import {expectNotType, expectType} from 'tsd'; + +import type {ElementHandle} from 'puppeteer'; + +declare const handle: ElementHandle; + +{ + { + { + expectType<ElementHandle<HTMLAnchorElement> | null>(await handle.$('a')); + expectNotType<ElementHandle<Element> | null>(await handle.$('a')); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('a#id') + ); + expectNotType<ElementHandle<Element> | null>(await handle.$('a#id')); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('a.class') + ); + expectNotType<ElementHandle<Element> | null>(await handle.$('a.class')); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('a[attr=value]') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('a[attr=value]') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('a:psuedo-class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('a:pseudo-class') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('a:func(arg)') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('a:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<HTMLDivElement> | null>(await handle.$('div')); + expectNotType<ElementHandle<Element> | null>(await handle.$('div')); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div#id') + ); + expectNotType<ElementHandle<Element> | null>(await handle.$('div#id')); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div.class') + ); + expectNotType<ElementHandle<Element> | null>(await handle.$('div.class')); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div[attr=value]') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div[attr=value]') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div:psuedo-class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div:pseudo-class') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div:func(arg)') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<Element> | null>(await handle.$('some-custom')); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('some-custom#id') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('some-custom.class') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('some-custom[attr=value]') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('some-custom:pseudo-class') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('some-custom:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<Element> | null>(await handle.$('')); + } + { + expectType<ElementHandle<Element> | null>(await handle.$('#id')); + } + { + expectType<ElementHandle<Element> | null>(await handle.$('.class')); + } + { + expectType<ElementHandle<Element> | null>(await handle.$('[attr=value]')); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$(':pseudo-class') + ); + } + { + expectType<ElementHandle<Element> | null>(await handle.$(':func(arg)')); + } + } + { + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a') + ); + expectNotType<ElementHandle<Element> | null>(await handle.$('div > a')); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a#id') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a#id') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a.class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a.class') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a[attr=value]') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a[attr=value]') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a:psuedo-class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a:pseudo-class') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a:func(arg)') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div') + ); + expectNotType<ElementHandle<Element> | null>(await handle.$('div > div')); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div#id') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div#id') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div.class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div.class') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div[attr=value]') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div[attr=value]') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div:psuedo-class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div:pseudo-class') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div:func(arg)') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom#id') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom.class') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom[attr=value]') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom:pseudo-class') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<Element> | null>(await handle.$('div > #id')); + } + { + expectType<ElementHandle<Element> | null>(await handle.$('div > .class')); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > [attr=value]') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > :pseudo-class') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > :func(arg)') + ); + } + } + { + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a') + ); + expectNotType<ElementHandle<Element> | null>(await handle.$('div > a')); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a#id') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a#id') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a.class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a.class') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a[attr=value]') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a[attr=value]') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a:psuedo-class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a:pseudo-class') + ); + } + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.$('div > a:func(arg)') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > a:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div') + ); + expectNotType<ElementHandle<Element> | null>(await handle.$('div > div')); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div#id') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div#id') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div.class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div.class') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div[attr=value]') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div[attr=value]') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div:psuedo-class') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div:pseudo-class') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.$('div > div:func(arg)') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.$('div > div:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom#id') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom.class') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom[attr=value]') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom:pseudo-class') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > some-custom:func(arg)') + ); + } + } + { + { + expectType<ElementHandle<Element> | null>(await handle.$('div > #id')); + } + { + expectType<ElementHandle<Element> | null>(await handle.$('div > .class')); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > [attr=value]') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > :pseudo-class') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.$('div > :func(arg)') + ); + } + } +} + +{ + { + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>(await handle.$$('a')); + expectNotType<Array<ElementHandle<Element>>>(await handle.$$('a')); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('a#id') + ); + expectNotType<Array<ElementHandle<Element>>>(await handle.$$('a#id')); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('a.class') + ); + expectNotType<Array<ElementHandle<Element>>>(await handle.$$('a.class')); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('a[attr=value]') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('a[attr=value]') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('a:psuedo-class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('a:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('a:func(arg)') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('a:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<HTMLDivElement>>>(await handle.$$('div')); + expectNotType<Array<ElementHandle<Element>>>(await handle.$$('div')); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div#id') + ); + expectNotType<Array<ElementHandle<Element>>>(await handle.$$('div#id')); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div.class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div.class') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div[attr=value]') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div[attr=value]') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div:psuedo-class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div:func(arg)') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<Element>>>(await handle.$$('some-custom')); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('some-custom#id') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('some-custom.class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('some-custom[attr=value]') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('some-custom:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('some-custom:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<Element>>>(await handle.$$('')); + } + { + expectType<Array<ElementHandle<Element>>>(await handle.$$('#id')); + } + { + expectType<Array<ElementHandle<Element>>>(await handle.$$('.class')); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('[attr=value]') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$(':pseudo-class') + ); + } + { + expectType<Array<ElementHandle<Element>>>(await handle.$$(':func(arg)')); + } + } + { + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a') + ); + expectNotType<Array<ElementHandle<Element>>>(await handle.$$('div > a')); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a#id') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a#id') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a.class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a.class') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a[attr=value]') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a[attr=value]') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a:psuedo-class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a:func(arg)') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div#id') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div#id') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div.class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div.class') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div[attr=value]') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div[attr=value]') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div:psuedo-class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div:func(arg)') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom#id') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom.class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom[attr=value]') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<Element>>>(await handle.$$('div > #id')); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > .class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > [attr=value]') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > :pseudo-class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > :func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a') + ); + expectNotType<Array<ElementHandle<Element>>>(await handle.$$('div > a')); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a#id') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a#id') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a.class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a.class') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a[attr=value]') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a[attr=value]') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a:psuedo-class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<HTMLAnchorElement>>>( + await handle.$$('div > a:func(arg)') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > a:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div#id') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div#id') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div.class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div.class') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div[attr=value]') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div[attr=value]') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div:psuedo-class') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<HTMLDivElement>>>( + await handle.$$('div > div:func(arg)') + ); + expectNotType<Array<ElementHandle<Element>>>( + await handle.$$('div > div:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom#id') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom.class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom[attr=value]') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom:pseudo-class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > some-custom:func(arg)') + ); + } + } + { + { + expectType<Array<ElementHandle<Element>>>(await handle.$$('div > #id')); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > .class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > [attr=value]') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > :pseudo-class') + ); + } + { + expectType<Array<ElementHandle<Element>>>( + await handle.$$('div > :func(arg)') + ); + } + } +} + +{ + expectType<void>( + await handle.$eval( + 'a', + (element, int) => { + expectType<HTMLAnchorElement>(element); + expectType<number>(int); + }, + 1 + ) + ); + expectType<void>( + await handle.$eval( + 'div', + (element, int, str) => { + expectType<HTMLDivElement>(element); + expectType<number>(int); + expectType<string>(str); + }, + 1, + '' + ) + ); + expectType<number>( + await handle.$eval( + 'a', + (element, value) => { + expectType<HTMLAnchorElement>(element); + return value; + }, + 1 + ) + ); + expectType<number>( + await handle.$eval( + 'some-element', + (element, value) => { + expectType<Element>(element); + return value; + }, + 1 + ) + ); + expectType<HTMLAnchorElement>( + await handle.$eval('a', element => { + return element; + }) + ); + expectType<unknown>(await handle.$eval('a', 'document')); +} + +{ + expectType<void>( + await handle.$$eval( + 'a', + (elements, int) => { + expectType<HTMLAnchorElement[]>(elements); + expectType<number>(int); + }, + 1 + ) + ); + expectType<void>( + await handle.$$eval( + 'div', + (elements, int, str) => { + expectType<HTMLDivElement[]>(elements); + expectType<number>(int); + expectType<string>(str); + }, + 1, + '' + ) + ); + expectType<number>( + await handle.$$eval( + 'a', + (elements, value) => { + expectType<HTMLAnchorElement[]>(elements); + return value; + }, + 1 + ) + ); + expectType<number>( + await handle.$$eval( + 'some-element', + (elements, value) => { + expectType<Element[]>(elements); + return value; + }, + 1 + ) + ); + expectType<HTMLAnchorElement[]>( + await handle.$$eval('a', elements => { + return elements; + }) + ); + expectType<unknown>(await handle.$$eval('a', 'document')); +} + +{ + { + expectType<ElementHandle<HTMLAnchorElement> | null>( + await handle.waitForSelector('a') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.waitForSelector('a') + ); + } + { + expectType<ElementHandle<HTMLDivElement> | null>( + await handle.waitForSelector('div') + ); + expectNotType<ElementHandle<Element> | null>( + await handle.waitForSelector('div') + ); + } + { + expectType<ElementHandle<Element> | null>( + await handle.waitForSelector('some-custom') + ); + } +} diff --git a/remote/test/puppeteer/test-d/JSHandle.test-d.ts b/remote/test/puppeteer/test-d/JSHandle.test-d.ts new file mode 100644 index 0000000000..fa7348d28e --- /dev/null +++ b/remote/test/puppeteer/test-d/JSHandle.test-d.ts @@ -0,0 +1,84 @@ +import {expectNotAssignable, expectNotType, expectType} from 'tsd'; + +import type {ElementHandle, JSHandle} from 'puppeteer'; + +declare const handle: JSHandle; + +{ + expectType<unknown>(await handle.evaluate('document')); + expectType<number>( + await handle.evaluate(() => { + return 1; + }) + ); + expectType<HTMLElement>( + await handle.evaluate(() => { + return document.body; + }) + ); + expectType<string>( + await handle.evaluate(() => { + return ''; + }) + ); + expectType<string>( + await handle.evaluate((value, str) => { + expectNotAssignable<never>(value); + expectType<string>(str); + return ''; + }, '') + ); +} + +{ + expectType<JSHandle>(await handle.evaluateHandle('document')); + expectType<JSHandle<number>>( + await handle.evaluateHandle(() => { + return 1; + }) + ); + expectType<JSHandle<string>>( + await handle.evaluateHandle(() => { + return ''; + }) + ); + expectType<JSHandle<string>>( + await handle.evaluateHandle((value, str) => { + expectNotAssignable<never>(value); + expectType<string>(str); + return ''; + }, '') + ); + expectType<ElementHandle<HTMLElement>>( + await handle.evaluateHandle(() => { + return document.body; + }) + ); +} + +declare const handle2: JSHandle<{test: number}>; + +{ + { + expectType<JSHandle<number>>(await handle2.getProperty('test')); + expectNotType<JSHandle<unknown>>(await handle2.getProperty('test')); + } + { + expectType<JSHandle<unknown>>( + await handle2.getProperty('key-doesnt-exist') + ); + expectNotType<JSHandle<string>>( + await handle2.getProperty('key-doesnt-exist') + ); + expectNotType<JSHandle<number>>( + await handle2.getProperty('key-doesnt-exist') + ); + } +} + +{ + void handle.evaluate((value, other) => { + expectType<unknown>(value); + expectType<{test: number}>(other); + }, handle2); +} diff --git a/remote/test/puppeteer/test-d/NodeFor.test-d.ts b/remote/test/puppeteer/test-d/NodeFor.test-d.ts new file mode 100644 index 0000000000..0a40b4e689 --- /dev/null +++ b/remote/test/puppeteer/test-d/NodeFor.test-d.ts @@ -0,0 +1,157 @@ +import {expectType, expectNotType} from 'tsd'; + +import type {NodeFor} from 'puppeteer'; + +declare const nodeFor: <Selector extends string>( + selector: Selector +) => NodeFor<Selector>; + +{ + { + expectType<HTMLTableRowElement>( + nodeFor( + '[data-testid="my-component"] div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div tbody tr' + ) + ); + expectNotType<Element>( + nodeFor( + '[data-testid="my-component"] div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div div tbody tr' + ) + ); + } + { + expectType<HTMLAnchorElement>(nodeFor('a')); + expectNotType<Element>(nodeFor('a')); + } + { + expectType<HTMLAnchorElement>(nodeFor('a#ignored')); + expectNotType<Element>(nodeFor('a#ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('a.ignored')); + expectNotType<Element>(nodeFor('a.ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('a[ignored')); + expectNotType<Element>(nodeFor('a[ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('a:ignored')); + expectNotType<Element>(nodeFor('a:ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored a')); + expectNotType<Element>(nodeFor('ignored a')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored a#ignored')); + expectNotType<Element>(nodeFor('ignored a#ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored a.ignored')); + expectNotType<Element>(nodeFor('ignored a.ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored a[ignored')); + expectNotType<Element>(nodeFor('ignored a[ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored a:ignored')); + expectNotType<Element>(nodeFor('ignored a:ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored > a')); + expectNotType<Element>(nodeFor('ignored > a')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored > a#ignored')); + expectNotType<Element>(nodeFor('ignored > a#ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored > a.ignored')); + expectNotType<Element>(nodeFor('ignored > a.ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored > a[ignored')); + expectNotType<Element>(nodeFor('ignored > a[ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored > a:ignored')); + expectNotType<Element>(nodeFor('ignored > a:ignored')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored + a')); + expectNotType<Element>(nodeFor('ignored + a')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored ~ a')); + expectNotType<Element>(nodeFor('ignored ~ a')); + } + { + expectType<HTMLAnchorElement>(nodeFor('ignored | a')); + expectNotType<Element>(nodeFor('ignored | a')); + } + { + expectType<HTMLAnchorElement>( + nodeFor('ignored ignored > ignored + ignored | a#ignore') + ); + expectNotType<Element>( + nodeFor('ignored ignored > ignored + ignored | a#ignore') + ); + } +} +{ + { + expectType<Element>(nodeFor('')); + } + { + expectType<Element>(nodeFor('#ignored')); + } + { + expectType<Element>(nodeFor('.ignored')); + } + { + expectType<Element>(nodeFor('[ignored')); + } + { + expectType<Element>(nodeFor(':ignored')); + } + { + expectType<Element>(nodeFor('ignored #ignored')); + } + { + expectType<Element>(nodeFor('ignored .ignored')); + } + { + expectType<Element>(nodeFor('ignored [ignored')); + } + { + expectType<Element>(nodeFor('ignored :ignored')); + } + { + expectType<Element>(nodeFor('ignored > #ignored')); + } + { + expectType<Element>(nodeFor('ignored > .ignored')); + } + { + expectType<Element>(nodeFor('ignored > [ignored')); + } + { + expectType<Element>(nodeFor('ignored > :ignored')); + } + { + expectType<Element>(nodeFor('ignored + #ignored')); + } + { + expectType<Element>(nodeFor('ignored ~ #ignored')); + } + { + expectType<Element>(nodeFor('ignored | #ignored')); + } + { + expectType<Element>( + nodeFor('ignored ignored > ignored ~ ignored + ignored | #ignored') + ); + } +} diff --git a/remote/test/puppeteer/test-d/puppeteer.test-d.ts b/remote/test/puppeteer/test-d/puppeteer.test-d.ts new file mode 100644 index 0000000000..f7a45c0db4 --- /dev/null +++ b/remote/test/puppeteer/test-d/puppeteer.test-d.ts @@ -0,0 +1,13 @@ +import {expectType} from 'tsd'; + +import puppeteer, { + type connect, + type defaultArgs, + type executablePath, + type launch, +} from 'puppeteer'; + +expectType<typeof launch>(puppeteer.launch); +expectType<typeof connect>(puppeteer.connect); +expectType<typeof defaultArgs>(puppeteer.defaultArgs); +expectType<typeof executablePath>(puppeteer.executablePath); diff --git a/remote/test/puppeteer/test/.eslintrc.js b/remote/test/puppeteer/test/.eslintrc.js new file mode 100644 index 0000000000..489868b6ed --- /dev/null +++ b/remote/test/puppeteer/test/.eslintrc.js @@ -0,0 +1,38 @@ +module.exports = { + rules: { + 'no-restricted-imports': [ + 'error', + { + /** The mocha tests run on the compiled output in the /lib directory + * so we should avoid importing from src. + */ + patterns: ['*src*'], + }, + ], + }, + overrides: [ + { + files: ['*.spec.ts'], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + {argsIgnorePattern: '^_', varsIgnorePattern: '^_'}, + ], + 'no-restricted-syntax': [ + 'error', + { + message: + 'Use helper command `launch` to make sure the browsers get cleaned', + selector: + 'MemberExpression[object.name="puppeteer"][property.name="launch"]', + }, + { + message: 'Unexpected debugging mocha test.', + selector: + 'CallExpression[callee.object.name="it"] > MemberExpression > Identifier[name="deflake"], CallExpression[callee.object.name="it"] > MemberExpression > Identifier[name="deflakeOnly"]', + }, + ], + }, + }, + ], +}; diff --git a/remote/test/puppeteer/test/README.md b/remote/test/puppeteer/test/README.md new file mode 100644 index 0000000000..72085ecfb2 --- /dev/null +++ b/remote/test/puppeteer/test/README.md @@ -0,0 +1,95 @@ +# Puppeteer tests + +Unit tests in Puppeteer are written using [Mocha] as the test runner and [Expect] as the assertions library. + +## Test state + +We have some common setup that runs before each test and is defined in `mocha-utils.js`. + +You can use the `getTestState` function to read state. It exposes the following that you can use in your tests. These will be reset/tidied between tests automatically for you: + +- `puppeteer`: an instance of the Puppeteer library. This is exactly what you'd get if you ran `require('puppeteer')`. +- `puppeteerPath`: the path to the root source file for Puppeteer. +- `defaultBrowserOptions`: the default options the Puppeteer browser is launched from in test mode, so tests can use them and override if required. +- `server`: a dummy test server instance (see `packages/testserver` for more). +- `httpsServer`: a dummy test server HTTPS instance (see `packages/testserver` for more). +- `isFirefox`: true if running in Firefox. +- `isChrome`: true if running Chromium. +- `isHeadless`: true if the test is in headless mode. + +If your test needs a browser instance, you can use the `setupTestBrowserHooks()` function which will automatically configure a browser that will be cleaned between each test suite run. You access this via `getTestState()`. + +If your test needs a Puppeteer page and context, you can use the `setupTestPageAndContextHooks()` function which will configure these. You can access `page` and `context` from `getTestState()` once you have done this. + +The best place to look is an existing test to see how they use the helpers. + +## Skipping tests in specific conditions + +To skip tests edit the [TestExpectations](https://github.com/puppeteer/puppeteer/blob/main/test/TestExpectations.json) file. See [test runner documentation](https://github.com/puppeteer/puppeteer/tree/main/tools/mocha-runner) for more details. + +## Running tests + +- To run all tests applicable for your platform: + +```bash +npm test +``` + +- **Important**: don't forget to first build the code if you're testing local changes: + +```bash +npm run build --workspace=@puppeteer-test/test && npm test +``` + +### CLI options + +| Description | Option | Type | +| ----------------------------------------------------------------- | ---------------- | ------- | +| Do not generate coverage report | --no-coverage | boolean | +| Do not generate suggestion for updating TestExpectation.json file | --no-suggestions | boolean | +| Specify a file to which to save run data | --save-stats-to | string | +| Specify a file with a custom Mocha reporter | --reporter | string | +| Number of times to retry failed tests. | --retries | number | +| Timeout threshold value. | --timeout | number | +| Tell Mocha to not run test files in parallel | --no-parallel | boolean | +| Generate full stacktrace upon failure | --fullTrace | boolean | +| Name of the Test suit defined in TestSuites.json | --test-suite | string | + +### Helpful information + +- To run a specific test, substitute the `it` with `it.only`: + +```ts + ... + it.only('should work', async function() { + const {server, page} = await getTestState(); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok).toBe(true); + }); +``` + +- To disable a specific test, substitute the `it` with `it.skip`: + +```ts + ... + it.skip('should work', async function({server, page}) { + const {server, page} = await getTestState(); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok).toBe(true); + }); +``` + +- To run Chrome headful tests: + +```bash +npm run test:chrome:headful +``` + +- To run tests with custom browser executable: + +```bash +BINARY=<path-to-executable> npm run test:chrome:headless # Or npm run test:firefox +``` + +[mocha]: https://mochajs.org/ +[expect]: https://www.npmjs.com/package/expect diff --git a/remote/test/puppeteer/test/TestExpectations.json b/remote/test/puppeteer/test/TestExpectations.json new file mode 100644 index 0000000000..f19c2f72e0 --- /dev/null +++ b/remote/test/puppeteer/test/TestExpectations.json @@ -0,0 +1,3714 @@ +[ + { + "testIdPattern": "*", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP", "TIMEOUT"] + }, + { + "testIdPattern": "[autofill.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[autofill.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[bfcache.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext BrowserContext.overridePermissions *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[chromiumonly.spec] Chromium-Specific Launcher tests *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[click.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[debugInfo.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[debugInfo.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[device-request-prompt.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[dialog.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[drag-and-drop.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[drag-and-drop.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[drag-and-drop.spec] Legacy Drag n' Drop *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[elementhandle.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[evaluation.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[fixtures.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[frame.spec] Frame specs *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[injected.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[jshandle.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch *", + "platforms": ["darwin", "linux"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch *", + "platforms": ["win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[locator.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[mouse.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[navigation.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goBack *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Network Events *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Page.authenticate *", + "platforms": ["darwin", "linux"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[network.spec] network Page.authenticate *", + "platforms": ["win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Page.setBypassServiceWorker *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Page.setBypassServiceWorker *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[network.spec] network Page.setExtraHTTPHeaders *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Request.isNavigationRequest *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Response.buffer *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[network.spec] network Response.json *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Response.text *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[page.spec] Page *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Popup *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[prerender.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[queryhandler.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[queryselector.spec] querySelector *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[screencast.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots Cdp *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[stacktrace.spec] Stack trace *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[waittask.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[accessibility.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP", "TIMEOUT"] + }, + { + "testIdPattern": "[accessibility.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryOne (Chromium web test) *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler waitForSelector (aria) should have an error message specifically for awaiting an element to be hidden", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler waitForSelector (aria) should have correct stack trace for timeout", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler waitForSelector (aria) should respect timeout", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler waitForSelector (aria) should throw when frame is detached", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[autofill.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "headless"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.process should return child_process instance", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.target should return browser target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext BrowserContext.overridePermissions should be prompt by default", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should close all belonging targets once closing context", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should create new incognito context", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should have default context", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should timeout waiting for a non-existent target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should wait for a target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext window.open should use parent tab context", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should not report created targets for custom CDP sessions", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[chromiumonly.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[chromiumonly.spec] Chromium-Specific Launcher tests Puppeteer.launch |pipe| option *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[click.spec] Page.click should click the button with fixed position inside an iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[Connection.spec] WebDriver BiDi Connection should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[coverage.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[coverage.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[coverage.spec] Coverage specs JSCoverage should ignore pptr internal scripts if reportAnonymousScripts is true", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[devtools.spec] DevTools should expose DevTools as a page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[devtools.spec] DevTools should open devtools when \"devtools: true\" option is given", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[devtools.spec] DevTools target.page() should return a DevTools page if asPage is used", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[devtools.spec] DevTools target.page() should return a DevTools page if custom isPageTarget is provided", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[drag-and-drop.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.boundingBox should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.click should return Point data", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.clickablePoint should not work if the click box is not visible due to the iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[emulation.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.viewport should get the proper viewport size", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.viewport should support mobile emulation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should replace symbols with undefined", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should return properly serialize objects with unknown type fields", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should work for circular object", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluateOnNewDocument *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.removeScriptToEvaluateOnNewDocument *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.removeScriptToEvaluateOnNewDocument *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[fixtures.spec] Fixtures dumpio option should work with pipe option", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should handle nested frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should report frame.name()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should support lazy frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[headful.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[idle_override.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[input.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[jshandle.spec] JSHandle JSHandle.jsonValue should not throw for circular objects", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[jshandle.spec] JSHandle JSHandle.jsonValue should work with dates", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[jshandle.spec] JSHandle Page.evaluateHandle should return the RemoteObject", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[jshandle.spec] JSHandle Page.evaluateHandle should use the same JS wrappers", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should send a character with sendCharacter in iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["TIMEOUT"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Browser.disconnect *", + "platforms": ["win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Browser.disconnect should reject navigation when browser closes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Browser.disconnect should reject waitForSelector when browser closes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.executablePath returns executablePath for channel", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.executablePath should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.executablePath when executable path is configured its value is used", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch can launch and close the browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should be able to launch Chrome", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should be able to launch Firefox", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should have custom URL when launching browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should launch Chrome properly with --no-startup-window and waitForInitialPage=false", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should work with no default arguments", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should work with no default arguments", + "platforms": ["linux"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should work with no default arguments", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch tmp profile should be cleaned up", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch userDataDir argument with non-existent dir", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[mouse.spec] Mouse should not throw if buttons are pressed twice", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[mouse.spec] Mouse should reset properly", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.goto should return matching responses", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should fail when navigating to bad SSL", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should send referer", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.RequestFinished", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Network Events should fire events in proper order", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Page.setBypassServiceWorker *", + "platforms": ["win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network raw network headers Cross-origin set-cookie", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Request.initiator should return the initiator", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Request.isNavigationRequest should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Request.isNavigationRequest should work when navigating to image", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Request.postData should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Request.postData should work with blobs", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"], + "comment": "Not implemented for BiDi yet." + }, + { + "testIdPattern": "[network.spec] network Response.fromServiceWorker Response.fromServiceWorker", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[network.spec] network Response.timing returns timing information", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[oopif.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF clickablePoint, boundingBox, boxModel should work for elements inside OOPIFs", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should provide access to elements", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should support evaluating in oop iframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should support frames within OOP frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should support frames within OOP iframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should track navigations within OOP iframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should treat OOP iframes and normal iframes the same", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF waitForFrame should resolve immediately if the frame already exists", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.addScriptTag should throw when added with content to the CSP page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.addStyleTag should throw when added with content to the CSP page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.bringToFront should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.close should *not* run beforeunload by default", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.close should *not* run beforeunload by default", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["TIMEOUT"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should have location when fetch fails", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should trigger correct Log", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should work for different console API calls with logging functions", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.error should throw when page crashes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.exposeFunction *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.exposeFunction should work with loading frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"], + "comment": "Missing request interception" + }, + { + "testIdPattern": "[page.spec] Page Page.metrics metrics event fired on console.timeStamp", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.metrics should get metrics from a page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.pdf can print to PDF with accessible", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.pdf should respect timeout", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.removeExposedFunction should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setCacheEnabled should stay disabled when toggling request interception on/off", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setGeolocation should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setOfflineMode should emulate navigator.onLine", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setOfflineMode should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setUserAgent *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.waitForNetworkIdle should work with aborted requests", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[prerender.spec] Prerender can screencast", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[prerender.spec] Prerender can screencast", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["new-headless"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[proxy.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[proxy.spec] request proxy in incognito browser context should respect proxy bypass list when configured at browser level", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy in incognito browser context should respect proxy bypass list when configured at context level", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy should respect proxy bypass list", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work ARIA selectors", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work ARIA selectors with name and role", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work ARIA selectors with role", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work for ARIA selectors in multiple isolated worlds", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work with :hover", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests Text selectors in Page should clear caches", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[queryObjects.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[requestinterception-experimental.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "SKIP"] + }, + { + "testIdPattern": "[requestinterception.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "SKIP"] + }, + { + "testIdPattern": "[screencast.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[screencast.spec] Screencasts Page.screencast should validate options", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots Page.screenshot should get screenshot bigger than the viewport", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[stacktrace.spec] Stack trace *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[target.spec] Target Browser.pages should return all of the pages", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target Browser.targets should return all of the targets", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target Browser.waitForTarget should timeout waiting for a non-existent target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[target.spec] Target should be able to use async waitForTarget", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target should be able to use the default page in the browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target should contain browser target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target should not crash while redirecting if original request was missed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target should report when a new page is created and closed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target should report when a target url changes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[TargetManager.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "SKIP"] + }, + { + "testIdPattern": "[touchscreen.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[tracing.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[tracing.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[worker.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[accessibility.spec] Accessibility filtering children of leaf nodes rich text editable fields should have children", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[accessibility.spec] Accessibility get snapshots while the tree is re-calculated", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler parseAriaSelector should find button", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryAll should find menu by name", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryAllArray $$eval should handle many elements", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryOne should find button by name and role", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryOne should find button by role", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryOne should find by name", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryOne should find first matching element", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[bfcache.spec] BFCache can navigate to a BFCached page containing an OOPIF and a worker", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.isConnected should set the browser connected state", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.isConnected should set the browser connected state", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.isConnected should set the browser connected state", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.process should not return child_process for remote browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.process should not return child_process for remote browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.target should return browser target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browser.spec] Browser specs Browser.version should return version", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext BrowserContext.overridePermissions should deny permission when not listed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext BrowserContext.overridePermissions should grant permission when listed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext BrowserContext.overridePermissions should grant persistent-storage", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext BrowserContext.overridePermissions should isolate permissions between browser contexts", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext BrowserContext.overridePermissions should reset permissions", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext BrowserContext.overridePermissions should trigger permission onchange", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should close all belonging targets once closing context", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should create new incognito context", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should fire target events", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should provide a context id", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should timeout waiting for a non-existent target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should wait for a target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should wait for a target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext should work across sessions", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[browsercontext.spec] BrowserContext window.open should use parent tab context", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should be able to detach session", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should be able to detach session", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should enable and disable domains independently", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should enable and disable domains independently", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should respect custom timeout", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should send events", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should send events", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should throw nice errors", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[chromiumonly.spec] Chromium-Specific Launcher tests Puppeteer.launch |browserURL| option should be able to connect using browserUrl, with and without trailing slash", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[click.spec] Page.click should click on checkbox input and toggle", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[click.spec] Page.click should click on checkbox label and toggle", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[click.spec] Page.click should click the button if window.Node is removed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[click.spec] Page.click should click the button inside an iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[click.spec] Page.click should click the button with deviceScaleFactor set", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[click.spec] Page.click should click the button with fixed position inside an iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[click.spec] Page.click should click the button with fixed position inside an iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[click.spec] Page.click should click the button with fixed position inside an iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[click.spec] Page.click should click with disabled javascript", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[click.spec] Page.click should click with disabled javascript", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[click.spec] Page.click should double click the button", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[click.spec] Page.click should scroll and click with disabled javascript", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[click.spec] Page.click should scroll and click with disabled javascript", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[click.spec] Page.click should select the text by triple clicking", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[click.spec] Page.click should select the text by triple clicking", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.cookies should get cookies from multiple urls", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.deleteCookie should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should default to setting secure cookie for HTTPS websites", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should isolate cookies in browser contexts", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should set a cookie on a different domain", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should set a cookie with a path", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should set cookie with reasonable defaults", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should set cookies from a frame", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should set multiple cookies", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should set secure same-site cookies from a frame", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[cookies.spec] Cookie specs Page.setCookie should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[coverage.spec] Coverage specs CSSCoverage should work with complicated usecases", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[coverage.spec] Coverage specs JSCoverage should not ignore eval() scripts if reportAnonymousScripts is true", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[defaultbrowsercontext.spec] DefaultBrowserContext page.cookies() should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[defaultbrowsercontext.spec] DefaultBrowserContext page.deleteCookie() should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[defaultbrowsercontext.spec] DefaultBrowserContext page.setCookie() should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[dialog.spec] Page.Events.Dialog should allow accepting prompts", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[dialog.spec] Page.Events.Dialog should allow accepting prompts", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[drag-and-drop.spec] Drag n' Drop should drag and drop", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.boundingBox should handle nested frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.boundingBox should handle nested frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.boxModel should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.click should return Point data", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.click should return Point data", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.clickablePoint should work for iframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.clickablePoint should work for iframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.contentFrame should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.isIntersectingViewport should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.isIntersectingViewport should work with svg elements", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulate should support clicking", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateCPUThrottling should change the CPU throttling rate successfully", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateMediaFeatures should throw in case of bad argument", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateMediaFeatures should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateMediaType should throw in case of bad argument", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateMediaType should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateNetworkConditions should change navigator.connection.effectiveType", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateNetworkConditions should change navigator.connection.effectiveType", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateTimezone should throw for invalid timezone IDs", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateTimezone should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateVisionDeficiency should throw for invalid vision deficiencies", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.emulateVisionDeficiency should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.viewport should detect touch when applying viewport with touches", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.viewport should get the proper viewport size", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.viewport should support landscape emulation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[emulation.spec] Emulation Page.viewport should support touch emulation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Frame.evaluate should have different execution contexts", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should await promise", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should simulate a user gesture", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should throw if elementHandles are from other frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should throw if elementHandles are from other frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should throw when evaluation triggers reload", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should work from-inside an exposed function", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[extensions.spec] extensions background_page target type should be available", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[extensions.spec] extensions service_worker target type should be available", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[extensions.spec] extensions target.page() should return a background_page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[extensions.spec] extensions target.page() should return a background_page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[fixtures.spec] Fixtures should close the browser when the node process closes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should detach child frames on navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should report different frame instance when frame re-attaches", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should report different frame instance when frame re-attaches", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should report frame from-inside shadow DOM", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should report frame.name()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should report frame.parent()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should report frame.parent()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should send events when frames are manipulated dynamically", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should send events when frames are manipulated dynamically", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should support framesets", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should support lazy frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame Management should support lazy frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame.evaluate should throw for detached frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame.evaluate should throw for detached frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[frame.spec] Frame specs Frame.executionContext should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors Response.securityDetails Network redirects should report SecurityDetails", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors Response.securityDetails should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors should work with mixed content", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors should work with mixed content", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors should work with mixed content", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[ignorehttpserrors.spec] ignoreHTTPSErrors should work with request interception", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard ElementHandle.press should not support |text| option", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should report shiftKey", + "platforms": ["darwin"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should send a character with sendCharacter", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should send a character with sendCharacter in iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should specify location", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should specify repeat property", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should type all kinds of characters", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should type emoji", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should type emoji into an iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[keyboard.spec] Keyboard should type emoji into an iframe", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Browser target events should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Browser.Events.disconnected should be emitted when: browser gets closed, disconnected or underlying websocket gets closed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Browser.close should terminate network waiters", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Browser.close should terminate network waiters", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Browser.disconnect should reject navigation when browser closes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should be able to close remote browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should be able to connect multiple times to the same browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should be able to connect to a browser with no page targets", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should be able to connect to a browser with no page targets", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should be able to connect to the same page simultaneously", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should be able to connect to the same page simultaneously", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should be able to reconnect", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should be able to reconnect to a disconnected browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should support ignoreHTTPSErrors option", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should support targetFilter option", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.connect should support targetFilter option", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.executablePath returns executablePath for channel", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.executablePath should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch can launch and close the browser", + "platforms": ["win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should be able to launch Chrome", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should be able to launch Firefox", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should be able to launch Firefox", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "headless"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should close browser with beforeunload page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should filter out ignored default argument in Firefox", + "platforms": ["linux"], + "parameters": ["firefox", "headful"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should filter out ignored default arguments in Chrome", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch should have custom URL when launching browser", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch tmp profile should be cleaned up", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch tmp profile should be cleaned up", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch userDataDir option should restore cookies", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[locator.spec] Locator Locator.click should work with a OOPIF", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[locator.spec] Locator Locator.click should work with a OOPIF", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[locator.spec] Locator Locator.race races multiple locators", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[mouse.spec] Mouse should send mouse wheel events", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[mouse.spec] Mouse should trigger hover state with removed window.Node", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.goto should navigate subframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.goto should navigate subframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.goto should reject when frame detaches", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.goto should return matching responses", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.waitForNavigation should fail when frame detaches", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.waitForNavigation should fail when frame detaches", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.waitForNavigation should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Frame.waitForNavigation should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS", "TIMEOUT"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goBack should work with HistoryAPI", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should fail when main resources failed to load", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should fail when server returns 204", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should navigate to dataURL and fire dataURL requests", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should navigate to dataURL and fire dataURL requests", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should navigate to dataURL and fire dataURL requests", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should navigate to empty page with networkidle0", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should navigate to empty page with networkidle2", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should navigate to page with iframe and networkidle0", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["TIMEOUT"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should navigate to URL with hash and fire requests without hash", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should navigate to URL with hash and fire requests without hash", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should not leak listeners during navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL", "PASS", "TIMEOUT"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should not leak listeners during navigation of 11 pages", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should return response when page changes its URL after load", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should send referer", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should wait for network idle to succeed navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should work when navigating to data url", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should work when navigating to data url", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should work when page calls history API in beforeunload", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should work with anchor navigation", + "platforms": ["linux"], + "parameters": ["chrome", "headless"], + "expectations": ["PASS", "TIMEOUT"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should work with anchor navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "headless"], + "expectations": ["PASS", "TIMEOUT"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.goto should work with subframes return 204", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.waitForNavigation should work when subframe issues window.stop()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.waitForNavigation should work when subframe issues window.stop()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.waitForNavigation should work when subframe issues window.stop()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.waitForNavigation should work with DOM history.back()/history.forward()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.waitForNavigation should work with history.pushState()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[navigation.spec] navigation Page.waitForNavigation should work with history.replaceState()", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.Request", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.Request", + "platforms": ["win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.RequestFailed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.RequestFinished", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.RequestServedFromCache", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.RequestServedFromCache", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.Response", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Network Events should fire events in proper order", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Network Events should fire events in proper order", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Network Events should support redirects", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Network Events should support redirects", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Page.authenticate should allow disable authentication", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Page.authenticate should fail if wrong credentials", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Page.authenticate should not disable caching", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Page.authenticate should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Page.Events.Request should fire for iframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Page.Events.Request should fire for iframes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Page.setExtraHTTPHeaders should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network raw network headers Same-origin set-cookie navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network raw network headers Same-origin set-cookie navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network raw network headers Same-origin set-cookie subresource", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network raw network headers Same-origin set-cookie subresource", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network raw network headers Same-origin set-cookie subresource", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network raw network headers Same-origin set-cookie subresource", + "platforms": ["win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Request.frame should work for subframe navigation request", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Request.frame should work for subframe navigation request", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Request.initiator should return the initiator", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Request.isNavigationRequest should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Request.isNavigationRequest should work with request interception", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Request.postData should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Request.postData should work with blobs", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"], + "comment": "Blobs have no POST data in Firefox's CDP implementation." + }, + { + "testIdPattern": "[network.spec] network Response.buffer should throw if the response does not have a body", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.buffer should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.buffer should work with compression", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.fromCache should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Response.fromCache should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network Response.fromCache should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Response.fromServiceWorker Response.fromServiceWorker", + "platforms": ["win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.fromServiceWorker Response.fromServiceWorker", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.headers should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[network.spec] network Response.json should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.text should return uncompressed text", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.text should throw when requesting body of redirected response", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[network.spec] network Response.text should wait until response completes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.text should wait until response completes", + "platforms": ["win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.text should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Response.timing returns timing information", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF clickablePoint, boundingBox, boxModel should work for elements inside OOPIFs", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should keep track of a frames OOP state", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should provide access to elements", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should support lazy OOP frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should support lazy OOP frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should support lazy OOP frames", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[oopif.spec] OOPIF should support wait for navigation for transitions from local to OOPIF", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["PASS", "TIMEOUT"] + }, + { + "testIdPattern": "[page.spec] Page Page.addScriptTag should throw when added with content to the CSP page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.addScriptTag should throw when added with content to the CSP page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.bringToFront should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.client should return the client instance", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.close should reject all promises when page is closed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.close should run beforeunload if asked for", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.close should run beforeunload if asked for", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.close should run beforeunload if asked for", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.close should terminate network waiters", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should have location and stack trace for console API calls", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should have location and stack trace for console API calls", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should have location and stack trace for console API calls", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should have location when fetch fails", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should not fail for window object", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should trigger correct Log", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should work for different console API calls with logging functions", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Console should work for different console API calls with timing functions", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.error should throw when page crashes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Popup should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Popup should work with clicking target=_blank and rel=noopener", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Popup should work with clicking target=_blank and with rel=opener", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Popup should work with clicking target=_blank and without rel=opener", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Popup should work with fake-clicking target=_blank and rel=noopener", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.Events.Popup should work with noopener", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.exposeFunction should be callable from-inside evaluateOnNewDocument", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.metrics metrics event fired on console.timeStamp", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.metrics should get metrics from a page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.pdf can print to PDF with accessible", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[page.spec] Page Page.removeExposedFunction should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.select should work when re-defining top-level Event class", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP should bypass after cross-process navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP should bypass after cross-process navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP should bypass CSP header", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP should bypass CSP header", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP should bypass CSP in iframes as well", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP should bypass CSP in iframes as well", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP should bypass CSP meta tag", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setBypassCSP should bypass CSP meta tag", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.setCacheEnabled should enable or disable the cache based on the state passed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setCacheEnabled should enable or disable the cache based on the state passed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setCacheEnabled should stay disabled when toggling request interception on/off", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setGeolocation should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setJavaScriptEnabled should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setJavaScriptEnabled should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.setJavaScriptEnabled should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setOfflineMode should emulate navigator.onLine", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setOfflineMode should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[page.spec] Page Page.setUserAgent should work with additional userAgentMetdata", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[prerender.spec] Prerender can navigate to a prerendered page via Puppeteer", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[prerender.spec] Prerender can screencast", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[prerender.spec] Prerender via frame can navigate to a prerendered page via Puppeteer", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy in incognito browser context should proxy requests when configured at browser level", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy in incognito browser context should proxy requests when configured at context level", + "platforms": ["win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[proxy.spec] request proxy in incognito browser context should proxy requests when configured at context level", + "platforms": ["linux"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy in incognito browser context should respect proxy bypass list when configured at browser level", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy in incognito browser context should respect proxy bypass list when configured at context level", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy should proxy requests when configured", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy should respect proxy bypass list", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[proxy.spec] request proxy should respect proxy bypass list", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work ARIA selectors", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work ARIA selectors with name and role", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work ARIA selectors with role", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work for ARIA selectors in multiple isolated worlds", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "TIMEOUT"] + }, + { + "testIdPattern": "[queryObjects.spec] page.queryObjects should fail for disposed handles", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[queryObjects.spec] page.queryObjects should fail primitive values as prototypes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[queryObjects.spec] page.queryObjects should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[queryObjects.spec] page.queryObjects should work for non-trivial page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[requestinterception-experimental.spec] request interception \"after each\" hook in \"request interception\"", + "platforms": ["win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[requestinterception-experimental.spec] request interception Page.setRequestInterception should load fonts if cache enabled", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["PASS", "TIMEOUT"] + }, + { + "testIdPattern": "[requestinterception-experimental.spec] request interception Page.setRequestInterception should navigate to URL with hash and fire requests without hash", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[requestinterception-experimental.spec] request interception Page.setRequestInterception should work with redirects", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[requestinterception.spec] request interception Page.setRequestInterception should navigate to URL with hash and fire requests without hash", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots Cdp should use scale for clip", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots ElementHandle.screenshot should capture full element when larger than viewport", + "platforms": ["win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots ElementHandle.screenshot should work for an element with an offset", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots ElementHandle.screenshot should work with a rotated element", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots Page.screenshot should clip clip bigger than the viewport without \"captureBeyondViewport\"", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[stacktrace.spec] Stack trace should work for none error objects", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target Browser.targets should return all of the targets", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target Browser.waitForTarget should wait for a target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[target.spec] Target Browser.waitForTarget should wait for a target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[target.spec] Target should be able to use async waitForTarget", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target should contain browser target", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[target.spec] Target should create a worker from a service worker", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[target.spec] Target should create a worker from a shared worker", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[target.spec] Target should have an opener", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[target.spec] Target should not crash while redirecting if original request was missed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[target.spec] Target should not crash while redirecting if original request was missed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[target.spec] Target should not report uninitialized pages", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[target.spec] Target should report when a new page is created and closed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[target.spec] Target should report when a service worker is created and destroyed", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[target.spec] Target should report when a target url changes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[target.spec] Target should report when a target url changes", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[touchscreen.spec] Touchscreen Touchscreen.prototype.touchMove should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForFunction should survive cross-process navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForFunction should survive cross-process navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForFunction should survive navigations", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForFunction should work when resolved right before execution context disposal", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForFunction should work with strict CSP policy", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForFunction should work with strict CSP policy", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector Page.waitForSelector is shortcut for main frame", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector Page.waitForSelector is shortcut for main frame", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector should run in specified frame", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector should run in specified frame", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector should survive cross-process navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["TIMEOUT"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector should survive cross-process navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["FAIL", "PASS", "TIMEOUT"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector should survive cross-process navigation", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector should throw when frame is detached", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector should throw when frame is detached", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForSelector should work with removed MutationObserver", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForXPath should run in specified frame", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForXPath should run in specified frame", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForXPath should throw when frame is detached", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[waittask.spec] waittask specs Frame.waitForXPath should throw when frame is detached", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[worker.spec] Workers should report console logs", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[worker.spec] Workers should report console logs", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[worker.spec] Workers should report errors", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[worker.spec] Workers should report errors", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[worker.spec] Workers should report errors", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should send events", + "platforms": ["win32"], + "parameters": ["cdp", "chrome", "new-headless"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[chromiumonly.spec] Chromium-Specific Launcher tests Puppeteer.launch |pipe| option should fire \"disconnected\" when closing with pipe", + "platforms": ["darwin"], + "parameters": ["cdp", "chrome", "new-headless"], + "expectations": ["FAIL"], + "comment": "Remove with M121" + }, + { + "testIdPattern": "[devtools.spec] DevTools should expose DevTools as a page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[devtools.spec] DevTools should open devtools when \"devtools: true\" option is given", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[devtools.spec] DevTools target.page() should return a DevTools page if asPage is used", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[devtools.spec] DevTools target.page() should return a DevTools page if custom isPageTarget is provided", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[extensions.spec] extensions background_page target type should be available", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[extensions.spec] extensions service_worker target type should be available", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[extensions.spec] extensions target.page() should return a background_page", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch userDataDir option restores preferences", + "platforms": ["win32"], + "parameters": ["firefox", "headless", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[network.spec] network Network Events Page.Events.Request", + "platforms": ["linux"], + "parameters": ["cdp", "chrome", "new-headless"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[network.spec] network raw network headers Same-origin set-cookie subresource", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headful"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[page.spec] Page Page.bringToFront should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[requestinterception.spec] request interception Page.setRequestInterception should be abortable", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headful"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[requestinterception.spec] request interception Page.setRequestInterception should work with redirects", + "platforms": ["win32"], + "parameters": ["cdp", "chrome", "new-headless"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[requestinterception.spec] request interception Page.setRequestInterception should work with redirects", + "platforms": ["win32"], + "parameters": ["cdp", "chrome", "headful"], + "expectations": ["FAIL", "PASS"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots Cdp should work in \"fromSurface: false\" mode", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "headless"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots ElementHandle.screenshot should work for an element with an offset", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox", "headful"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots ElementHandle.screenshot should work for an element with an offset", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox", "headless"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots ElementHandle.screenshot should work with a rotated element", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox", "headful"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots ElementHandle.screenshot should work with a rotated element", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox", "headless"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots Page.screenshot should take fullPage screenshots", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox", "headful"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[screenshot.spec] Screenshots Page.screenshot should take fullPage screenshots", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox", "headless"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[worker.spec] Workers Page.workers", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "chrome", "headless"], + "expectations": ["FAIL", "PASS"] + } +] diff --git a/remote/test/puppeteer/test/TestSuites.json b/remote/test/puppeteer/test/TestSuites.json new file mode 100644 index 0000000000..32adc45d3c --- /dev/null +++ b/remote/test/puppeteer/test/TestSuites.json @@ -0,0 +1,74 @@ +{ + "testSuites": [ + { + "id": "chrome-headless", + "platforms": ["linux", "win32", "darwin"], + "parameters": ["chrome", "headless", "cdp"], + "expectedLineCoverage": 93 + }, + { + "id": "chrome-headful", + "platforms": ["linux"], + "parameters": ["chrome", "headful", "cdp"], + "expectedLineCoverage": 93 + }, + { + "id": "chrome-new-headless", + "platforms": ["linux"], + "parameters": ["chrome", "new-headless", "cdp"], + "expectedLineCoverage": 93 + }, + { + "id": "firefox-headless", + "platforms": ["linux", "darwin"], + "parameters": ["firefox", "headless", "cdp"], + "expectedLineCoverage": 80 + }, + { + "id": "firefox-headful", + "platforms": ["linux"], + "parameters": ["firefox", "headful", "cdp"], + "expectedLineCoverage": 80 + }, + { + "id": "firefox-bidi", + "platforms": ["linux"], + "parameters": ["firefox", "headless", "webDriverBiDi"], + "expectedLineCoverage": 56 + }, + { + "id": "firefox-bidi-headful", + "platforms": ["linux"], + "parameters": ["firefox", "headful", "webDriverBiDi"], + "expectedLineCoverage": 56 + }, + { + "id": "chrome-bidi", + "platforms": ["linux"], + "parameters": ["chrome", "headless", "webDriverBiDi"], + "expectedLineCoverage": 56 + } + ], + "parameterDefinitions": { + "chrome": { + "PUPPETEER_PRODUCT": "chrome" + }, + "firefox": { + "PUPPETEER_PRODUCT": "firefox" + }, + "headless": { + "HEADLESS": "true", + "PUPPETEER_LOGLEVEL": "silent" + }, + "headful": { + "HEADLESS": "false" + }, + "new-headless": { + "HEADLESS": "new" + }, + "webDriverBiDi": { + "PUPPETEER_PROTOCOL": "webDriverBiDi" + }, + "cdp": {} + } +} diff --git a/remote/test/puppeteer/test/assets/abort-request.html b/remote/test/puppeteer/test/assets/abort-request.html new file mode 100644 index 0000000000..77c056a422 --- /dev/null +++ b/remote/test/puppeteer/test/assets/abort-request.html @@ -0,0 +1,13 @@ +<button id="abort"></button> + +<script> + const button = document.getElementById('abort'); + button.addEventListener('click', getJson) + async function getJson() { + const abort = new AbortController(); + const result = fetch("/simple.json", { + signal: abort.signal + }); + abort.abort(); + } +</script>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/beforeunload.html b/remote/test/puppeteer/test/assets/beforeunload.html new file mode 100644 index 0000000000..3cef6763f3 --- /dev/null +++ b/remote/test/puppeteer/test/assets/beforeunload.html @@ -0,0 +1,10 @@ +<div>beforeunload demo.</div> +<script> +window.addEventListener('beforeunload', event => { + // Chrome way. + event.returnValue = 'Leave?'; + // Firefox way. + event.preventDefault(); +}); +</script> + diff --git a/remote/test/puppeteer/test/assets/cached/bfcache/index.html b/remote/test/puppeteer/test/assets/cached/bfcache/index.html new file mode 100644 index 0000000000..3d79312828 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/bfcache/index.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +<body>BFCached<a href="target.html">next</a></body> diff --git a/remote/test/puppeteer/test/assets/cached/bfcache/target.html b/remote/test/puppeteer/test/assets/cached/bfcache/target.html new file mode 100644 index 0000000000..eafc537b64 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/bfcache/target.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +<body>target</body> diff --git a/remote/test/puppeteer/test/assets/cached/bfcache/worker-iframe-container.html b/remote/test/puppeteer/test/assets/cached/bfcache/worker-iframe-container.html new file mode 100644 index 0000000000..857914bb6d --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/bfcache/worker-iframe-container.html @@ -0,0 +1,11 @@ +<body>BFCached<a href="target.html">next</a></body> +<script> + window.addEventListener('DOMContentLoaded', () => { + const iframe = document.createElement('iframe'); + const url = new URL(location.href); + url.hostname = url.hostname === 'localhost' ? '127.0.0.1' : 'localhost'; + url.pathname = '/cached/bfcache/worker-iframe.html'; + iframe.src = url.toString(); + document.body.appendChild(iframe); + }, false); +</script> diff --git a/remote/test/puppeteer/test/assets/cached/bfcache/worker-iframe.html b/remote/test/puppeteer/test/assets/cached/bfcache/worker-iframe.html new file mode 100644 index 0000000000..9233f557c5 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/bfcache/worker-iframe.html @@ -0,0 +1,3 @@ +<script> + const worker = new Worker('worker.mjs', {type: 'module'}) +</script> diff --git a/remote/test/puppeteer/test/assets/cached/bfcache/worker.mjs b/remote/test/puppeteer/test/assets/cached/bfcache/worker.mjs new file mode 100644 index 0000000000..72a8036e68 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/bfcache/worker.mjs @@ -0,0 +1 @@ +console.log('HELLO'); diff --git a/remote/test/puppeteer/test/assets/cached/one-style-font.css b/remote/test/puppeteer/test/assets/cached/one-style-font.css new file mode 100644 index 0000000000..6178de0350 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/one-style-font.css @@ -0,0 +1,9 @@ +@font-face { + font-family: 'one-style'; + src: url('./one-style.woff') format('woff'); +} + +body { + background-color: pink; + font-family: 'one-style', sans-serif; +} diff --git a/remote/test/puppeteer/test/assets/cached/one-style-font.html b/remote/test/puppeteer/test/assets/cached/one-style-font.html new file mode 100644 index 0000000000..8e7236dfb3 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/one-style-font.html @@ -0,0 +1,2 @@ +<link rel='stylesheet' href='./one-style-font.css'> +<div>hello, world!</div> diff --git a/remote/test/puppeteer/test/assets/cached/one-style.css b/remote/test/puppeteer/test/assets/cached/one-style.css new file mode 100644 index 0000000000..04e7110b41 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/one-style.css @@ -0,0 +1,3 @@ +body { + background-color: pink; +} diff --git a/remote/test/puppeteer/test/assets/cached/one-style.html b/remote/test/puppeteer/test/assets/cached/one-style.html new file mode 100644 index 0000000000..4760f2b9f7 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/one-style.html @@ -0,0 +1,2 @@ +<link rel='stylesheet' href='./one-style.css'> +<div>hello, world!</div> diff --git a/remote/test/puppeteer/test/assets/consolelog.html b/remote/test/puppeteer/test/assets/consolelog.html new file mode 100644 index 0000000000..4a27803aa9 --- /dev/null +++ b/remote/test/puppeteer/test/assets/consolelog.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> + <head> + <title>console.log test</title> + </head> + <body> + <script> + function foo() { + console.log('yellow') + } + function bar() { + foo(); + } + bar(); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/credit-card.html b/remote/test/puppeteer/test/assets/credit-card.html new file mode 100644 index 0000000000..101013a0ca --- /dev/null +++ b/remote/test/puppeteer/test/assets/credit-card.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html> + +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> +</head> + +<body> + <form id="testform" method="post"> + <table> + <tbody> + <tr> + <td> + <label for="name">Name on Card</label> + </td> + <td> + <input size="40" id="name" /> + </td> + </tr> + <tr> + <td> + <label for="number">Card Number</label> + </td> + <td> + <input size="40" id="number" name="card_number" /> + </td> + </tr> + <tr> + <td> + <label>Expiration Date</label> + </td> + <td> + <input size="2" id="expiration_month" name="ccmonth"> <input size="4" id="expiration_year" + name="ccyear" /> + </td> + </tr> + </tbody> + </table> + <input type="submit" value="Submit"> + </form> +</body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/csp.html b/remote/test/puppeteer/test/assets/csp.html new file mode 100644 index 0000000000..34fc1fc1a5 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csp.html @@ -0,0 +1 @@ +<meta http-equiv="Content-Security-Policy" content="default-src 'self'"> diff --git a/remote/test/puppeteer/test/assets/csscoverage/Dosis-Regular.ttf b/remote/test/puppeteer/test/assets/csscoverage/Dosis-Regular.ttf Binary files differnew file mode 100644 index 0000000000..4b208624e8 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/Dosis-Regular.ttf diff --git a/remote/test/puppeteer/test/assets/csscoverage/OFL.txt b/remote/test/puppeteer/test/assets/csscoverage/OFL.txt new file mode 100644 index 0000000000..a9b3c8b34e --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/OFL.txt @@ -0,0 +1,95 @@ +Copyright (c) 2011, Edgar Tolentino and Pablo Impallari (www.impallari.com|impallari@gmail.com), +Copyright (c) 2011, Igino Marini. (www.ikern.com|mail@iginomarini.com), +with Reserved Font Names "Dosis". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/remote/test/puppeteer/test/assets/csscoverage/empty.html b/remote/test/puppeteer/test/assets/csscoverage/empty.html new file mode 100644 index 0000000000..b3845c366d --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/empty.html @@ -0,0 +1,3 @@ +<style></style> +<div>empty style tag</div> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/involved.html b/remote/test/puppeteer/test/assets/csscoverage/involved.html new file mode 100644 index 0000000000..bcd9845b93 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/involved.html @@ -0,0 +1,26 @@ +<style> +@charset "utf-8"; +@namespace svg url(http://www.w3.org/2000/svg); +@font-face { + font-family: "Example Font"; + src: url("./Dosis-Regular.ttf"); +} + +#fluffy { + border: 1px solid black; + z-index: 1; + /* -webkit-disabled-property: rgb(1, 2, 3) */ + -lol-cats: "dogs" /* non-existing property */ +} + +@media (min-width: 1px) { + span { + -webkit-border-radius: 10px; + font-family: "Example Font"; + animation: 1s identifier; + } +} +</style> +<div id="fluffy">woof!</div> +<span>fancy text</span> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/media.html b/remote/test/puppeteer/test/assets/csscoverage/media.html new file mode 100644 index 0000000000..bfb89f8f75 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/media.html @@ -0,0 +1,4 @@ +<style> +@media screen { div { color: green; } } </style> +<div>hello, world</div> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/multiple.html b/remote/test/puppeteer/test/assets/csscoverage/multiple.html new file mode 100644 index 0000000000..0fd97e962a --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/multiple.html @@ -0,0 +1,8 @@ +<link rel="stylesheet" href="stylesheet1.css"> +<link rel="stylesheet" href="stylesheet2.css"> +<script> +window.addEventListener('DOMContentLoaded', () => { + // Force stylesheets to load. + console.log(window.getComputedStyle(document.body).color); +}, false); +</script> diff --git a/remote/test/puppeteer/test/assets/csscoverage/simple.html b/remote/test/puppeteer/test/assets/csscoverage/simple.html new file mode 100644 index 0000000000..3beae21829 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/simple.html @@ -0,0 +1,6 @@ +<style> +div { color: green; } +a { color: blue; } +</style> +<div>hello, world</div> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/sourceurl.html b/remote/test/puppeteer/test/assets/csscoverage/sourceurl.html new file mode 100644 index 0000000000..df4e9c276c --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/sourceurl.html @@ -0,0 +1,7 @@ +<style> +body { + padding: 10px; +} +/*# sourceURL=nicename.css */ +</style> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/stylesheet1.css b/remote/test/puppeteer/test/assets/csscoverage/stylesheet1.css new file mode 100644 index 0000000000..60f1eab971 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/stylesheet1.css @@ -0,0 +1,3 @@ +body { + color: red; +} diff --git a/remote/test/puppeteer/test/assets/csscoverage/stylesheet2.css b/remote/test/puppeteer/test/assets/csscoverage/stylesheet2.css new file mode 100644 index 0000000000..a87defb098 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/stylesheet2.css @@ -0,0 +1,4 @@ +html { + margin: 0; + padding: 0; +} diff --git a/remote/test/puppeteer/test/assets/csscoverage/unused.html b/remote/test/puppeteer/test/assets/csscoverage/unused.html new file mode 100644 index 0000000000..5b8186a3bf --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/unused.html @@ -0,0 +1,7 @@ +<style> +@media screen { + a { color: green; } +} +/*# sourceURL=unused.css */ +</style> + diff --git a/remote/test/puppeteer/test/assets/detect-touch.html b/remote/test/puppeteer/test/assets/detect-touch.html new file mode 100644 index 0000000000..80a4123fbd --- /dev/null +++ b/remote/test/puppeteer/test/assets/detect-touch.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html> + <head> + <title>Detect Touch Test</title> + <script src='modernizr.js'></script> + </head> + <body style="font-size:30vmin"> + <script> + document.body.textContent = Modernizr.touchevents ? 'YES' : 'NO'; + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/digits/0.png b/remote/test/puppeteer/test/assets/digits/0.png Binary files differnew file mode 100644 index 0000000000..ac3c4768ed --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/0.png diff --git a/remote/test/puppeteer/test/assets/digits/1.png b/remote/test/puppeteer/test/assets/digits/1.png Binary files differnew file mode 100644 index 0000000000..6768222729 --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/1.png diff --git a/remote/test/puppeteer/test/assets/digits/2.png b/remote/test/puppeteer/test/assets/digits/2.png Binary files differnew file mode 100644 index 0000000000..b1daa4735d --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/2.png diff --git a/remote/test/puppeteer/test/assets/digits/3.png b/remote/test/puppeteer/test/assets/digits/3.png Binary files differnew file mode 100644 index 0000000000..6eca99b21b --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/3.png diff --git a/remote/test/puppeteer/test/assets/digits/4.png b/remote/test/puppeteer/test/assets/digits/4.png Binary files differnew file mode 100644 index 0000000000..a721071e2c --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/4.png diff --git a/remote/test/puppeteer/test/assets/digits/5.png b/remote/test/puppeteer/test/assets/digits/5.png Binary files differnew file mode 100644 index 0000000000..15cb19932a --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/5.png diff --git a/remote/test/puppeteer/test/assets/digits/6.png b/remote/test/puppeteer/test/assets/digits/6.png Binary files differnew file mode 100644 index 0000000000..639f38439d --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/6.png diff --git a/remote/test/puppeteer/test/assets/digits/7.png b/remote/test/puppeteer/test/assets/digits/7.png Binary files differnew file mode 100644 index 0000000000..5c1150b005 --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/7.png diff --git a/remote/test/puppeteer/test/assets/digits/8.png b/remote/test/puppeteer/test/assets/digits/8.png Binary files differnew file mode 100644 index 0000000000..abb8b48b0b --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/8.png diff --git a/remote/test/puppeteer/test/assets/digits/9.png b/remote/test/puppeteer/test/assets/digits/9.png Binary files differnew file mode 100644 index 0000000000..6a40a21c6f --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/9.png diff --git a/remote/test/puppeteer/test/assets/dynamic-oopif.html b/remote/test/puppeteer/test/assets/dynamic-oopif.html new file mode 100644 index 0000000000..38614d0289 --- /dev/null +++ b/remote/test/puppeteer/test/assets/dynamic-oopif.html @@ -0,0 +1,10 @@ +<script> +window.addEventListener('DOMContentLoaded', () => { + const iframe = document.createElement('iframe'); + const url = new URL(location.href); + url.hostname = url.hostname === 'localhost' ? '127.0.0.1' : 'localhost'; + url.pathname = '/oopif.html'; + iframe.src = url.toString(); + document.body.appendChild(iframe); +}, false); +</script> diff --git a/remote/test/puppeteer/test/assets/empty.html b/remote/test/puppeteer/test/assets/empty.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/empty.html diff --git a/remote/test/puppeteer/test/assets/error.html b/remote/test/puppeteer/test/assets/error.html new file mode 100644 index 0000000000..130400c006 --- /dev/null +++ b/remote/test/puppeteer/test/assets/error.html @@ -0,0 +1,15 @@ +<script> +a(); + +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + throw new Error('Fancy error!'); +} +</script> diff --git a/remote/test/puppeteer/test/assets/es6/.eslintrc b/remote/test/puppeteer/test/assets/es6/.eslintrc new file mode 100644 index 0000000000..1903e176f5 --- /dev/null +++ b/remote/test/puppeteer/test/assets/es6/.eslintrc @@ -0,0 +1,5 @@ +{ + "parserOptions": { + "sourceType": "module" + } +}
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/es6/es6import.js b/remote/test/puppeteer/test/assets/es6/es6import.js new file mode 100644 index 0000000000..9aac2d4d64 --- /dev/null +++ b/remote/test/puppeteer/test/assets/es6/es6import.js @@ -0,0 +1,2 @@ +import num from './es6module.js'; +window.__es6injected = num; diff --git a/remote/test/puppeteer/test/assets/es6/es6module.js b/remote/test/puppeteer/test/assets/es6/es6module.js new file mode 100644 index 0000000000..7a4e8a723a --- /dev/null +++ b/remote/test/puppeteer/test/assets/es6/es6module.js @@ -0,0 +1 @@ +export default 42; diff --git a/remote/test/puppeteer/test/assets/es6/es6pathimport.js b/remote/test/puppeteer/test/assets/es6/es6pathimport.js new file mode 100644 index 0000000000..eb17a9a3d1 --- /dev/null +++ b/remote/test/puppeteer/test/assets/es6/es6pathimport.js @@ -0,0 +1,2 @@ +import num from './es6/es6module.js'; +window.__es6injected = num; diff --git a/remote/test/puppeteer/test/assets/favicon.ico b/remote/test/puppeteer/test/assets/favicon.ico Binary files differnew file mode 100644 index 0000000000..d4edd50799 --- /dev/null +++ b/remote/test/puppeteer/test/assets/favicon.ico diff --git a/remote/test/puppeteer/test/assets/file-to-upload.txt b/remote/test/puppeteer/test/assets/file-to-upload.txt new file mode 100644 index 0000000000..b4ad118489 --- /dev/null +++ b/remote/test/puppeteer/test/assets/file-to-upload.txt @@ -0,0 +1 @@ +contents of the file
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/frames/frame.html b/remote/test/puppeteer/test/assets/frames/frame.html new file mode 100644 index 0000000000..8f20d2da9f --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/frame.html @@ -0,0 +1,8 @@ +<link rel='stylesheet' href='./style.css'> +<script src='./script.js' type='text/javascript'></script> +<style> +div { + line-height: 18px; +} +</style> +<div>Hi, I'm frame</div> diff --git a/remote/test/puppeteer/test/assets/frames/frameset.html b/remote/test/puppeteer/test/assets/frames/frameset.html new file mode 100644 index 0000000000..4d56f88839 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/frameset.html @@ -0,0 +1,8 @@ +<frameset> + <frameset> + <frame src='./frame.html'></frame> + <frame src='about:blank'></frame> + </frameset> + <frame src='/empty.html'></frame> + <frame></frame> +</frameset> diff --git a/remote/test/puppeteer/test/assets/frames/lazy-frame.html b/remote/test/puppeteer/test/assets/frames/lazy-frame.html new file mode 100644 index 0000000000..4821cd76cd --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/lazy-frame.html @@ -0,0 +1,3 @@ +<iframe width="100%" height="300" src="about:blank"></iframe> +<div style="height: 800vh"></div> +<iframe width="100%" height="300" src='./frame.html' loading="lazy"></iframe>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/frames/nested-frames.html b/remote/test/puppeteer/test/assets/frames/nested-frames.html new file mode 100644 index 0000000000..e9c5d83c03 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/nested-frames.html @@ -0,0 +1,26 @@ +<style> +:root { + scrollbar-width: none; +} + +body { + display: flex; +} + +body iframe { + flex-grow: 1; + flex-shrink: 1; +} +</style> +<script> +async function attachFrame(frameId, url) { + var frame = document.createElement('iframe'); + frame.src = url; + frame.id = frameId; + document.body.appendChild(frame); + await new Promise(x => frame.onload = x); + return 'kazakh'; +} +</script> +<iframe src='./two-frames.html' name='2frames'></iframe> +<iframe src='./frame.html' name='aframe'></iframe> diff --git a/remote/test/puppeteer/test/assets/frames/one-frame-url-fragment.html b/remote/test/puppeteer/test/assets/frames/one-frame-url-fragment.html new file mode 100644 index 0000000000..d1462641ff --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/one-frame-url-fragment.html @@ -0,0 +1 @@ +<iframe src='./frame.html?param=value#fragment'></iframe> diff --git a/remote/test/puppeteer/test/assets/frames/one-frame.html b/remote/test/puppeteer/test/assets/frames/one-frame.html new file mode 100644 index 0000000000..e941d795a2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/one-frame.html @@ -0,0 +1 @@ +<iframe src='./frame.html'></iframe> diff --git a/remote/test/puppeteer/test/assets/frames/script.js b/remote/test/puppeteer/test/assets/frames/script.js new file mode 100644 index 0000000000..be22256d16 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/script.js @@ -0,0 +1 @@ +console.log('Cheers!'); diff --git a/remote/test/puppeteer/test/assets/frames/style.css b/remote/test/puppeteer/test/assets/frames/style.css new file mode 100644 index 0000000000..5b5436e874 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/style.css @@ -0,0 +1,3 @@ +div { + color: blue; +} diff --git a/remote/test/puppeteer/test/assets/frames/two-frames.html b/remote/test/puppeteer/test/assets/frames/two-frames.html new file mode 100644 index 0000000000..b2ee853eda --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/two-frames.html @@ -0,0 +1,13 @@ +<style> +body { + display: flex; + flex-direction: column; +} + +body iframe { + flex-grow: 1; + flex-shrink: 1; +} +</style> +<iframe src='./frame.html' name='uno'></iframe> +<iframe src='./frame.html' name='dos'></iframe> diff --git a/remote/test/puppeteer/test/assets/global-var.html b/remote/test/puppeteer/test/assets/global-var.html new file mode 100644 index 0000000000..b6be975038 --- /dev/null +++ b/remote/test/puppeteer/test/assets/global-var.html @@ -0,0 +1,3 @@ +<script> +var globalVar = 123; +</script>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/grid.html b/remote/test/puppeteer/test/assets/grid.html new file mode 100644 index 0000000000..437193573d --- /dev/null +++ b/remote/test/puppeteer/test/assets/grid.html @@ -0,0 +1,51 @@ +<script> +document.addEventListener('DOMContentLoaded', function() { + function generatePalette(amount) { + var result = []; + var hueStep = 360 / amount; + for (var i = 0; i < amount; ++i) + result.push('hsl(' + (hueStep * i) + ', 100%, 90%)'); + return result; + } + + var palette = generatePalette(100); + for (var i = 0; i < 200; ++i) { + var box = document.createElement('div'); + box.classList.add('box'); + box.style.setProperty('background-color', palette[i % palette.length]); + var x = i; + do { + var digit = x % 10; + x = (x / 10)|0; + var img = document.createElement('img'); + img.src = `./digits/${digit}.png`; + box.insertBefore(img, box.firstChild); + } while (x); + document.body.appendChild(box); + } +}); +</script> + +<style> + +:root { + scrollbar-width: none; +} + +body { + margin: 0; + padding: 0; +} + +.box { + font-family: arial; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + width: 50px; + height: 50px; + box-sizing: border-box; + border: 1px solid darkgray; +} diff --git a/remote/test/puppeteer/test/assets/historyapi.html b/remote/test/puppeteer/test/assets/historyapi.html new file mode 100644 index 0000000000..bacaf9e9a0 --- /dev/null +++ b/remote/test/puppeteer/test/assets/historyapi.html @@ -0,0 +1,5 @@ +<script> +window.addEventListener('DOMContentLoaded', () => { + history.pushState({}, '', '#1'); +}); +</script> diff --git a/remote/test/puppeteer/test/assets/idle-detector.html b/remote/test/puppeteer/test/assets/idle-detector.html new file mode 100644 index 0000000000..83b496c03d --- /dev/null +++ b/remote/test/puppeteer/test/assets/idle-detector.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<div id="state"></div> +<script> + const elState = document.querySelector('#state'); + function setState(msg) { + elState.textContent = msg; + } + async function main() { + const controller = new AbortController(); + const signal = controller.signal; + const idleDetector = new IdleDetector({ + threshold: 60000, + signal, + }); + idleDetector.addEventListener('change', () => { + const userState = idleDetector.userState; + const screenState = idleDetector.screenState; + setState(`Idle state: ${userState}, ${screenState}.`); + }); + idleDetector.start(); + } + main(); +</script> diff --git a/remote/test/puppeteer/test/assets/initiator.html b/remote/test/puppeteer/test/assets/initiator.html new file mode 100644 index 0000000000..12889d3242 --- /dev/null +++ b/remote/test/puppeteer/test/assets/initiator.html @@ -0,0 +1,2 @@ +<iframe src="./frames/frame.html"></iframe> +<script src="./initiator.js"></script> diff --git a/remote/test/puppeteer/test/assets/initiator.js b/remote/test/puppeteer/test/assets/initiator.js new file mode 100644 index 0000000000..642e775f31 --- /dev/null +++ b/remote/test/puppeteer/test/assets/initiator.js @@ -0,0 +1,8 @@ +const script = document.createElement('script'); +script.src = './injectedfile.js'; +document.body.appendChild(script); + +const style = document.createElement('link'); +style.rel = 'stylesheet'; +style.href = './injectedstyle.css'; +document.head.appendChild(style); diff --git a/remote/test/puppeteer/test/assets/injectedfile.js b/remote/test/puppeteer/test/assets/injectedfile.js new file mode 100644 index 0000000000..c211b62c16 --- /dev/null +++ b/remote/test/puppeteer/test/assets/injectedfile.js @@ -0,0 +1,2 @@ +window.__injected = 42; +window.__injectedError = new Error('hi'); diff --git a/remote/test/puppeteer/test/assets/injectedstyle.css b/remote/test/puppeteer/test/assets/injectedstyle.css new file mode 100644 index 0000000000..aa1634c255 --- /dev/null +++ b/remote/test/puppeteer/test/assets/injectedstyle.css @@ -0,0 +1,3 @@ +body { + background-color: red; +} diff --git a/remote/test/puppeteer/test/assets/inline-svg.html b/remote/test/puppeteer/test/assets/inline-svg.html new file mode 100644 index 0000000000..20023ecc79 --- /dev/null +++ b/remote/test/puppeteer/test/assets/inline-svg.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html lang="en"> + <body> + <svg> + <circle cx="10" cy="10" r="10" /> + </svg> + + <div style="margin-top: 5000px;"> + <svg> + <circle cx="10" cy="10" r="10" /> + </svg> + </div> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/inner-frame1.html b/remote/test/puppeteer/test/assets/inner-frame1.html new file mode 100644 index 0000000000..00f19ec166 --- /dev/null +++ b/remote/test/puppeteer/test/assets/inner-frame1.html @@ -0,0 +1,10 @@ +<script> + window.addEventListener('DOMContentLoaded', () => { + const iframe = document.createElement('iframe'); + const url = new URL(location.href); + url.hostname = 'inner-frame2.test'; + url.pathname = '/inner-frame2.html'; + iframe.src = url.toString(); + document.body.appendChild(iframe); + }, false); +</script> diff --git a/remote/test/puppeteer/test/assets/inner-frame2.html b/remote/test/puppeteer/test/assets/inner-frame2.html new file mode 100644 index 0000000000..9a236cc48f --- /dev/null +++ b/remote/test/puppeteer/test/assets/inner-frame2.html @@ -0,0 +1 @@ +<button>click</button> diff --git a/remote/test/puppeteer/test/assets/input/button.html b/remote/test/puppeteer/test/assets/input/button.html new file mode 100644 index 0000000000..d4c6e13fd2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/button.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <title>Button test</title> + </head> + <body> + <script src="mouse-helper.js"></script> + <button onclick="clicked();">Click target</button> + <script> + window.result = 'Was not clicked'; + function clicked() { + result = 'Clicked'; + } + </script> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/input/checkbox.html b/remote/test/puppeteer/test/assets/input/checkbox.html new file mode 100644 index 0000000000..ca56762e2b --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/checkbox.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html> + <head> + <title>Selection Test</title> + </head> + <body> + <label for="agree">Remember Me</label> + <input id="agree" type="checkbox"> + <script> + window.result = { + check: null, + events: [], + }; + + let checkbox = document.querySelector('input'); + + const events = [ + 'change', + 'click', + 'dblclick', + 'input', + 'mousedown', + 'mouseenter', + 'mouseleave', + 'mousemove', + 'mouseout', + 'mouseover', + 'mouseup', + ]; + + for (let event of events) { + checkbox.addEventListener(event, () => { + if (['change', 'click', 'dblclick', 'input'].includes(event) === true) { + result.check = checkbox.checked; + } + + result.events.push(event); + }, false); + } + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/drag-and-drop.html b/remote/test/puppeteer/test/assets/input/drag-and-drop.html new file mode 100644 index 0000000000..b77870c4ad --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/drag-and-drop.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html> + <head> + <title>Drag-and-drop test</title> + <style> + #drop { + width: 5em; + height: 5em; + border: 1px solid black; + } + </style> + </head> + <body> + <div id="drag" draggable="true">drag me</div> + <div id="drop"></div> + <div id="drag-state">0</div> + <script> + const drag = document.getElementById('drag'); + const drop = document.getElementById('drop'); + drag.addEventListener('dragstart', function(event) { + event.dataTransfer.setData('id', event.target.id); + document.getElementById('drag-state').textContent += '1'; + }); + drop.addEventListener('dragenter', function(event) { + event.preventDefault(); + document.getElementById('drag-state').textContent += '2'; + }); + drop.addEventListener('dragover', function(event) { + event.preventDefault(); + document.getElementById('drag-state').textContent += '3'; + }); + drop.addEventListener('drop', function(event) { + event.preventDefault(); + const id = event.dataTransfer.getData('id'); + const el = document.getElementById(id); + if (el) { + event.target.appendChild(el); + document.getElementById('drag-state').textContent += '4'; + } + }); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/fileupload.html b/remote/test/puppeteer/test/assets/input/fileupload.html new file mode 100644 index 0000000000..55fd7c5006 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/fileupload.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> + <head> + <title>File upload test</title> + </head> + <body> + <input type="file"> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/input/keyboard.html b/remote/test/puppeteer/test/assets/input/keyboard.html new file mode 100644 index 0000000000..2f4b7d33c2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/keyboard.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html> + <head> + <title>Keyboard test</title> + </head> + <body> + <textarea></textarea> + <script> + window.result = ""; + let textarea = document.querySelector('textarea'); + textarea.focus(); + textarea.addEventListener('keydown', event => { + log('Keydown:', event.key, event.code, modifiers(event)); + }); + textarea.addEventListener('input', event => { + log('input:', event.data, event.inputType, event.isComposing); + }); + textarea.addEventListener('keyup', event => { + log('Keyup:', event.key, event.code, modifiers(event)); + }); + function modifiers(event) { + let m = []; + if (event.altKey) + m.push('Alt') + if (event.ctrlKey) + m.push('Control'); + if (event.shiftKey) + m.push('Shift') + return '[' + m.join(' ') + ']'; + } + function log(...args) { + console.log.apply(console, args); + result += args.join(' ') + '\n'; + } + function getResult() { + let temp = result.trim(); + result = ""; + return temp; + } + </script> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/input/mouse-helper.js b/remote/test/puppeteer/test/assets/input/mouse-helper.js new file mode 100644 index 0000000000..97a764aa80 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/mouse-helper.js @@ -0,0 +1,74 @@ +// This injects a box into the page that moves with the mouse; +// Useful for debugging +(function () { + const box = document.createElement('div'); + box.classList.add('mouse-helper'); + const styleElement = document.createElement('style'); + styleElement.innerHTML = ` + .mouse-helper { + pointer-events: none; + position: absolute; + top: 0; + left: 0; + width: 20px; + height: 20px; + background: rgba(0,0,0,.4); + border: 1px solid white; + border-radius: 10px; + margin-left: -10px; + margin-top: -10px; + transition: background .2s, border-radius .2s, border-color .2s; + } + .mouse-helper.button-1 { + transition: none; + background: rgba(0,0,0,0.9); + } + .mouse-helper.button-2 { + transition: none; + border-color: rgba(0,0,255,0.9); + } + .mouse-helper.button-3 { + transition: none; + border-radius: 4px; + } + .mouse-helper.button-4 { + transition: none; + border-color: rgba(255,0,0,0.9); + } + .mouse-helper.button-5 { + transition: none; + border-color: rgba(0,255,0,0.9); + } + `; + document.head.appendChild(styleElement); + document.body.appendChild(box); + document.addEventListener( + 'mousemove', + (event) => { + box.style.left = event.pageX + 'px'; + box.style.top = event.pageY + 'px'; + updateButtons(event.buttons); + }, + true + ); + document.addEventListener( + 'mousedown', + (event) => { + updateButtons(event.buttons); + box.classList.add('button-' + event.which); + }, + true + ); + document.addEventListener( + 'mouseup', + (event) => { + updateButtons(event.buttons); + box.classList.remove('button-' + event.which); + }, + true + ); + function updateButtons(buttons) { + for (let i = 0; i < 5; i++) + {box.classList.toggle('button-' + i, buttons & (1 << i));} + } +})(); diff --git a/remote/test/puppeteer/test/assets/input/rotatedButton.html b/remote/test/puppeteer/test/assets/input/rotatedButton.html new file mode 100644 index 0000000000..1bce66cf5e --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/rotatedButton.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html> + <head> + <title>Rotated button test</title> + </head> + <body> + <script src="mouse-helper.js"></script> + <button onclick="clicked();">Click target</button> + <style> + button { + transform: rotateY(180deg); + } + </style> + <script> + window.result = 'Was not clicked'; + function clicked() { + result = 'Clicked'; + } + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/scrollable.html b/remote/test/puppeteer/test/assets/input/scrollable.html new file mode 100644 index 0000000000..75757824a4 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/scrollable.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<html> + <head> + <title>Scrollable test</title> + </head> + <body> + <script src='mouse-helper.js'></script> + <script> + for (let i = 0; i < 100; i++) { + let button = document.createElement('button'); + button.textContent = i + ': not clicked'; + button.id = 'button-' + i; + button.onclick = () => button.textContent = 'clicked'; + button.oncontextmenu = event => { + if (![2].includes(event.button)) { + return; + } + event.preventDefault(); + button.textContent = 'context menu'; + } + button.onmouseup = event => { + if (![1,3,4].includes(event.button)) { + return; + } + event.preventDefault(); + button.textContent = { + 3: 'back click', + 4: 'forward click', + 1: 'aux click', + }[event.button]; + } + document.body.appendChild(button); + document.body.appendChild(document.createElement('br')); + } + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/select.html b/remote/test/puppeteer/test/assets/input/select.html new file mode 100644 index 0000000000..026d48e328 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/select.html @@ -0,0 +1,70 @@ +<!DOCTYPE html> +<html> + <head> + <title>Selection Test</title> + </head> + <body> + <select> + <option value="">Empty</option> + <option value="black">Black</option> + <option value="blue">Blue</option> + <option value="brown">Brown</option> + <option value="cyan">Cyan</option> + <option value="gray">Gray</option> + <option value="green">Green</option> + <option value="indigo">Indigo</option> + <option value="magenta">Magenta</option> + <option value="orange">Orange</option> + <option value="pink">Pink</option> + <option value="purple">Purple</option> + <option value="red">Red</option> + <option value="violet">Violet</option> + <option value="white">White</option> + <option value="yellow">Yellow</option> + </select> + <script> + window.result = { + onInput: null, + onChange: null, + onBubblingChange: null, + onBubblingInput: null, + }; + + let select = document.querySelector('select'); + + function makeEmpty() { + for (let i = select.options.length - 1; i >= 0; --i) { + select.remove(i); + } + } + + function makeMultiple() { + select.setAttribute('multiple', true); + } + + select.addEventListener('input', () => { + result.onInput = Array.from(select.querySelectorAll('option:checked')).map((option) => { + return option.value; + }); + }, false); + + select.addEventListener('change', () => { + result.onChange = Array.from(select.querySelectorAll('option:checked')).map((option) => { + return option.value; + }); + }, false); + + document.body.addEventListener('input', () => { + result.onBubblingInput = Array.from(select.querySelectorAll('option:checked')).map((option) => { + return option.value; + }); + }, false); + + document.body.addEventListener('change', () => { + result.onBubblingChange = Array.from(select.querySelectorAll('option:checked')).map((option) => { + return option.value; + }); + }, false); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/textarea.html b/remote/test/puppeteer/test/assets/input/textarea.html new file mode 100644 index 0000000000..66fdc40304 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/textarea.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> + <head> + <title>Textarea test</title> + </head> + <body> + <textarea rows="5" cols="20"></textarea> + <script src='mouse-helper.js'></script> + <script> + globalThis.result = ''; + globalThis.textarea = document.querySelector('textarea'); + textarea.addEventListener('input', () => result = textarea.value, false); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/touchscreen.html b/remote/test/puppeteer/test/assets/input/touchscreen.html new file mode 100644 index 0000000000..76e31c97f9 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/touchscreen.html @@ -0,0 +1,122 @@ +<!doctype html> +<html> + <head> + <title>Touch test</title> + </head> + + <body> + <style> + button { + box-sizing: border-box; + position: absolute; + left: 0; + top: 0; + width: 10px; + height: 10px; + padding: 0; + margin: 0; + } + </style> + <button>Click target</button> + <script> + var allEvents = []; + globalThis.addEventListener( + "touchstart", + (event) => { + allEvents.push({ + type: "touchstart", + touches: [...event.changedTouches].map((touch) => [ + touch.clientX, + touch.clientY, + touch.radiusX, + touch.radiusY, + ]), + }); + }, + true, + ); + globalThis.addEventListener( + "touchmove", + (event) => { + allEvents.push({ + type: "touchmove", + touches: [...event.changedTouches].map((touch) => [ + touch.clientX, + touch.clientY, + touch.radiusX, + touch.radiusY, + ]), + }); + }, + true, + ); + globalThis.addEventListener( + "touchend", + (event) => { + allEvents.push({ + type: "touchend", + touches: [...event.changedTouches].map((touch) => [ + touch.clientX, + touch.clientY, + touch.radiusX, + touch.radiusY, + ]) + }); + }, + true, + ); + globalThis.addEventListener( + "pointerdown", + (event) => { + allEvents.push({ + type: "pointerdown", + x: event.x, + y: event.y, + width: event.width, + height: event.height, + }); + }, + true, + ); + globalThis.addEventListener( + "pointermove", + (event) => { + allEvents.push({ + type: "pointermove", + x: event.x, + y: event.y, + width: event.width, + height: event.height, + }); + }, + true, + ); + globalThis.addEventListener( + "pointerup", + (event) => { + allEvents.push({ + type: "pointerup", + x: event.x, + y: event.y, + width: event.width, + height: event.height, + }); + }, + true, + ); + globalThis.addEventListener( + "click", + (event) => { + allEvents.push({ + type: "click", + x: event.x, + y: event.y, + width: event.width, + height: event.height, + }); + }, + true, + ); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/wheel.html b/remote/test/puppeteer/test/assets/input/wheel.html new file mode 100644 index 0000000000..3d093a993e --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/wheel.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <style> + body { + min-height: 100vh; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + } + + div { + width: 105px; + height: 105px; + background: #cdf; + padding: 5px; + } + </style> + <title>Element: wheel event - Scaling_an_element_via_the_wheel - code sample</title> + </head> + <body> + <div>Scale me with your mouse wheel.</div> + <script> + function zoom(event) { + event.preventDefault(); + + scale += event.deltaY * -0.01; + + // Restrict scale + scale = Math.min(Math.max(.125, scale), 4); + + // Apply scale transform + el.style.transform = `scale(${scale})`; + } + + let scale = 1; + const el = document.querySelector('div'); + el.onwheel = zoom; + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/jscoverage/eval.html b/remote/test/puppeteer/test/assets/jscoverage/eval.html new file mode 100644 index 0000000000..838ae28763 --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/eval.html @@ -0,0 +1 @@ +<script>eval('console.log("foo")')</script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/involved.html b/remote/test/puppeteer/test/assets/jscoverage/involved.html new file mode 100644 index 0000000000..fcc32ba2ca --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/involved.html @@ -0,0 +1,16 @@ +<script> +function foo() { + if (1 > 2) + console.log(1); + if (1 < 2) + console.log(2); + let x = 1 > 2 ? 'foo' : 'bar'; + let y = 1 < 2 ? 'foo' : 'bar'; + let p = {a:1 > 2?function(){console.log('unused');}:function(){console.log('unused');}}; + let z = () => {}; + let q = () => {}; + q(); +} + +foo(); +</script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/multiple.html b/remote/test/puppeteer/test/assets/jscoverage/multiple.html new file mode 100644 index 0000000000..bdef59885b --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/multiple.html @@ -0,0 +1,2 @@ +<script src='script1.js'></script> +<script src='script2.js'></script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/ranges.html b/remote/test/puppeteer/test/assets/jscoverage/ranges.html new file mode 100644 index 0000000000..3d02670aea --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/ranges.html @@ -0,0 +1,2 @@ +<script> +function unused(){}console.log('used!');if(true===false)console.log('unused!');</script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/script1.js b/remote/test/puppeteer/test/assets/jscoverage/script1.js new file mode 100644 index 0000000000..3bd241b50e --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/script1.js @@ -0,0 +1 @@ +console.log(3); diff --git a/remote/test/puppeteer/test/assets/jscoverage/script2.js b/remote/test/puppeteer/test/assets/jscoverage/script2.js new file mode 100644 index 0000000000..3bd241b50e --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/script2.js @@ -0,0 +1 @@ +console.log(3); diff --git a/remote/test/puppeteer/test/assets/jscoverage/simple.html b/remote/test/puppeteer/test/assets/jscoverage/simple.html new file mode 100644 index 0000000000..49eeeea6ae --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/simple.html @@ -0,0 +1,2 @@ +<script> +function foo() {function bar() { } console.log(1); } foo(); </script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/sourceurl.html b/remote/test/puppeteer/test/assets/jscoverage/sourceurl.html new file mode 100644 index 0000000000..e477750320 --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/sourceurl.html @@ -0,0 +1,4 @@ +<script> +console.log(1); +//# sourceURL=nicename.js +</script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/unused.html b/remote/test/puppeteer/test/assets/jscoverage/unused.html new file mode 100644 index 0000000000..59c4a5a70b --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/unused.html @@ -0,0 +1 @@ +<script>function foo() { }</script> diff --git a/remote/test/puppeteer/test/assets/lazy-oopif-frame.html b/remote/test/puppeteer/test/assets/lazy-oopif-frame.html new file mode 100644 index 0000000000..83a420d029 --- /dev/null +++ b/remote/test/puppeteer/test/assets/lazy-oopif-frame.html @@ -0,0 +1,3 @@ +<iframe width="100%" height="300" src="about:blank"></iframe> +<div style="height: 800vh"></div> +<iframe width="100%" height="300" src="https://www.example.com" loading="lazy"></iframe> diff --git a/remote/test/puppeteer/test/assets/main-frame.html b/remote/test/puppeteer/test/assets/main-frame.html new file mode 100644 index 0000000000..0c50feff85 --- /dev/null +++ b/remote/test/puppeteer/test/assets/main-frame.html @@ -0,0 +1,10 @@ +<script> + window.addEventListener('DOMContentLoaded', () => { + const iframe = document.createElement('iframe'); + const url = new URL(location.href); + url.hostname = 'inner-frame1.test'; + url.pathname = '/inner-frame1.html'; + iframe.src = url.toString(); + document.body.appendChild(iframe); + }, false); +</script> diff --git a/remote/test/puppeteer/test/assets/mobile.html b/remote/test/puppeteer/test/assets/mobile.html new file mode 100644 index 0000000000..8e94b2fe29 --- /dev/null +++ b/remote/test/puppeteer/test/assets/mobile.html @@ -0,0 +1 @@ +<meta name = "viewport" content = "initial-scale = 1, user-scalable = no"> diff --git a/remote/test/puppeteer/test/assets/modernizr.js b/remote/test/puppeteer/test/assets/modernizr.js new file mode 100644 index 0000000000..7991a4ec40 --- /dev/null +++ b/remote/test/puppeteer/test/assets/modernizr.js @@ -0,0 +1,3 @@ +/*! modernizr 3.5.0 (Custom Build) | MIT * +* https://modernizr.com/download/?-touchevents-setclasses !*/ +!function(e,n,t){function o(e,n){return typeof e===n}function s(){var e,n,t,s,a,i,r;for(var l in c)if(c.hasOwnProperty(l)){if(e=[],n=c[l],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(t=0;t<n.options.aliases.length;t++)e.push(n.options.aliases[t].toLowerCase());for(s=o(n.fn,"function")?n.fn():n.fn,a=0;a<e.length;a++)i=e[a],r=i.split("."),1===r.length?Modernizr[r[0]]=s:(!Modernizr[r[0]]||Modernizr[r[0]]instanceof Boolean||(Modernizr[r[0]]=new Boolean(Modernizr[r[0]])),Modernizr[r[0]][r[1]]=s),f.push((s?"":"no-")+r.join("-"))}}function a(e){var n=u.className,t=Modernizr._config.classPrefix||"";if(p&&(n=n.baseVal),Modernizr._config.enableJSClass){var o=new RegExp("(^|\\s)"+t+"no-js(\\s|$)");n=n.replace(o,"$1"+t+"js$2")}Modernizr._config.enableClasses&&(n+=" "+t+e.join(" "+t),p?u.className.baseVal=n:u.className=n)}function i(){return"function"!=typeof n.createElement?n.createElement(arguments[0]):p?n.createElementNS.call(n,"http://www.w3.org/2000/svg",arguments[0]):n.createElement.apply(n,arguments)}function r(){var e=n.body;return e||(e=i(p?"svg":"body"),e.fake=!0),e}function l(e,t,o,s){var a,l,f,c,d="modernizr",p=i("div"),h=r();if(parseInt(o,10))for(;o--;)f=i("div"),f.id=s?s[o]:d+(o+1),p.appendChild(f);return a=i("style"),a.type="text/css",a.id="s"+d,(h.fake?h:p).appendChild(a),h.appendChild(p),a.styleSheet?a.styleSheet.cssText=e:a.appendChild(n.createTextNode(e)),p.id=d,h.fake&&(h.style.background="",h.style.overflow="hidden",c=u.style.overflow,u.style.overflow="hidden",u.appendChild(h)),l=t(p,e),h.fake?(h.parentNode.removeChild(h),u.style.overflow=c,u.offsetHeight):p.parentNode.removeChild(p),!!l}var f=[],c=[],d={_version:"3.5.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,n){var t=this;setTimeout(function(){n(t[e])},0)},addTest:function(e,n,t){c.push({name:e,fn:n,options:t})},addAsyncTest:function(e){c.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=d,Modernizr=new Modernizr;var u=n.documentElement,p="svg"===u.nodeName.toLowerCase(),h=d._config.usePrefixes?" -webkit- -moz- -o- -ms- ".split(" "):["",""];d._prefixes=h;var m=d.testStyles=l;Modernizr.addTest("touchevents",function(){var t;if("ontouchstart"in e||e.DocumentTouch&&n instanceof DocumentTouch)t=!0;else{var o=["@media (",h.join("touch-enabled),("),"heartz",")","{#modernizr{top:9px;position:absolute}}"].join("");m(o,function(e){t=9===e.offsetTop})}return t}),s(),a(f),delete d.addTest,delete d.addAsyncTest;for(var v=0;v<Modernizr._q.length;v++)Modernizr._q[v]();e.Modernizr=Modernizr}(window,document); diff --git a/remote/test/puppeteer/test/assets/networkidle.html b/remote/test/puppeteer/test/assets/networkidle.html new file mode 100644 index 0000000000..910ae1736d --- /dev/null +++ b/remote/test/puppeteer/test/assets/networkidle.html @@ -0,0 +1,19 @@ +<script> + async function sleep(delay) { + return new Promise(resolve => setTimeout(resolve, delay)); + } + + async function main() { + const roundOne = Promise.all([ + fetch('fetch-request-a.js'), + fetch('fetch-request-b.js'), + fetch('fetch-request-c.js'), + ]); + + await roundOne; + await sleep(50); + await fetch('fetch-request-d.js'); + } + + main(); +</script> diff --git a/remote/test/puppeteer/test/assets/offscreenbuttons.html b/remote/test/puppeteer/test/assets/offscreenbuttons.html new file mode 100644 index 0000000000..e487caf4d3 --- /dev/null +++ b/remote/test/puppeteer/test/assets/offscreenbuttons.html @@ -0,0 +1,40 @@ +<!DOCTYPE html> +<style> + button { + position: absolute; + width: 100px; + height: 20px; + } + + #btn0 { right: 0px; top: 0; } + #btn1 { right: -10px; top: 25px; } + #btn2 { right: -20px; top: 50px; } + #btn3 { right: -30px; top: 75px; } + #btn4 { right: -40px; top: 100px; } + #btn5 { right: -50px; top: 125px; } + #btn6 { right: -60px; top: 150px; } + #btn7 { right: -70px; top: 175px; } + #btn8 { right: -80px; top: 200px; } + #btn9 { right: -90px; top: 225px; } + #btn10 { right: -100px; top: 250px; } + #btn11 { right: -99.999px; top: 275px; } +</style> +<button id=btn0>0</button> +<button id=btn1>1</button> +<button id=btn2>2</button> +<button id=btn3>3</button> +<button id=btn4>4</button> +<button id=btn5>5</button> +<button id=btn6>6</button> +<button id=btn7>7</button> +<button id=btn8>8</button> +<button id=btn9>9</button> +<button id=btn10>10</button> +<button id=btn11>11</button> +<script> + for (const button of document.querySelectorAll('button')) { + button.addEventListener('click', () => { + console.log(`button #${button.textContent} clicked`); + }); + } +</script> diff --git a/remote/test/puppeteer/test/assets/one-style.css b/remote/test/puppeteer/test/assets/one-style.css new file mode 100644 index 0000000000..7b26410d8a --- /dev/null +++ b/remote/test/puppeteer/test/assets/one-style.css @@ -0,0 +1,3 @@ +body { + background-color: pink; +} diff --git a/remote/test/puppeteer/test/assets/one-style.html b/remote/test/puppeteer/test/assets/one-style.html new file mode 100644 index 0000000000..4760f2b9f7 --- /dev/null +++ b/remote/test/puppeteer/test/assets/one-style.html @@ -0,0 +1,2 @@ +<link rel='stylesheet' href='./one-style.css'> +<div>hello, world!</div> diff --git a/remote/test/puppeteer/test/assets/oopif.html b/remote/test/puppeteer/test/assets/oopif.html new file mode 100644 index 0000000000..f04b9127af --- /dev/null +++ b/remote/test/puppeteer/test/assets/oopif.html @@ -0,0 +1,5 @@ +<a id="navigate-within-document" href="#nav">Navigate within document</a> +<a name="nav"></a> +<script> + fetch('oopif.html?requestFromOOPIF') +</script> diff --git a/remote/test/puppeteer/test/assets/p-selectors.html b/remote/test/puppeteer/test/assets/p-selectors.html new file mode 100644 index 0000000000..24900623d8 --- /dev/null +++ b/remote/test/puppeteer/test/assets/p-selectors.html @@ -0,0 +1,15 @@ +<div id="a">hello <button id="b">world</button> + <span id="f"></span> + <div id="c"></div> +</div> +<a>My name is Jun (pronounced like "June")</a> + +<script> + const topShadow = document.querySelector('#c'); + topShadow.attachShadow({ mode: "open" }); + topShadow.shadowRoot.innerHTML = `shadow dom<div id="d"></div>`; + + const innerShadow = topShadow.shadowRoot.querySelector('#d'); + innerShadow.attachShadow({ mode: "open" }); + innerShadow.shadowRoot.innerHTML = `<a id="e">deep text</a>`; +</script>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/pdf.html b/remote/test/puppeteer/test/assets/pdf.html new file mode 100644 index 0000000000..987df27ebe --- /dev/null +++ b/remote/test/puppeteer/test/assets/pdf.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>PDF</title> + </head> + <body> + <div>PDF Content</div> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/picture.html b/remote/test/puppeteer/test/assets/picture.html new file mode 100644 index 0000000000..18e3d70f5e --- /dev/null +++ b/remote/test/puppeteer/test/assets/picture.html @@ -0,0 +1,6 @@ +<!DOCTYPE html> +<img + srcset="logo-1x.png, logo-2x.png 2x, logo-3x.png 3x" + src="logo-1x.png" + height="320" + width="320" />
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/playground.html b/remote/test/puppeteer/test/assets/playground.html new file mode 100644 index 0000000000..828cfb1c70 --- /dev/null +++ b/remote/test/puppeteer/test/assets/playground.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> + <head> + <title>Playground</title> + </head> + <body> + <button>A button</button> + <textarea>A text area</textarea> + <div id="first">First div</div> + <div id="second"> + Second div + <span class="inner">Inner span</span> + </div> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/popup/popup.html b/remote/test/puppeteer/test/assets/popup/popup.html new file mode 100644 index 0000000000..b855162c25 --- /dev/null +++ b/remote/test/puppeteer/test/assets/popup/popup.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> + <head> + <title>Popup</title> + </head> + <body> + I am a popup + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/popup/window-open.html b/remote/test/puppeteer/test/assets/popup/window-open.html new file mode 100644 index 0000000000..d138be1d22 --- /dev/null +++ b/remote/test/puppeteer/test/assets/popup/window-open.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Popup test</title> + </head> + <body> + <script> + window.open('./popup.html'); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/pptr.png b/remote/test/puppeteer/test/assets/pptr.png Binary files differnew file mode 100644 index 0000000000..65d87c68e6 --- /dev/null +++ b/remote/test/puppeteer/test/assets/pptr.png diff --git a/remote/test/puppeteer/test/assets/prerender/index.html b/remote/test/puppeteer/test/assets/prerender/index.html new file mode 100644 index 0000000000..e0eecb717d --- /dev/null +++ b/remote/test/puppeteer/test/assets/prerender/index.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<head> +<script> + function addRules() { + const script = document.createElement('script'); + script.type = 'speculationrules'; + script.innerText = ` + { + "prerender": [ + {"source": "list", "urls": ["target.html"]} + ] + } + `; + document.head.append(script); + } +</script> +</head> +<body> + <button onclick="addRules()">add rules</button> + <a href="target.html">test</a> +</body> diff --git a/remote/test/puppeteer/test/assets/prerender/target.html b/remote/test/puppeteer/test/assets/prerender/target.html new file mode 100644 index 0000000000..f384b3cbb0 --- /dev/null +++ b/remote/test/puppeteer/test/assets/prerender/target.html @@ -0,0 +1,5 @@ +<!DOCTYPE html> +<head> + <script>fetch('target.html?fromPrerendered')</script> +</head> +<body>target<input></input></body> diff --git a/remote/test/puppeteer/test/assets/resetcss.html b/remote/test/puppeteer/test/assets/resetcss.html new file mode 100644 index 0000000000..e4e04b1f8a --- /dev/null +++ b/remote/test/puppeteer/test/assets/resetcss.html @@ -0,0 +1,50 @@ +<style> +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +</style> diff --git a/remote/test/puppeteer/test/assets/resolution.html b/remote/test/puppeteer/test/assets/resolution.html new file mode 100644 index 0000000000..6d9f59ef9f --- /dev/null +++ b/remote/test/puppeteer/test/assets/resolution.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<style> + p { + color: transparent; + } + @media (resolution: 1dppx) { + p { + font-size: 1px; + } + } + @media (resolution: 2dppx) { + p { + font-size: 2px; + } + } + @media (resolution: 3dppx) { + p { + font-size: 3px; + } + } + </style> + <p>Test</p> +
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/self-request.html b/remote/test/puppeteer/test/assets/self-request.html new file mode 100644 index 0000000000..88aff620ff --- /dev/null +++ b/remote/test/puppeteer/test/assets/self-request.html @@ -0,0 +1,5 @@ +<script> +var req = new XMLHttpRequest(); +req.open('GET', '/self-request.html'); +req.send(null); +</script> diff --git a/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.html b/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.html new file mode 100644 index 0000000000..bef85d985b --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.html @@ -0,0 +1,3 @@ +<script> + window.registrationPromise = navigator.serviceWorker.register('sw.js'); +</script> diff --git a/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.js b/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.js new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.js diff --git a/remote/test/puppeteer/test/assets/serviceworkers/extension/background.js b/remote/test/puppeteer/test/assets/serviceworkers/extension/background.js new file mode 100644 index 0000000000..8b1a393741 --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/extension/background.js @@ -0,0 +1 @@ +// empty diff --git a/remote/test/puppeteer/test/assets/serviceworkers/extension/manifest.json b/remote/test/puppeteer/test/assets/serviceworkers/extension/manifest.json new file mode 100644 index 0000000000..25828b6d2b --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/extension/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "Simple extension", + "version": "0.1", + "background": { + "service_worker": "background.js" + }, + "permissions": ["background", "activeTab"], + "manifest_version": 3 +} diff --git a/remote/test/puppeteer/test/assets/serviceworkers/fetch/style.css b/remote/test/puppeteer/test/assets/serviceworkers/fetch/style.css new file mode 100644 index 0000000000..7b26410d8a --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/fetch/style.css @@ -0,0 +1,3 @@ +body { + background-color: pink; +} diff --git a/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.html b/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.html new file mode 100644 index 0000000000..a9d28acb09 --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.html @@ -0,0 +1,5 @@ +<link rel="stylesheet" href="./style.css"> +<script> + window.registrationPromise = navigator.serviceWorker.register('sw.js'); + window.activationPromise = new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve); +</script> diff --git a/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.js b/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.js new file mode 100644 index 0000000000..21381484b6 --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.js @@ -0,0 +1,7 @@ +self.addEventListener('fetch', (event) => { + event.respondWith(fetch(event.request)); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(clients.claim()); +}); diff --git a/remote/test/puppeteer/test/assets/shadow.html b/remote/test/puppeteer/test/assets/shadow.html new file mode 100644 index 0000000000..3796ca768c --- /dev/null +++ b/remote/test/puppeteer/test/assets/shadow.html @@ -0,0 +1,17 @@ +<script> + +let h1 = null; +window.button = null; +window.clicked = false; + +window.addEventListener('DOMContentLoaded', () => { + const shadowRoot = document.body.attachShadow({mode: 'open'}); + h1 = document.createElement('h1'); + h1.textContent = 'Hellow Shadow DOM v1'; + button = document.createElement('button'); + button.textContent = 'Click'; + button.addEventListener('click', () => clicked = true); + shadowRoot.appendChild(h1); + shadowRoot.appendChild(button); +}); +</script> diff --git a/remote/test/puppeteer/test/assets/simple-extension/content-script.js b/remote/test/puppeteer/test/assets/simple-extension/content-script.js new file mode 100644 index 0000000000..0fd83b90f1 --- /dev/null +++ b/remote/test/puppeteer/test/assets/simple-extension/content-script.js @@ -0,0 +1,2 @@ +console.log('hey from the content-script'); +self.thisIsTheContentScript = true; diff --git a/remote/test/puppeteer/test/assets/simple-extension/index.js b/remote/test/puppeteer/test/assets/simple-extension/index.js new file mode 100644 index 0000000000..a0bb3f4eae --- /dev/null +++ b/remote/test/puppeteer/test/assets/simple-extension/index.js @@ -0,0 +1,2 @@ +// Mock script for background extension +window.MAGIC = 42; diff --git a/remote/test/puppeteer/test/assets/simple-extension/manifest.json b/remote/test/puppeteer/test/assets/simple-extension/manifest.json new file mode 100644 index 0000000000..da2cd082ed --- /dev/null +++ b/remote/test/puppeteer/test/assets/simple-extension/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Simple extension", + "version": "0.1", + "background": { + "scripts": ["index.js"] + }, + "content_scripts": [{ + "matches": ["<all_urls>"], + "css": [], + "js": ["content-script.js"] + }], + "permissions": ["background", "activeTab"], + "manifest_version": 2 +} diff --git a/remote/test/puppeteer/test/assets/simple.json b/remote/test/puppeteer/test/assets/simple.json new file mode 100644 index 0000000000..6d95903051 --- /dev/null +++ b/remote/test/puppeteer/test/assets/simple.json @@ -0,0 +1 @@ +{"foo": "bar"} diff --git a/remote/test/puppeteer/test/assets/tamperable.html b/remote/test/puppeteer/test/assets/tamperable.html new file mode 100644 index 0000000000..d027e97038 --- /dev/null +++ b/remote/test/puppeteer/test/assets/tamperable.html @@ -0,0 +1,3 @@ +<script> + window.result = window.injected; +</script>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/title.html b/remote/test/puppeteer/test/assets/title.html new file mode 100644 index 0000000000..88a86ce412 --- /dev/null +++ b/remote/test/puppeteer/test/assets/title.html @@ -0,0 +1 @@ +<title>Woof-Woof</title> diff --git a/remote/test/puppeteer/test/assets/worker/worker.html b/remote/test/puppeteer/test/assets/worker/worker.html new file mode 100644 index 0000000000..7de2d9fd9e --- /dev/null +++ b/remote/test/puppeteer/test/assets/worker/worker.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> + <head> + <title>Worker test</title> + </head> + <body> + <script> + var worker = new Worker('worker.js'); + worker.onmessage = function(message) { + console.log(message.data); + }; + </script> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/worker/worker.js b/remote/test/puppeteer/test/assets/worker/worker.js new file mode 100644 index 0000000000..0626f13e58 --- /dev/null +++ b/remote/test/puppeteer/test/assets/worker/worker.js @@ -0,0 +1,16 @@ +console.log('hello from the worker'); + +function workerFunction() { + return 'worker function result'; +} + +self.addEventListener('message', (event) => { + console.log('got this data: ' + event.data); +}); + +(async function () { + while (true) { + self.postMessage(workerFunction.toString()); + await new Promise((x) => setTimeout(x, 100)); + } +})(); diff --git a/remote/test/puppeteer/test/assets/wrappedlink.html b/remote/test/puppeteer/test/assets/wrappedlink.html new file mode 100644 index 0000000000..429b6e9156 --- /dev/null +++ b/remote/test/puppeteer/test/assets/wrappedlink.html @@ -0,0 +1,32 @@ +<style> +:root { + font-family: monospace; +} + +body { + display: flex; + align-items: center; + justify-content: center; +} + +div { + width: 10ch; + word-wrap: break-word; + border: 1px solid blue; + transform: rotate(33deg); + line-height: 8ch; + padding: 2ch; +} + +a { + margin-left: 7ch; +} +</style> +<div> + <a href='#clicked'>123321</a> +</div> +<script> + document.querySelector('a').addEventListener('click', () => { + window.__clicked = true; + }); +</script> diff --git a/remote/test/puppeteer/test/fixtures/closeme.js b/remote/test/puppeteer/test/fixtures/closeme.js new file mode 100644 index 0000000000..dbe798f70d --- /dev/null +++ b/remote/test/puppeteer/test/fixtures/closeme.js @@ -0,0 +1,5 @@ +(async () => { + const [, , puppeteerRoot, options] = process.argv; + const browser = await require(puppeteerRoot).launch(JSON.parse(options)); + console.log(browser.wsEndpoint()); +})(); diff --git a/remote/test/puppeteer/test/fixtures/dumpio.js b/remote/test/puppeteer/test/fixtures/dumpio.js new file mode 100644 index 0000000000..a16cf4d633 --- /dev/null +++ b/remote/test/puppeteer/test/fixtures/dumpio.js @@ -0,0 +1,10 @@ +(async () => { + const [, , puppeteerRoot, options] = process.argv; + const browser = await require(puppeteerRoot).launch(JSON.parse(options)); + const page = await browser.newPage(); + await page.evaluate(() => { + return console.error('message from dumpio'); + }); + await page.close(); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/test/golden-chrome/csscoverage-involved.txt b/remote/test/puppeteer/test/golden-chrome/csscoverage-involved.txt new file mode 100644 index 0000000000..ecb1a6342f --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/csscoverage-involved.txt @@ -0,0 +1,20 @@ +[ + { + "url": "http://localhost:<PORT>/csscoverage/involved.html", + "ranges": [ + { + "start": 149, + "end": 297 + }, + { + "start": 306, + "end": 323 + }, + { + "start": 327, + "end": 433 + } + ], + "text": "\n@charset \"utf-8\";\n@namespace svg url(http://www.w3.org/2000/svg);\n@font-face {\n font-family: \"Example Font\";\n src: url(\"./Dosis-Regular.ttf\");\n}\n\n#fluffy {\n border: 1px solid black;\n z-index: 1;\n /* -webkit-disabled-property: rgb(1, 2, 3) */\n -lol-cats: \"dogs\" /* non-existing property */\n}\n\n@media (min-width: 1px) {\n span {\n -webkit-border-radius: 10px;\n font-family: \"Example Font\";\n animation: 1s identifier;\n }\n}\n" + } +]
\ No newline at end of file diff --git a/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio1.png b/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio1.png Binary files differnew file mode 100644 index 0000000000..c53502031f --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio1.png diff --git a/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio2.png b/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio2.png Binary files differnew file mode 100644 index 0000000000..9d3e9fcc31 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio2.png diff --git a/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio3.png b/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio3.png Binary files differnew file mode 100644 index 0000000000..3349dbd0ac --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/device-pixel-ratio3.png diff --git a/remote/test/puppeteer/test/golden-chrome/grid-cell-0.png b/remote/test/puppeteer/test/golden-chrome/grid-cell-0.png Binary files differnew file mode 100644 index 0000000000..ff282e989b --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/grid-cell-0.png diff --git a/remote/test/puppeteer/test/golden-chrome/grid-cell-1.png b/remote/test/puppeteer/test/golden-chrome/grid-cell-1.png Binary files differnew file mode 100644 index 0000000000..91a1cb8510 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/grid-cell-1.png diff --git a/remote/test/puppeteer/test/golden-chrome/grid-cell-2.png b/remote/test/puppeteer/test/golden-chrome/grid-cell-2.png Binary files differnew file mode 100644 index 0000000000..7b01753b6a --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/grid-cell-2.png diff --git a/remote/test/puppeteer/test/golden-chrome/grid-cell-3.png b/remote/test/puppeteer/test/golden-chrome/grid-cell-3.png Binary files differnew file mode 100644 index 0000000000..b9b8b2922b --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/grid-cell-3.png diff --git a/remote/test/puppeteer/test/golden-chrome/jscoverage-involved.txt b/remote/test/puppeteer/test/golden-chrome/jscoverage-involved.txt new file mode 100644 index 0000000000..016b30bde8 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/jscoverage-involved.txt @@ -0,0 +1,36 @@ +[ + { + "url": "http://localhost:<PORT>/jscoverage/involved.html", + "ranges": [ + { + "start": 0, + "end": 35 + }, + { + "start": 50, + "end": 100 + }, + { + "start": 107, + "end": 141 + }, + { + "start": 148, + "end": 168 + }, + { + "start": 203, + "end": 204 + }, + { + "start": 238, + "end": 251 + }, + { + "start": 259, + "end": 298 + } + ], + "text": "\nfunction foo() {\n if (1 > 2)\n console.log(1);\n if (1 < 2)\n console.log(2);\n let x = 1 > 2 ? 'foo' : 'bar';\n let y = 1 < 2 ? 'foo' : 'bar';\n let p = {a:1 > 2?function(){console.log('unused');}:function(){console.log('unused');}};\n let z = () => {};\n let q = () => {};\n q();\n}\n\nfoo();\n" + } +]
\ No newline at end of file diff --git a/remote/test/puppeteer/test/golden-chrome/mock-binary-response.png b/remote/test/puppeteer/test/golden-chrome/mock-binary-response.png Binary files differnew file mode 100644 index 0000000000..8595e0598e --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/mock-binary-response.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-clip-odd-size.png b/remote/test/puppeteer/test/golden-chrome/screenshot-clip-odd-size.png Binary files differnew file mode 100644 index 0000000000..b010d1f87f --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-clip-odd-size.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-clip-rect-scale2.png b/remote/test/puppeteer/test/golden-chrome/screenshot-clip-rect-scale2.png Binary files differnew file mode 100644 index 0000000000..d713d27943 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-clip-rect-scale2.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-clip-rect.png b/remote/test/puppeteer/test/golden-chrome/screenshot-clip-rect.png Binary files differnew file mode 100644 index 0000000000..ac23b7de50 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-clip-rect.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-element-bounding-box.png b/remote/test/puppeteer/test/golden-chrome/screenshot-element-bounding-box.png Binary files differnew file mode 100644 index 0000000000..32e05bf05b --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-element-bounding-box.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-element-fractional-offset.png b/remote/test/puppeteer/test/golden-chrome/screenshot-element-fractional-offset.png Binary files differnew file mode 100644 index 0000000000..cc8669d598 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-element-fractional-offset.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-element-fractional.png b/remote/test/puppeteer/test/golden-chrome/screenshot-element-fractional.png Binary files differnew file mode 100644 index 0000000000..35c53377f9 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-element-fractional.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-element-larger-than-viewport.png b/remote/test/puppeteer/test/golden-chrome/screenshot-element-larger-than-viewport.png Binary files differnew file mode 100644 index 0000000000..5fcdb92355 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-element-larger-than-viewport.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-element-padding-border.png b/remote/test/puppeteer/test/golden-chrome/screenshot-element-padding-border.png Binary files differnew file mode 100644 index 0000000000..917dd48188 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-element-padding-border.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-element-rotate.png b/remote/test/puppeteer/test/golden-chrome/screenshot-element-rotate.png Binary files differnew file mode 100644 index 0000000000..d0c05ba795 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-element-rotate.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-element-scrolled-into-view.png b/remote/test/puppeteer/test/golden-chrome/screenshot-element-scrolled-into-view.png Binary files differnew file mode 100644 index 0000000000..917dd48188 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-element-scrolled-into-view.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-grid-fullpage-2.png b/remote/test/puppeteer/test/golden-chrome/screenshot-grid-fullpage-2.png Binary files differnew file mode 100644 index 0000000000..edc01c1041 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-grid-fullpage-2.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-grid-fullpage.png b/remote/test/puppeteer/test/golden-chrome/screenshot-grid-fullpage.png Binary files differnew file mode 100644 index 0000000000..d6d38217f7 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-grid-fullpage.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-offscreen-clip-2.png b/remote/test/puppeteer/test/golden-chrome/screenshot-offscreen-clip-2.png Binary files differnew file mode 100644 index 0000000000..7ec69d3040 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-offscreen-clip-2.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-offscreen-clip.png b/remote/test/puppeteer/test/golden-chrome/screenshot-offscreen-clip.png Binary files differnew file mode 100644 index 0000000000..d7637631b7 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-offscreen-clip.png diff --git a/remote/test/puppeteer/test/golden-chrome/screenshot-sanity.png b/remote/test/puppeteer/test/golden-chrome/screenshot-sanity.png Binary files differnew file mode 100644 index 0000000000..ecab61fe17 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/screenshot-sanity.png diff --git a/remote/test/puppeteer/test/golden-chrome/transparent.png b/remote/test/puppeteer/test/golden-chrome/transparent.png Binary files differnew file mode 100644 index 0000000000..1cf45d8688 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/transparent.png diff --git a/remote/test/puppeteer/test/golden-chrome/vision-deficiency-achromatopsia.png b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-achromatopsia.png Binary files differnew file mode 100644 index 0000000000..4d74aac44c --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-achromatopsia.png diff --git a/remote/test/puppeteer/test/golden-chrome/vision-deficiency-blurredVision.png b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-blurredVision.png Binary files differnew file mode 100644 index 0000000000..78979425a9 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-blurredVision.png diff --git a/remote/test/puppeteer/test/golden-chrome/vision-deficiency-deuteranopia.png b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-deuteranopia.png Binary files differnew file mode 100644 index 0000000000..79b4b0fa1b --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-deuteranopia.png diff --git a/remote/test/puppeteer/test/golden-chrome/vision-deficiency-protanopia.png b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-protanopia.png Binary files differnew file mode 100644 index 0000000000..bede7c1ed0 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-protanopia.png diff --git a/remote/test/puppeteer/test/golden-chrome/vision-deficiency-tritanopia.png b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-tritanopia.png Binary files differnew file mode 100644 index 0000000000..d5f6bbec2e --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/vision-deficiency-tritanopia.png diff --git a/remote/test/puppeteer/test/golden-chrome/white.jpg b/remote/test/puppeteer/test/golden-chrome/white.jpg Binary files differnew file mode 100644 index 0000000000..fb9070def3 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chrome/white.jpg diff --git a/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio1.png b/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio1.png Binary files differnew file mode 100644 index 0000000000..8c814cf3f4 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio1.png diff --git a/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio2.png b/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio2.png Binary files differnew file mode 100644 index 0000000000..a52579a1af --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio2.png diff --git a/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio3.png b/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio3.png Binary files differnew file mode 100644 index 0000000000..d43e08f4ad --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/device-pixel-ratio3.png diff --git a/remote/test/puppeteer/test/golden-firefox/grid-cell-0.png b/remote/test/puppeteer/test/golden-firefox/grid-cell-0.png Binary files differnew file mode 100644 index 0000000000..2e671db41c --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/grid-cell-0.png diff --git a/remote/test/puppeteer/test/golden-firefox/grid-cell-1.png b/remote/test/puppeteer/test/golden-firefox/grid-cell-1.png Binary files differnew file mode 100644 index 0000000000..a2a61af3d3 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/grid-cell-1.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-clip-odd-size.png b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-odd-size.png Binary files differnew file mode 100644 index 0000000000..a6f69dd20a --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-odd-size.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect-scale2.png b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect-scale2.png Binary files differnew file mode 100644 index 0000000000..5cce794edb --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect-scale2.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect.png b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect.png Binary files differnew file mode 100644 index 0000000000..0a96e67f9a --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-bounding-box.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-bounding-box.png Binary files differnew file mode 100644 index 0000000000..63956b2a7c --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-bounding-box.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional-offset.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional-offset.png Binary files differnew file mode 100644 index 0000000000..f554b1d62c --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional-offset.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional.png Binary files differnew file mode 100644 index 0000000000..5f58502b49 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-larger-than-viewport.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-larger-than-viewport.png Binary files differnew file mode 100644 index 0000000000..cc0eb7bfe4 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-larger-than-viewport.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-padding-border.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-padding-border.png Binary files differnew file mode 100644 index 0000000000..fadcaa1207 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-padding-border.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-rotate.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-rotate.png Binary files differnew file mode 100644 index 0000000000..0a78fb1ae7 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-rotate.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-scrolled-into-view.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-scrolled-into-view.png Binary files differnew file mode 100644 index 0000000000..fadcaa1207 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-scrolled-into-view.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage-2.png b/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage-2.png Binary files differnew file mode 100644 index 0000000000..ac47ec83b1 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage-2.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage.png b/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage.png Binary files differnew file mode 100644 index 0000000000..ac47ec83b1 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip-2.png b/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip-2.png Binary files differnew file mode 100644 index 0000000000..f7c0830ba9 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip-2.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip.png b/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip.png Binary files differnew file mode 100644 index 0000000000..4c34e47fbd --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-sanity.png b/remote/test/puppeteer/test/golden-firefox/screenshot-sanity.png Binary files differnew file mode 100644 index 0000000000..f02ecae645 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-sanity.png diff --git a/remote/test/puppeteer/test/golden-firefox/transparent.png b/remote/test/puppeteer/test/golden-firefox/transparent.png Binary files differnew file mode 100644 index 0000000000..1cf45d8688 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/transparent.png diff --git a/remote/test/puppeteer/test/golden-firefox/white.jpg b/remote/test/puppeteer/test/golden-firefox/white.jpg Binary files differnew file mode 100644 index 0000000000..f04d7ec2ad --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/white.jpg diff --git a/remote/test/puppeteer/test/installation/.mocharc.cjs b/remote/test/puppeteer/test/installation/.mocharc.cjs new file mode 100644 index 0000000000..5a797716e0 --- /dev/null +++ b/remote/test/puppeteer/test/installation/.mocharc.cjs @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @type {import('mocha').MochaOptions} + */ +module.exports = { + spec: ['build/**/*.spec.js'], + timeout: '240000ms', +}; diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer-core/imports.js b/remote/test/puppeteer/test/installation/assets/puppeteer-core/imports.js new file mode 100644 index 0000000000..8f8fb329e7 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer-core/imports.js @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import 'puppeteer-core'; +import 'puppeteer-core/internal/revisions.js'; +import 'puppeteer-core/lib/esm/puppeteer/revisions.js'; diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer-core/launch.js b/remote/test/puppeteer/test/installation/assets/puppeteer-core/launch.js new file mode 100644 index 0000000000..4776d7e261 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer-core/launch.js @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import puppeteer from 'puppeteer-core'; + +(async () => { + try { + await puppeteer.launch({ + product: '${product}', + executablePath: 'node', + }); + } catch (error) { + if (error.message.includes('Failed to launch the browser process')) { + process.exit(0); + } + console.error(error); + process.exit(1); + } +})(); diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer-core/requires.cjs b/remote/test/puppeteer/test/installation/assets/puppeteer-core/requires.cjs new file mode 100644 index 0000000000..f4276f2589 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer-core/requires.cjs @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +require('puppeteer-core'); +require('puppeteer-core/internal/revisions.js'); +require('puppeteer-core/lib/cjs/puppeteer/revisions.js'); diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/basic.js b/remote/test/puppeteer/test/installation/assets/puppeteer/basic.js new file mode 100644 index 0000000000..9e6ce241b2 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/basic.js @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import puppeteer from 'puppeteer'; +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('http://example.com'); + await page.$('aria/example'); + await page.screenshot({path: 'example.png'}); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/basic.ts b/remote/test/puppeteer/test/installation/assets/puppeteer/basic.ts new file mode 100644 index 0000000000..28396d0096 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/basic.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import puppeteer from 'puppeteer'; +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('http://example.com'); + await page.$('aria/example'); + await page.screenshot({path: 'example.png'}); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/bidi.js b/remote/test/puppeteer/test/installation/assets/puppeteer/bidi.js new file mode 100644 index 0000000000..3e1df93654 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/bidi.js @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import puppeteer from 'puppeteer'; +(async () => { + const browser = await puppeteer.launch({ + protocol: 'webDriverBiDi', + }); + const page = await browser.newPage(); + await page.goto('http://example.com'); + await page.$('h1'); + await page.screenshot({path: 'example.png'}); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/configuration/.puppeteerrc.cjs b/remote/test/puppeteer/test/installation/assets/puppeteer/configuration/.puppeteerrc.cjs new file mode 100644 index 0000000000..64a7b96681 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/configuration/.puppeteerrc.cjs @@ -0,0 +1,8 @@ +const {join} = require('path'); + +/** + * @type {import("puppeteer").Configuration} + */ +module.exports = { + cacheDirectory: join(__dirname, '.cache', 'puppeteer'), +}; diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/configuration/puppeteer.config.ts b/remote/test/puppeteer/test/installation/assets/puppeteer/configuration/puppeteer.config.ts new file mode 100644 index 0000000000..5bcb82ffc8 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/configuration/puppeteer.config.ts @@ -0,0 +1,6 @@ +import {type Configuration} from 'puppeteer'; +import {join} from 'path'; + +export default { + cacheDirectory: join(__dirname, '.cache', 'puppeteer'), +} satisfies Configuration; diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/imports.js b/remote/test/puppeteer/test/installation/assets/puppeteer/imports.js new file mode 100644 index 0000000000..cd742bafd5 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/imports.js @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import 'puppeteer'; + +// Should still be reachable. +import 'puppeteer-core/internal/revisions.js'; diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/installCanary.js b/remote/test/puppeteer/test/installation/assets/puppeteer/installCanary.js new file mode 100644 index 0000000000..39a0113de9 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/installCanary.js @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Browser, + detectBrowserPlatform, + install, + resolveBuildId, +} from '@puppeteer/browsers'; + +(async () => { + await install({ + cacheDir: process.env['PUPPETEER_CACHE_DIR'], + browser: Browser.CHROME, + buildId: await resolveBuildId( + Browser.CHROME, + detectBrowserPlatform(), + 'canary' + ), + }); +})(); diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/requires.cjs b/remote/test/puppeteer/test/installation/assets/puppeteer/requires.cjs new file mode 100644 index 0000000000..208eee9021 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/requires.cjs @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +require('puppeteer'); + +// Should still be reachable. +require('puppeteer-core/internal/revisions.js'); diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/trimCache.js b/remote/test/puppeteer/test/installation/assets/puppeteer/trimCache.js new file mode 100644 index 0000000000..a810e2aac2 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/trimCache.js @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import puppeteer from 'puppeteer'; + +(async () => { + await puppeteer.trimCache(); +})(); diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/tsconfig.json b/remote/test/puppeteer/test/installation/assets/puppeteer/tsconfig.json new file mode 100644 index 0000000000..ce77dbf8d9 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + }, +} diff --git a/remote/test/puppeteer/test/installation/assets/puppeteer/webpack/webpack.config.js b/remote/test/puppeteer/test/installation/assets/puppeteer/webpack/webpack.config.js new file mode 100644 index 0000000000..30de2a4890 --- /dev/null +++ b/remote/test/puppeteer/test/installation/assets/puppeteer/webpack/webpack.config.js @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export default { + mode: 'production', + entry: './index.js', + target: 'node', + externals: 'typescript', + output: { + path: process.cwd(), + filename: 'bundle.js', + }, +}; diff --git a/remote/test/puppeteer/test/installation/package.json b/remote/test/puppeteer/test/installation/package.json new file mode 100644 index 0000000000..f5e804d99c --- /dev/null +++ b/remote/test/puppeteer/test/installation/package.json @@ -0,0 +1,50 @@ +{ + "name": "@puppeteer-test/installation", + "version": "latest", + "type": "module", + "private": true, + "scripts": { + "build": "wireit", + "clean": "../../tools/clean.js", + "test": "mocha" + }, + "wireit": { + "build": { + "command": "tsc -b", + "clean": "if-file-deleted", + "dependencies": [ + "build:packages" + ], + "files": [ + "tsconfig.json", + "src/**" + ], + "output": [ + "build/**", + "tsconfig.tsbuildinfo" + ] + }, + "build:packages": { + "command": "npm pack --quiet --workspace puppeteer --workspace puppeteer-core --workspace @puppeteer/browsers", + "dependencies": [ + "../../packages/puppeteer:build", + "../../packages/puppeteer-core:build", + "../../packages/browsers:build" + ], + "files": [], + "output": [ + "puppeteer-*.tgz" + ] + } + }, + "files": [ + ".mocharc.cjs", + "puppeteer-*.tgz", + "build", + "assets" + ], + "dependencies": { + "glob": "10.3.10", + "mocha": "10.2.0" + } +} diff --git a/remote/test/puppeteer/test/installation/src/browsers.spec.ts b/remote/test/puppeteer/test/installation/src/browsers.spec.ts new file mode 100644 index 0000000000..0c91731455 --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/browsers.spec.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {spawnSync} from 'child_process'; + +import {configureSandbox} from './sandbox.js'; + +describe('`@puppeteer/browsers`', () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers'], + }); + + it('can launch CLI', async function () { + const result = spawnSync('npx', ['@puppeteer/browsers', '--help'], { + // npx is not found without the shell flag on Windows. + shell: process.platform === 'win32', + cwd: this.sandbox, + }); + assert.strictEqual(result.status, 0); + assert.ok( + result.stdout + .toString('utf-8') + .startsWith('@puppeteer/browsers <command>') + ); + }); +}); diff --git a/remote/test/puppeteer/test/installation/src/constants.ts b/remote/test/puppeteer/test/installation/src/constants.ts new file mode 100644 index 0000000000..2b66b792d5 --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/constants.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {dirname, join, resolve} from 'path'; +import {fileURLToPath} from 'url'; + +import {globSync} from 'glob'; + +export const PUPPETEER_CORE_PACKAGE_PATH = resolve( + globSync('puppeteer-core-*.tgz')[0]! +); +export const PUPPETEER_BROWSERS_PACKAGE_PATH = resolve( + globSync('puppeteer-browsers-[0-9]*.tgz')[0]! +); +export const PUPPETEER_PACKAGE_PATH = resolve( + globSync('puppeteer-[0-9]*.tgz')[0]! +); +export const ASSETS_DIR = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'assets' +); diff --git a/remote/test/puppeteer/test/installation/src/puppeteer-cli.spec.ts b/remote/test/puppeteer/test/installation/src/puppeteer-cli.spec.ts new file mode 100644 index 0000000000..650cbc1832 --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/puppeteer-cli.spec.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {spawnSync} from 'child_process'; +import {existsSync} from 'fs'; +import {readdir} from 'fs/promises'; +import {join} from 'path'; + +import {configureSandbox} from './sandbox.js'; + +describe('Puppeteer CLI', () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + PUPPETEER_SKIP_DOWNLOAD: 'true', + }; + }, + }); + + it('can launch', async function () { + const result = spawnSync('npx', ['puppeteer', '--help'], { + // npx is not found without the shell flag on Windows. + shell: process.platform === 'win32', + cwd: this.sandbox, + }); + assert.strictEqual(result.status, 0); + assert.ok( + result.stdout.toString('utf-8').startsWith('puppeteer <command>') + ); + }); + + it('can download a browser', async function () { + assert.ok(!existsSync(join(this.sandbox, '.cache', 'puppeteer'))); + const result = spawnSync( + 'npx', + ['puppeteer', 'browsers', 'install', 'chrome'], + { + // npx is not found without the shell flag on Windows. + shell: process.platform === 'win32', + cwd: this.sandbox, + env: { + ...process.env, + PUPPETEER_CACHE_DIR: join(this.sandbox, '.cache', 'puppeteer'), + }, + } + ); + assert.strictEqual(result.status, 0); + const files = await readdir(join(this.sandbox, '.cache', 'puppeteer')); + assert.equal(files.length, 1); + assert.equal(files[0], 'chrome'); + }); +}); diff --git a/remote/test/puppeteer/test/installation/src/puppeteer-configuration.spec.ts b/remote/test/puppeteer/test/installation/src/puppeteer-configuration.spec.ts new file mode 100644 index 0000000000..1ed5511f6c --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/puppeteer-configuration.spec.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {readdir, writeFile} from 'fs/promises'; +import {join} from 'path'; + +import {configureSandbox} from './sandbox.js'; +import {readAsset} from './util.js'; + +describe('`puppeteer` with configuration', () => { + describe('cjs', () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + }; + }, + before: async cwd => { + await writeFile( + join(cwd, '.puppeteerrc.cjs'), + await readAsset('puppeteer', 'configuration', '.puppeteerrc.cjs') + ); + }, + }); + + it('evaluates', async function () { + const files = await readdir(join(this.sandbox, '.cache', 'puppeteer')); + assert.equal(files.length, 2); + assert(files.includes('chrome')); + assert(files.includes('chrome-headless-shell')); + + const script = await readAsset('puppeteer', 'basic.js'); + await this.runScript(script, 'mjs'); + }); + }); + + describe('ts', () => { + configureSandbox({ + dependencies: [ + '@puppeteer/browsers', + 'puppeteer-core', + 'puppeteer', + 'typescript', + ], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + }; + }, + before: async cwd => { + await writeFile( + join(cwd, 'puppeteer.config.ts'), + await readAsset('puppeteer', 'configuration', 'puppeteer.config.ts') + ); + }, + }); + + it('evaluates', async function () { + const files = await readdir(join(this.sandbox, '.cache', 'puppeteer')); + assert.equal(files.length, 2); + assert(files.includes('chrome')); + assert(files.includes('chrome-headless-shell')); + + const script = await readAsset('puppeteer', 'basic.js'); + await this.runScript(script, 'mjs'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/installation/src/puppeteer-core.spec.ts b/remote/test/puppeteer/test/installation/src/puppeteer-core.spec.ts new file mode 100644 index 0000000000..9df19e1c85 --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/puppeteer-core.spec.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {configureSandbox} from './sandbox.js'; +import {readAsset} from './util.js'; + +describe('`puppeteer-core`', () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core'], + }); + + it('evaluates CommonJS', async function () { + const script = await readAsset('puppeteer-core', 'requires.cjs'); + await this.runScript(script, 'cjs'); + }); + + it('evaluates ES modules', async function () { + const script = await readAsset('puppeteer-core', 'imports.js'); + await this.runScript(script, 'mjs'); + }); + + for (const product of ['firefox', 'chrome']) { + it(`\`launch\` for \`${product}\` with a bad \`executablePath\``, async function () { + const script = (await readAsset('puppeteer-core', 'launch.js')).replace( + '${product}', + product + ); + await this.runScript(script, 'mjs'); + }); + } +}); diff --git a/remote/test/puppeteer/test/installation/src/puppeteer-firefox.spec.ts b/remote/test/puppeteer/test/installation/src/puppeteer-firefox.spec.ts new file mode 100644 index 0000000000..b599af01dc --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/puppeteer-firefox.spec.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {readdir} from 'fs/promises'; +import {platform} from 'os'; +import {join} from 'path'; + +import {configureSandbox} from './sandbox.js'; +import {readAsset} from './util.js'; + +// Skipping this test on Windows as windows runners are much slower. +(platform() === 'win32' ? describe.skip : describe)( + '`puppeteer` with Firefox', + () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + PUPPETEER_PRODUCT: 'firefox', + }; + }, + }); + + describe('with CDP', () => { + it('evaluates CommonJS', async function () { + const files = await readdir(join(this.sandbox, '.cache', 'puppeteer')); + assert.equal(files.length, 1); + assert.equal(files[0], 'firefox'); + const script = await readAsset('puppeteer-core', 'requires.cjs'); + await this.runScript(script, 'cjs'); + }); + + it('evaluates ES modules', async function () { + const script = await readAsset('puppeteer-core', 'imports.js'); + await this.runScript(script, 'mjs'); + }); + }); + + describe('with WebDriverBiDi', () => { + it('evaluates ES modules', async function () { + const script = await readAsset('puppeteer', 'bidi.js'); + await this.runScript(script, 'mjs'); + }); + }); + } +); diff --git a/remote/test/puppeteer/test/installation/src/puppeteer-typescript.spec.ts b/remote/test/puppeteer/test/installation/src/puppeteer-typescript.spec.ts new file mode 100644 index 0000000000..fc8ff133fb --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/puppeteer-typescript.spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {readFile, writeFile} from 'fs/promises'; +import {platform} from 'os'; +import {join} from 'path'; + +import {configureSandbox} from './sandbox.js'; +import {execFile, readAsset} from './util.js'; + +// Skipping this test on Windows as windows runners are much slower. +(platform() === 'win32' ? describe.skip : describe)( + '`puppeteer` with TypeScript', + () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + devDependencies: ['typescript@4.7.4', '@types/node@16.3.3'], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + }; + }, + }); + + it('should work', async function () { + // Write a Webpack configuration. + await writeFile( + join(this.sandbox, 'tsconfig.json'), + await readAsset('puppeteer', 'tsconfig.json') + ); + + // Write the source code. + await writeFile( + join(this.sandbox, 'index.ts'), + await readAsset('puppeteer', 'basic.ts') + ); + + // Compile. + await execFile('npx', ['tsc'], {cwd: this.sandbox, shell: true}); + + const script = await readFile(join(this.sandbox, 'index.js'), 'utf-8'); + + await this.runScript(script, 'cjs'); + }); + } +); diff --git a/remote/test/puppeteer/test/installation/src/puppeteer-webpack.spec.ts b/remote/test/puppeteer/test/installation/src/puppeteer-webpack.spec.ts new file mode 100644 index 0000000000..93902aec32 --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/puppeteer-webpack.spec.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {readFile, rm, writeFile} from 'fs/promises'; +import {join} from 'path'; + +import {configureSandbox} from './sandbox.js'; +import {execFile, readAsset} from './util.js'; + +describe('`puppeteer` with Webpack', () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + devDependencies: ['webpack', 'webpack-cli'], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + }; + }, + }); + + it('evaluates WebPack Bundles', async function () { + // Write a Webpack configuration. + await writeFile( + join(this.sandbox, 'webpack.config.mjs'), + await readAsset('puppeteer', 'webpack', 'webpack.config.js') + ); + + // Write the source code. + await writeFile( + join(this.sandbox, 'index.js'), + await readAsset('puppeteer', 'basic.js') + ); + + // Bundle. + await execFile('npx', ['webpack'], {cwd: this.sandbox, shell: true}); + + // Remove `node_modules` to test independence. + await rm('node_modules', {recursive: true, force: true}); + + const script = await readFile(join(this.sandbox, 'bundle.js'), 'utf-8'); + + await this.runScript(script, 'cjs'); + }); +}); diff --git a/remote/test/puppeteer/test/installation/src/puppeteer.spec.ts b/remote/test/puppeteer/test/installation/src/puppeteer.spec.ts new file mode 100644 index 0000000000..d7b8757284 --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/puppeteer.spec.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {readdirSync} from 'fs'; +import {readdir} from 'fs/promises'; +import {platform} from 'os'; +import {join} from 'path'; + +import {configureSandbox} from './sandbox.js'; +import {readAsset} from './util.js'; + +describe('`puppeteer`', () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + }; + }, + }); + + it('evaluates CommonJS', async function () { + const files = await readdir(join(this.sandbox, '.cache', 'puppeteer')); + assert.equal(files.length, 2); + assert(files.includes('chrome')); + assert(files.includes('chrome-headless-shell')); + + const script = await readAsset('puppeteer-core', 'requires.cjs'); + await this.runScript(script, 'cjs'); + }); + + it('evaluates ES modules', async function () { + const script = await readAsset('puppeteer-core', 'imports.js'); + await this.runScript(script, 'mjs'); + }); +}); + +// Skipping this test on Windows as windows runners are much slower. +(platform() === 'win32' ? describe.skip : describe)( + '`puppeteer` with PUPPETEER_DOWNLOAD_PATH', + () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + env: cwd => { + return { + PUPPETEER_DOWNLOAD_PATH: join(cwd, '.cache', 'puppeteer'), + }; + }, + }); + + it('evaluates', async function () { + const files = await readdir(join(this.sandbox, '.cache', 'puppeteer')); + assert.equal(files.length, 2); + assert(files.includes('chrome')); + assert(files.includes('chrome-headless-shell')); + + const script = await readAsset('puppeteer', 'basic.js'); + await this.runScript(script, 'mjs'); + }); + } +); + +// Skipping this test on Windows as windows runners are much slower. +(platform() === 'win32' ? describe.skip : describe)( + '`puppeteer` clears cache', + () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + }; + }, + }); + + it('evaluates', async function () { + assert.equal( + readdirSync(join(this.sandbox, '.cache', 'puppeteer', 'chrome')).length, + 1 + ); + + await this.runScript( + await readAsset('puppeteer', 'installCanary.js'), + 'mjs' + ); + + assert.equal( + readdirSync(join(this.sandbox, '.cache', 'puppeteer', 'chrome')).length, + 2 + ); + + await this.runScript(await readAsset('puppeteer', 'trimCache.js'), 'mjs'); + + assert.equal( + readdirSync(join(this.sandbox, '.cache', 'puppeteer', 'chrome')).length, + 1 + ); + }); + } +); diff --git a/remote/test/puppeteer/test/installation/src/sandbox.ts b/remote/test/puppeteer/test/installation/src/sandbox.ts new file mode 100644 index 0000000000..fde30dfcf9 --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/sandbox.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import crypto from 'crypto'; +import {mkdtemp, rm, writeFile} from 'fs/promises'; +import {tmpdir} from 'os'; +import {join} from 'path'; + +import { + PUPPETEER_CORE_PACKAGE_PATH, + PUPPETEER_PACKAGE_PATH, + PUPPETEER_BROWSERS_PACKAGE_PATH, +} from './constants.js'; +import {execFile} from './util.js'; + +const PKG_MANAGER = process.env['PKG_MANAGER'] || 'npm'; + +let ADD_PKG_SUBCOMMAND = 'install'; +if (PKG_MANAGER !== 'npm') { + ADD_PKG_SUBCOMMAND = 'add'; +} + +export interface ItEvaluatesOptions { + commonjs?: boolean; +} + +export interface ItEvaluatesFn { + ( + title: string, + options: ItEvaluatesOptions, + getScriptContent: (cwd: string) => Promise<string> + ): void; + (title: string, getScriptContent: (cwd: string) => Promise<string>): void; +} + +export interface SandboxOptions { + dependencies?: string[]; + devDependencies?: string[]; + /** + * This should be idempotent. + */ + env?: ((cwd: string) => NodeJS.ProcessEnv) | NodeJS.ProcessEnv; + before?: (cwd: string) => Promise<void>; +} + +declare module 'mocha' { + export interface Context { + /** + * The path to the root of the sandbox folder. + */ + sandbox: string; + env: NodeJS.ProcessEnv | undefined; + runScript: (content: string, type: 'cjs' | 'mjs') => Promise<void>; + } +} + +/** + * Configures mocha before/after hooks to create a temp folder and install + * specified dependencies. + */ +export const configureSandbox = (options: SandboxOptions): void => { + before(async function (): Promise<void> { + console.time('before'); + const sandbox = await mkdtemp(join(tmpdir(), 'puppeteer-')); + const dependencies = (options.dependencies ?? []).map(module => { + switch (module) { + case 'puppeteer': + return PUPPETEER_PACKAGE_PATH; + case 'puppeteer-core': + return PUPPETEER_CORE_PACKAGE_PATH; + case '@puppeteer/browsers': + return PUPPETEER_BROWSERS_PACKAGE_PATH; + default: + return module; + } + }); + const devDependencies = options.devDependencies ?? []; + + let getEnv: (cwd: string) => NodeJS.ProcessEnv | undefined; + if (typeof options.env === 'function') { + getEnv = options.env; + } else { + const env = options.env; + getEnv = () => { + return env; + }; + } + const env = {...process.env, ...getEnv(sandbox)}; + + await options.before?.(sandbox); + if (dependencies.length > 0) { + await execFile(PKG_MANAGER, [ADD_PKG_SUBCOMMAND, ...dependencies], { + cwd: sandbox, + env, + shell: true, + }); + } + if (devDependencies.length > 0) { + await execFile( + PKG_MANAGER, + [ADD_PKG_SUBCOMMAND, '-D', ...devDependencies], + { + cwd: sandbox, + env, + shell: true, + } + ); + } + + this.sandbox = sandbox; + this.env = env; + this.runScript = async (content: string, type: 'cjs' | 'mjs') => { + const script = join(sandbox, `script-${crypto.randomUUID()}.${type}`); + await writeFile(script, content); + await execFile('node', [script], {cwd: sandbox, env}); + }; + console.timeEnd('before'); + }); + + after(async function () { + console.time('after'); + if (!process.env['KEEP_SANDBOX']) { + await rm(this.sandbox, {recursive: true, force: true, maxRetries: 5}); + } else { + console.log('sandbox saved in', this.sandbox); + } + console.timeEnd('after'); + }); +}; diff --git a/remote/test/puppeteer/test/installation/src/util.ts b/remote/test/puppeteer/test/installation/src/util.ts new file mode 100644 index 0000000000..c975fd61e3 --- /dev/null +++ b/remote/test/puppeteer/test/installation/src/util.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {execFile as execFileAsync} from 'child_process'; +import {readFile} from 'fs/promises'; +import {join} from 'path'; +import {promisify} from 'util'; + +import {ASSETS_DIR} from './constants.js'; + +export const execFile = promisify(execFileAsync); +export const readAsset = (...components: string[]): Promise<string> => { + return readFile(join(ASSETS_DIR, ...components), 'utf8'); +}; diff --git a/remote/test/puppeteer/test/installation/tsconfig.json b/remote/test/puppeteer/test/installation/tsconfig.json new file mode 100644 index 0000000000..146127b470 --- /dev/null +++ b/remote/test/puppeteer/test/installation/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "build", + "rootDir": "src", + }, + "include": ["src"], +} diff --git a/remote/test/puppeteer/test/installation/tsdoc.json b/remote/test/puppeteer/test/installation/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/test/installation/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/test/package.json b/remote/test/puppeteer/test/package.json new file mode 100644 index 0000000000..6470297572 --- /dev/null +++ b/remote/test/puppeteer/test/package.json @@ -0,0 +1,37 @@ +{ + "name": "@puppeteer-test/test", + "version": "latest", + "private": true, + "scripts": { + "build": "wireit", + "clean": "../tools/clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b", + "clean": "if-file-deleted", + "dependencies": [ + "../packages/puppeteer:build", + "../packages/testserver:build" + ], + "files": [ + "src/**" + ], + "output": [ + "build/**", + "tsconfig.tsbuildinfo" + ] + } + }, + "dependencies": { + "diff": "5.1.0", + "jpeg-js": "0.4.4", + "pixelmatch": "5.3.0", + "pngjs": "7.0.0" + }, + "devDependencies": { + "@types/diff": "5.0.9", + "@types/pixelmatch": "5.2.6", + "@types/pngjs": "6.0.4" + } +} diff --git a/remote/test/puppeteer/test/src/accessibility.spec.ts b/remote/test/puppeteer/test/src/accessibility.spec.ts new file mode 100644 index 0000000000..09e9c90b96 --- /dev/null +++ b/remote/test/puppeteer/test/src/accessibility.spec.ts @@ -0,0 +1,567 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; + +import expect from 'expect'; +import type {SerializedAXNode} from 'puppeteer-core/internal/cdp/Accessibility.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('Accessibility', function () { + setupTestBrowserHooks(); + + it('should work', async () => { + const {page, isFirefox} = await getTestState(); + + await page.setContent(` + <head> + <title>Accessibility Test</title> + </head> + <body> + <div>Hello World</div> + <h1>Inputs</h1> + <input placeholder="Empty input" autofocus /> + <input placeholder="readonly input" readonly /> + <input placeholder="disabled input" disabled /> + <input aria-label="Input with whitespace" value=" " /> + <input value="value only" /> + <input aria-placeholder="placeholder" value="and a value" /> + <div aria-hidden="true" id="desc">This is a description!</div> + <input aria-placeholder="placeholder" value="and a value" aria-describedby="desc" /> + <select> + <option>First Option</option> + <option>Second Option</option> + </select> + </body>`); + + await page.focus('[placeholder="Empty input"]'); + const golden = isFirefox + ? { + role: 'document', + name: 'Accessibility Test', + children: [ + {role: 'text leaf', name: 'Hello World'}, + {role: 'heading', name: 'Inputs', level: 1}, + {role: 'entry', name: 'Empty input', focused: true}, + {role: 'entry', name: 'readonly input', readonly: true}, + {role: 'entry', name: 'disabled input', disabled: true}, + {role: 'entry', name: 'Input with whitespace', value: ' '}, + {role: 'entry', name: '', value: 'value only'}, + {role: 'entry', name: '', value: 'and a value'}, // firefox doesn't use aria-placeholder for the name + { + role: 'entry', + name: '', + value: 'and a value', + description: 'This is a description!', + }, // and here + { + role: 'combobox', + name: '', + value: 'First Option', + haspopup: true, + children: [ + { + role: 'combobox option', + name: 'First Option', + selected: true, + }, + {role: 'combobox option', name: 'Second Option'}, + ], + }, + ], + } + : { + role: 'RootWebArea', + name: 'Accessibility Test', + children: [ + {role: 'StaticText', name: 'Hello World'}, + {role: 'heading', name: 'Inputs', level: 1}, + {role: 'textbox', name: 'Empty input', focused: true}, + {role: 'textbox', name: 'readonly input', readonly: true}, + {role: 'textbox', name: 'disabled input', disabled: true}, + {role: 'textbox', name: 'Input with whitespace', value: ' '}, + {role: 'textbox', name: '', value: 'value only'}, + {role: 'textbox', name: 'placeholder', value: 'and a value'}, + { + role: 'textbox', + name: 'placeholder', + value: 'and a value', + description: 'This is a description!', + }, + { + role: 'combobox', + name: '', + value: 'First Option', + haspopup: 'menu', + children: [ + {role: 'menuitem', name: 'First Option', selected: true}, + {role: 'menuitem', name: 'Second Option'}, + ], + }, + ], + }; + expect(await page.accessibility.snapshot()).toMatchObject(golden); + }); + it('should report uninteresting nodes', async () => { + const {page, isFirefox} = await getTestState(); + + await page.setContent(`<textarea>hi</textarea>`); + await page.focus('textarea'); + const golden = isFirefox + ? { + role: 'entry', + name: '', + value: 'hi', + focused: true, + multiline: true, + children: [ + { + role: 'text leaf', + name: 'hi', + }, + ], + } + : { + role: 'textbox', + name: '', + value: 'hi', + focused: true, + multiline: true, + children: [ + { + role: 'generic', + name: '', + children: [ + { + role: 'StaticText', + name: 'hi', + }, + ], + }, + ], + }; + expect( + findFocusedNode( + await page.accessibility.snapshot({interestingOnly: false}) + ) + ).toMatchObject(golden); + }); + it('get snapshots while the tree is re-calculated', async () => { + // see https://github.com/puppeteer/puppeteer/issues/9404 + const {page} = await getTestState(); + + await page.setContent( + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Accessible name + aria-expanded puppeteer bug</title> + <style> + [aria-expanded="false"] + * { + display: none; + } + </style> + </head> + <body> + <button hidden>Show</button> + <p>Some content</p> + <script> + const button = document.querySelector('button'); + button.removeAttribute('hidden') + button.setAttribute('aria-expanded', 'false'); + button.addEventListener('click', function() { + button.setAttribute('aria-expanded', button.getAttribute('aria-expanded') !== 'true') + if (button.getAttribute('aria-expanded') == 'true') { + button.textContent = 'Hide' + } else { + button.textContent = 'Show' + } + }) + </script> + </body> + </html>` + ); + async function getAccessibleName(page: any, element: any) { + return (await page.accessibility.snapshot({root: element})).name; + } + using button = await page.$('button'); + expect(await getAccessibleName(page, button)).toEqual('Show'); + await button?.click(); + await page.waitForSelector('aria/Hide'); + }); + it('roledescription', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<div tabIndex=-1 aria-roledescription="foo">Hi</div>' + ); + const snapshot = await page.accessibility.snapshot(); + // See https://chromium-review.googlesource.com/c/chromium/src/+/3088862 + assert(snapshot); + assert(snapshot.children); + assert(snapshot.children[0]); + expect(snapshot.children[0]!.roledescription).toBeUndefined(); + }); + it('orientation', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<a href="" role="slider" aria-orientation="vertical">11</a>' + ); + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + assert(snapshot.children[0]); + expect(snapshot.children[0]!.orientation).toEqual('vertical'); + }); + it('autocomplete', async () => { + const {page} = await getTestState(); + + await page.setContent('<input type="number" aria-autocomplete="list" />'); + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + assert(snapshot.children[0]); + expect(snapshot.children[0]!.autocomplete).toEqual('list'); + }); + it('multiselectable', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<div role="grid" tabIndex=-1 aria-multiselectable=true>hey</div>' + ); + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + assert(snapshot.children[0]); + expect(snapshot.children[0]!.multiselectable).toEqual(true); + }); + it('keyshortcuts', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<div role="grid" tabIndex=-1 aria-keyshortcuts="foo">hey</div>' + ); + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + assert(snapshot.children[0]); + expect(snapshot.children[0]!.keyshortcuts).toEqual('foo'); + }); + describe('filtering children of leaf nodes', function () { + it('should not report text nodes inside controls', async () => { + const {page, isFirefox} = await getTestState(); + + await page.setContent(` + <div role="tablist"> + <div role="tab" aria-selected="true"><b>Tab1</b></div> + <div role="tab">Tab2</div> + </div>`); + const golden = isFirefox + ? { + role: 'document', + name: '', + children: [ + { + role: 'pagetab', + name: 'Tab1', + selected: true, + }, + { + role: 'pagetab', + name: 'Tab2', + }, + ], + } + : { + role: 'RootWebArea', + name: '', + children: [ + { + role: 'tab', + name: 'Tab1', + selected: true, + }, + { + role: 'tab', + name: 'Tab2', + }, + ], + }; + expect(await page.accessibility.snapshot()).toEqual(golden); + }); + it('rich text editable fields should have children', async () => { + const {page, isFirefox} = await getTestState(); + + await page.setContent(` + <div contenteditable="true"> + Edit this image: <img src="fakeimage.png" alt="my fake image"> + </div>`); + const golden = isFirefox + ? { + role: 'section', + name: '', + children: [ + { + role: 'text leaf', + name: 'Edit this image:', + }, + { + role: 'StaticText', + name: 'my fake image', + }, + ], + } + : { + role: 'generic', + name: '', + value: 'Edit this image: ', + children: [ + { + role: 'StaticText', + name: 'Edit this image: ', + }, + { + role: 'image', + name: 'my fake image', + }, + ], + }; + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + expect(snapshot.children[0]).toMatchObject(golden); + }); + it('rich text editable fields with role should have children', async () => { + const {page, isFirefox} = await getTestState(); + + await page.setContent(` + <div contenteditable="true" role='textbox'> + Edit this image: <img src="fakeimage.png" alt="my fake image"> + </div>`); + // Image node should not be exposed in contenteditable elements. See https://crbug.com/1324392. + const golden = isFirefox + ? { + role: 'entry', + name: '', + value: 'Edit this image: my fake image', + children: [ + { + role: 'StaticText', + name: 'my fake image', + }, + ], + } + : { + role: 'textbox', + name: '', + value: 'Edit this image: ', + multiline: true, + children: [ + { + role: 'StaticText', + name: 'Edit this image: ', + }, + ], + }; + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + expect(snapshot.children[0]).toMatchObject(golden); + }); + + // Firefox does not support contenteditable="plaintext-only". + describe('plaintext contenteditable', function () { + it('plain text field with role should not have children', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <div contenteditable="plaintext-only" role='textbox'>Edit this image:<img src="fakeimage.png" alt="my fake image"></div>`); + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + expect(snapshot.children[0]).toEqual({ + role: 'textbox', + name: '', + value: 'Edit this image:', + multiline: true, + }); + }); + }); + it('non editable textbox with role and tabIndex and label should not have children', async () => { + const {page, isFirefox} = await getTestState(); + + await page.setContent(` + <div role="textbox" tabIndex=0 aria-checked="true" aria-label="my favorite textbox"> + this is the inner content + <img alt="yo" src="fakeimg.png"> + </div>`); + const golden = isFirefox + ? { + role: 'entry', + name: 'my favorite textbox', + value: 'this is the inner content yo', + } + : { + role: 'textbox', + name: 'my favorite textbox', + value: 'this is the inner content ', + }; + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + expect(snapshot.children[0]).toEqual(golden); + }); + it('checkbox with and tabIndex and label should not have children', async () => { + const {page, isFirefox} = await getTestState(); + + await page.setContent(` + <div role="checkbox" tabIndex=0 aria-checked="true" aria-label="my favorite checkbox"> + this is the inner content + <img alt="yo" src="fakeimg.png"> + </div>`); + const golden = isFirefox + ? { + role: 'checkbutton', + name: 'my favorite checkbox', + checked: true, + } + : { + role: 'checkbox', + name: 'my favorite checkbox', + checked: true, + }; + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + expect(snapshot.children[0]).toEqual(golden); + }); + it('checkbox without label should not have children', async () => { + const {page, isFirefox} = await getTestState(); + + await page.setContent(` + <div role="checkbox" aria-checked="true"> + this is the inner content + <img alt="yo" src="fakeimg.png"> + </div>`); + const golden = isFirefox + ? { + role: 'checkbutton', + name: 'this is the inner content yo', + checked: true, + } + : { + role: 'checkbox', + name: 'this is the inner content yo', + checked: true, + }; + const snapshot = await page.accessibility.snapshot(); + assert(snapshot); + assert(snapshot.children); + expect(snapshot.children[0]).toEqual(golden); + }); + + describe('root option', function () { + it('should work a button', async () => { + const {page} = await getTestState(); + + await page.setContent(`<button>My Button</button>`); + + using button = (await page.$('button'))!; + expect(await page.accessibility.snapshot({root: button})).toEqual({ + role: 'button', + name: 'My Button', + }); + }); + it('should work an input', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input title="My Input" value="My Value">`); + + using input = (await page.$('input'))!; + expect(await page.accessibility.snapshot({root: input})).toEqual({ + role: 'textbox', + name: 'My Input', + value: 'My Value', + }); + }); + it('should work a menu', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <div role="menu" title="My Menu"> + <div role="menuitem">First Item</div> + <div role="menuitem">Second Item</div> + <div role="menuitem">Third Item</div> + </div> + `); + + using menu = (await page.$('div[role="menu"]'))!; + expect(await page.accessibility.snapshot({root: menu})).toEqual({ + role: 'menu', + name: 'My Menu', + children: [ + {role: 'menuitem', name: 'First Item'}, + {role: 'menuitem', name: 'Second Item'}, + {role: 'menuitem', name: 'Third Item'}, + ], + orientation: 'vertical', + }); + }); + it('should return null when the element is no longer in DOM', async () => { + const {page} = await getTestState(); + + await page.setContent(`<button>My Button</button>`); + using button = (await page.$('button'))!; + await page.$eval('button', button => { + return button.remove(); + }); + expect(await page.accessibility.snapshot({root: button})).toEqual(null); + }); + it('should support the interestingOnly option', async () => { + const {page} = await getTestState(); + + await page.setContent(`<div><button>My Button</button></div>`); + using div = (await page.$('div'))!; + expect(await page.accessibility.snapshot({root: div})).toEqual(null); + expect( + await page.accessibility.snapshot({ + root: div, + interestingOnly: false, + }) + ).toMatchObject({ + role: 'generic', + name: '', + children: [ + { + role: 'button', + name: 'My Button', + children: [{role: 'StaticText', name: 'My Button'}], + }, + ], + }); + }); + }); + }); + + function findFocusedNode( + node: SerializedAXNode | null + ): SerializedAXNode | null { + if (node?.focused) { + return node; + } + for (const child of node?.children || []) { + const focusedChild = findFocusedNode(child); + if (focusedChild) { + return focusedChild; + } + } + return null; + } +}); diff --git a/remote/test/puppeteer/test/src/ariaqueryhandler.spec.ts b/remote/test/puppeteer/test/src/ariaqueryhandler.spec.ts new file mode 100644 index 0000000000..434d01426a --- /dev/null +++ b/remote/test/puppeteer/test/src/ariaqueryhandler.spec.ts @@ -0,0 +1,721 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; + +import expect from 'expect'; +import {TimeoutError} from 'puppeteer'; +import type {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {attachFrame, detachFrame} from './utils.js'; + +describe('AriaQueryHandler', () => { + setupTestBrowserHooks(); + + describe('parseAriaSelector', () => { + it('should find button', async () => { + const {page} = await getTestState(); + await page.setContent( + '<button id="btn" role="button"> Submit button and some spaces </button>' + ); + const expectFound = async (button: ElementHandle | null) => { + assert(button); + const id = await button.evaluate((button: Element) => { + return button.id; + }); + expect(id).toBe('btn'); + }; + { + using button = await page.$( + 'aria/Submit button and some spaces[role="button"]' + ); + await expectFound(button); + } + { + using button = await page.$( + "aria/Submit button and some spaces[role='button']" + ); + await expectFound(button); + } + using button = await page.$( + 'aria/ Submit button and some spaces[role="button"]' + ); + await expectFound(button); + { + using button = await page.$( + 'aria/Submit button and some spaces [role="button"]' + ); + await expectFound(button); + } + { + using button = await page.$( + 'aria/Submit button and some spaces [ role = "button" ] ' + ); + await expectFound(button); + } + { + using button = await page.$( + 'aria/[role="button"]Submit button and some spaces' + ); + await expectFound(button); + } + { + using button = await page.$( + 'aria/Submit button [role="button"]and some spaces' + ); + await expectFound(button); + } + { + using button = await page.$( + 'aria/[name=" Submit button and some spaces"][role="button"]' + ); + await expectFound(button); + } + { + using button = await page.$( + "aria/[name=' Submit button and some spaces'][role='button']" + ); + await expectFound(button); + } + { + using button = await page.$( + 'aria/ignored[name="Submit button and some spaces"][role="button"]' + ); + await expectFound(button); + await expect(page.$('aria/smth[smth="true"]')).rejects.toThrow( + 'Unknown aria attribute "smth" in selector' + ); + } + }); + }); + + describe('queryOne', () => { + it('should find button by role', async () => { + const {page} = await getTestState(); + await page.setContent( + '<div id="div"><button id="btn" role="button">Submit</button></div>' + ); + using button = (await page.$( + 'aria/[role="button"]' + )) as ElementHandle<HTMLButtonElement>; + const id = await button!.evaluate(button => { + return button.id; + }); + expect(id).toBe('btn'); + }); + + it('should find button by name and role', async () => { + const {page} = await getTestState(); + await page.setContent( + '<div id="div"><button id="btn" role="button">Submit</button></div>' + ); + using button = (await page.$( + 'aria/Submit[role="button"]' + )) as ElementHandle<HTMLButtonElement>; + const id = await button!.evaluate(button => { + return button.id; + }); + expect(id).toBe('btn'); + }); + + it('should find first matching element', async () => { + const {page} = await getTestState(); + await page.setContent( + ` + <div role="menu" id="mnu1" aria-label="menu div"></div> + <div role="menu" id="mnu2" aria-label="menu div"></div> + ` + ); + using div = (await page.$( + 'aria/menu div' + )) as ElementHandle<HTMLDivElement>; + const id = await div!.evaluate(div => { + return div.id; + }); + expect(id).toBe('mnu1'); + }); + + it('should find by name', async () => { + const {page} = await getTestState(); + await page.setContent( + ` + <div role="menu" id="mnu1" aria-label="menu-label1">menu div</div> + <div role="menu" id="mnu2" aria-label="menu-label2">menu div</div> + ` + ); + using menu = (await page.$( + 'aria/menu-label1' + )) as ElementHandle<HTMLDivElement>; + const id = await menu!.evaluate(div => { + return div.id; + }); + expect(id).toBe('mnu1'); + }); + + it('should find 2nd element by name', async () => { + const {page} = await getTestState(); + await page.setContent( + ` + <div role="menu" id="mnu1" aria-label="menu-label1">menu div</div> + <div role="menu" id="mnu2" aria-label="menu-label2">menu div</div> + ` + ); + using menu = (await page.$( + 'aria/menu-label2' + )) as ElementHandle<HTMLDivElement>; + const id = await menu!.evaluate(div => { + return div.id; + }); + expect(id).toBe('mnu2'); + }); + }); + + describe('queryAll', () => { + it('should find menu by name', async () => { + const {page} = await getTestState(); + await page.setContent( + ` + <div role="menu" id="mnu1" aria-label="menu div"></div> + <div role="menu" id="mnu2" aria-label="menu div"></div> + ` + ); + const divs = (await page.$$('aria/menu div')) as Array< + ElementHandle<HTMLDivElement> + >; + const ids = await Promise.all( + divs.map(n => { + return n.evaluate(div => { + return div.id; + }); + }) + ); + expect(ids.join(', ')).toBe('mnu1, mnu2'); + }); + }); + describe('queryAllArray', () => { + it('$$eval should handle many elements', async function () { + this.timeout(40_000); + + const {page} = await getTestState(); + await page.setContent(''); + await page.evaluate( + ` + for (var i = 0; i <= 10000; i++) { + const button = document.createElement('button'); + button.textContent = i; + document.body.appendChild(button); + } + ` + ); + const sum = await page.$$eval('aria/[role="button"]', buttons => { + return buttons.reduce((acc, button) => { + return acc + Number(button.textContent); + }, 0); + }); + expect(sum).toBe(50005000); + }); + }); + + describe('waitForSelector (aria)', function () { + const addElement = (tag: string) => { + return document.body.appendChild(document.createElement(tag)); + }; + + it('should immediately resolve promise if node exists', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + }); + + it('should work for ElementHandle.waitForSelector', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + return (document.body.innerHTML = `<div><button>test</button></div>`); + }); + using element = (await page.$('div'))!; + await element!.waitForSelector('aria/test'); + }); + + it('should persist query handler bindings across reloads', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + await page.reload(); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + }); + + it('should persist query handler bindings across navigations', async () => { + const {page, server} = await getTestState(); + + // Reset page but make sure that execution context ids start with 1. + await page.goto('data:text/html,'); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + + // Reset page but again make sure that execution context ids start with 1. + await page.goto('data:text/html,'); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + }); + + it('should work independently of `exposeFunction`', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.exposeFunction('ariaQuerySelector', (a: number, b: number) => { + return a + b; + }); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + const result = await page.evaluate('globalThis.ariaQuerySelector(2,8)'); + expect(result).toBe(10); + }); + + it('should work with removed MutationObserver', async () => { + const {page} = await getTestState(); + + await page.evaluate(() => { + // @ts-expect-error This is the point of the test. + return delete window.MutationObserver; + }); + const [handle] = await Promise.all([ + page.waitForSelector('aria/anything'), + page.setContent(`<h1>anything</h1>`), + ]); + assert(handle); + expect( + await page.evaluate(x => { + return x.textContent; + }, handle) + ).toBe('anything'); + }); + + it('should resolve promise when node is added', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const watchdog = frame.waitForSelector('aria/[role="heading"]'); + await frame.evaluate(addElement, 'br'); + await frame.evaluate(addElement, 'h1'); + using elementHandle = (await watchdog)!; + const tagName = await ( + await elementHandle.getProperty('tagName') + ).jsonValue(); + expect(tagName).toBe('H1'); + }); + + it('should work when node is added through innerHTML', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const watchdog = page.waitForSelector('aria/name'); + await page.evaluate(addElement, 'span'); + await page.evaluate(() => { + return (document.querySelector('span')!.innerHTML = + '<h3><div aria-label="name"></div></h3>'); + }); + await watchdog; + }); + + it('Page.waitForSelector is shortcut for main frame', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + const otherFrame = page.frames()[1]; + const watchdog = page.waitForSelector('aria/[role="button"]'); + await otherFrame!.evaluate(addElement, 'button'); + await page.evaluate(addElement, 'button'); + using elementHandle = await watchdog; + expect(elementHandle!.frame).toBe(page.mainFrame()); + }); + + it('should run in specified frame', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]; + const frame2 = page.frames()[2]; + const waitForSelectorPromise = frame2!.waitForSelector( + 'aria/[role="button"]' + ); + await frame1!.evaluate(addElement, 'button'); + await frame2!.evaluate(addElement, 'button'); + using elementHandle = await waitForSelectorPromise; + expect(elementHandle!.frame).toBe(frame2); + }); + + it('should throw when frame is detached', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + let waitError!: Error; + const waitPromise = frame! + .waitForSelector('aria/does-not-exist') + .catch(error => { + return (waitError = error); + }); + await detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError.message).toContain( + 'waitForFunction failed: frame got detached.' + ); + }); + + it('should survive cross-process navigation', async () => { + const {page, server} = await getTestState(); + + let imgFound = false; + const waitForSelector = page + .waitForSelector('aria/[role="image"]') + .then(() => { + return (imgFound = true); + }); + await page.goto(server.EMPTY_PAGE); + expect(imgFound).toBe(false); + await page.reload(); + expect(imgFound).toBe(false); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + await waitForSelector; + expect(imgFound).toBe(true); + }); + + it('should wait for visible', async () => { + const {page} = await getTestState(); + + let divFound = false; + const waitForSelector = page + .waitForSelector('aria/name', {visible: true}) + .then(() => { + return (divFound = true); + }); + await page.setContent( + `<div aria-label='name' style='display: none; visibility: hidden;'>1</div>` + ); + expect(divFound).toBe(false); + await page.evaluate(() => { + return document.querySelector('div')!.style.removeProperty('display'); + }); + expect(divFound).toBe(false); + await page.evaluate(() => { + return document + .querySelector('div')! + .style.removeProperty('visibility'); + }); + expect(await waitForSelector).toBe(true); + expect(divFound).toBe(true); + }); + + it('should wait for visible recursively', async () => { + const {page} = await getTestState(); + + let divVisible = false; + const waitForSelector = page + .waitForSelector('aria/inner', {visible: true}) + .then(() => { + return (divVisible = true); + }) + .catch(() => { + return (divVisible = false); + }); + await page.setContent( + `<div style='display: none; visibility: hidden;'><div aria-label="inner">hi</div></div>` + ); + expect(divVisible).toBe(false); + await page.evaluate(() => { + return document.querySelector('div')!.style.removeProperty('display'); + }); + expect(divVisible).toBe(false); + await page.evaluate(() => { + return document + .querySelector('div')! + .style.removeProperty('visibility'); + }); + expect(await waitForSelector).toBe(true); + expect(divVisible).toBe(true); + }); + + it('hidden should wait for visibility: hidden', async () => { + const {page} = await getTestState(); + + let divHidden = false; + await page.setContent( + `<div role='button' style='display: block;'>text</div>` + ); + const waitForSelector = page + .waitForSelector('aria/[role="button"]', {hidden: true}) + .then(() => { + return (divHidden = true); + }) + .catch(() => { + return (divHidden = false); + }); + await page.waitForSelector('aria/[role="button"]'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => { + return document + .querySelector('div')! + .style.setProperty('visibility', 'hidden'); + }); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + + it('hidden should wait for display: none', async () => { + const {page} = await getTestState(); + + let divHidden = false; + await page.setContent( + `<div role='main' style='display: block;'>text</div>` + ); + const waitForSelector = page + .waitForSelector('aria/[role="main"]', {hidden: true}) + .then(() => { + return (divHidden = true); + }) + .catch(() => { + return (divHidden = false); + }); + await page.waitForSelector('aria/[role="main"]'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => { + return document + .querySelector('div')! + .style.setProperty('display', 'none'); + }); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + + it('hidden should wait for removal', async () => { + const {page} = await getTestState(); + + await page.setContent(`<div role='main'>text</div>`); + let divRemoved = false; + const waitForSelector = page + .waitForSelector('aria/[role="main"]', {hidden: true}) + .then(() => { + return (divRemoved = true); + }) + .catch(() => { + return (divRemoved = false); + }); + await page.waitForSelector('aria/[role="main"]'); // do a round trip + expect(divRemoved).toBe(false); + await page.evaluate(() => { + return document.querySelector('div')!.remove(); + }); + expect(await waitForSelector).toBe(true); + expect(divRemoved).toBe(true); + }); + + it('should return null if waiting to hide non-existing element', async () => { + const {page} = await getTestState(); + + using handle = await page.waitForSelector('aria/non-existing', { + hidden: true, + }); + expect(handle).toBe(null); + }); + + it('should respect timeout', async () => { + const {page} = await getTestState(); + + const error = await page + .waitForSelector('aria/[role="button"]', { + timeout: 10, + }) + .catch(error => { + return error; + }); + expect(error.message).toContain( + 'Waiting for selector `[role="button"]` failed: Waiting failed: 10ms exceeded' + ); + expect(error).toBeInstanceOf(TimeoutError); + }); + + it('should have an error message specifically for awaiting an element to be hidden', async () => { + const {page} = await getTestState(); + + await page.setContent(`<div role='main'>text</div>`); + const promise = page.waitForSelector('aria/[role="main"]', { + hidden: true, + timeout: 10, + }); + await expect(promise).rejects.toMatchObject({ + message: + 'Waiting for selector `[role="main"]` failed: Waiting failed: 10ms exceeded', + }); + }); + + it('should respond to node attribute mutation', async () => { + const {page} = await getTestState(); + + let divFound = false; + const waitForSelector = page + .waitForSelector('aria/zombo') + .then(() => { + return (divFound = true); + }) + .catch(() => { + return (divFound = false); + }); + await page.setContent(`<div aria-label='notZombo'></div>`); + expect(divFound).toBe(false); + await page.evaluate(() => { + return document + .querySelector('div')! + .setAttribute('aria-label', 'zombo'); + }); + expect(await waitForSelector).toBe(true); + }); + + it('should return the element handle', async () => { + const {page} = await getTestState(); + + const waitForSelector = page.waitForSelector('aria/zombo').catch(err => { + return err; + }); + await page.setContent(`<div aria-label='zombo'>anything</div>`); + expect( + await page.evaluate( + x => { + return x?.textContent; + }, + await waitForSelector + ) + ).toBe('anything'); + }); + + it('should have correct stack trace for timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.waitForSelector('aria/zombo', {timeout: 10}).catch(error_ => { + return (error = error_); + }); + expect(error!.stack).toContain( + 'Waiting for selector `zombo` failed: Waiting failed: 10ms exceeded' + ); + }); + }); + + describe('queryOne (Chromium web test)', () => { + async function setupPage(): ReturnType<typeof getTestState> { + const state = await getTestState(); + await state.page.setContent( + ` + <h2 id="shown">title</h2> + <h2 id="hidden" aria-hidden="true">title</h2> + <div id="node1" aria-labeledby="node2"></div> + <div id="node2" aria-label="bar"></div> + <div id="node3" aria-label="foo"></div> + <div id="node4" class="container"> + <div id="node5" role="button" aria-label="foo"></div> + <div id="node6" role="button" aria-label="foo"></div> + <!-- Accessible name not available when element is hidden --> + <div id="node7" hidden role="button" aria-label="foo"></div> + <div id="node8" role="button" aria-label="bar"></div> + </div> + <button id="node10">text content</button> + <h1 id="node11">text content</h1> + <!-- Accessible name not available when role is "presentation" --> + <h1 id="node12" role="presentation">text content</h1> + <!-- Elements inside shadow dom should be found --> + <script> + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({mode: 'open'}); + const h1 = document.createElement('h1'); + h1.textContent = 'text content'; + h1.id = 'node13'; + shadowRoot.appendChild(h1); + document.documentElement.appendChild(div); + </script> + <img id="node20" src="" alt="Accessible Name"> + <input id="node21" type="submit" value="Accessible Name"> + <label id="node22" for="node23">Accessible Name</label> + <!-- Accessible name for the <input> is "Accessible Name" --> + <input id="node23"> + <div id="node24" title="Accessible Name"></div> + <div role="treeitem" id="node30"> + <div role="treeitem" id="node31"> + <div role="treeitem" id="node32">item1</div> + <div role="treeitem" id="node33">item2</div> + </div> + <div role="treeitem" id="node34">item3</div> + </div> + <!-- Accessible name for the <div> is "item1 item2 item3" --> + <div aria-describedby="node30"></div> + ` + ); + return state; + } + const getIds = async (elements: ElementHandle[]) => { + return await Promise.all( + elements.map(element => { + return element.evaluate((element: Element) => { + return element.id; + }); + }) + ); + }; + it('should find by name "foo"', async () => { + const {page} = await setupPage(); + const found = await page.$$('aria/foo'); + const ids = await getIds(found); + expect(ids).toEqual(['node3', 'node5', 'node6']); + }); + it('should find by name "bar"', async () => { + const {page} = await setupPage(); + const found = await page.$$('aria/bar'); + const ids = await getIds(found); + expect(ids).toEqual(['node1', 'node2', 'node8']); + }); + it('should find treeitem by name', async () => { + const {page} = await setupPage(); + const found = await page.$$('aria/item1 item2 item3'); + const ids = await getIds(found); + expect(ids).toEqual(['node30']); + }); + it('should find by role "button"', async () => { + const {page} = await setupPage(); + const found = (await page.$$('aria/[role="button"]')) as Array< + ElementHandle<HTMLButtonElement> + >; + const ids = await getIds(found); + expect(ids).toEqual([ + 'node5', + 'node6', + 'node7', + 'node8', + 'node10', + 'node21', + ]); + }); + it('should find by role "heading"', async () => { + const {page} = await setupPage(); + const found = await page.$$('aria/[role="heading"]'); + const ids = await getIds(found); + expect(ids).toEqual(['shown', 'hidden', 'node11', 'node13']); + }); + it('should find both ignored and unignored', async () => { + const {page} = await setupPage(); + const found = await page.$$('aria/title'); + const ids = await getIds(found); + expect(ids).toEqual(['shown']); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/autofill.spec.ts b/remote/test/puppeteer/test/src/autofill.spec.ts new file mode 100644 index 0000000000..a04e4b8e8b --- /dev/null +++ b/remote/test/puppeteer/test/src/autofill.spec.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('Autofill', function () { + setupTestBrowserHooks(); + describe('ElementHandle.autofill', () => { + it('should fill out a credit card', async () => { + const {page, server} = await getTestState(); + await page.goto(server.PREFIX + '/credit-card.html'); + using name = await page.waitForSelector('#name'); + await name!.autofill({ + creditCard: { + number: '4444444444444444', + name: 'John Smith', + expiryMonth: '01', + expiryYear: '2030', + cvc: '123', + }, + }); + expect( + await page.evaluate(() => { + const result = []; + for (const el of document.querySelectorAll('input')) { + result.push(el.value); + } + return result.join(','); + }) + ).toBe('John Smith,4444444444444444,01,2030,Submit'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/browser.spec.ts b/remote/test/puppeteer/test/src/browser.spec.ts new file mode 100644 index 0000000000..6f21af5d9a --- /dev/null +++ b/remote/test/puppeteer/test/src/browser.spec.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('Browser specs', function () { + setupTestBrowserHooks(); + + describe('Browser.version', function () { + it('should return version', async () => { + const {browser} = await getTestState(); + + const version = await browser.version(); + expect(version.length).toBeGreaterThan(0); + expect(version.toLowerCase()).atLeastOneToContain(['firefox', 'chrome']); + }); + }); + + describe('Browser.userAgent', function () { + it('should include Browser engine', async () => { + const {browser, isChrome} = await getTestState(); + + const userAgent = await browser.userAgent(); + expect(userAgent.length).toBeGreaterThan(0); + if (isChrome) { + expect(userAgent).toContain('WebKit'); + } else { + expect(userAgent).toContain('Gecko'); + } + }); + }); + + describe('Browser.target', function () { + it('should return browser target', async () => { + const {browser} = await getTestState(); + + const target = browser.target(); + expect(target.type()).toBe('browser'); + }); + }); + + describe('Browser.process', function () { + it('should return child_process instance', async () => { + const {browser} = await getTestState(); + + const process = await browser.process(); + expect(process!.pid).toBeGreaterThan(0); + }); + it('should not return child_process for remote browser', async () => { + const {browser, puppeteer} = await getTestState(); + + const browserWSEndpoint = browser.wsEndpoint(); + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint, + protocol: browser.protocol, + }); + expect(remoteBrowser.process()).toBe(null); + await remoteBrowser.disconnect(); + }); + }); + + describe('Browser.isConnected', () => { + it('should set the browser connected state', async () => { + const {browser, puppeteer} = await getTestState(); + + const browserWSEndpoint = browser.wsEndpoint(); + const newBrowser = await puppeteer.connect({ + browserWSEndpoint, + protocol: browser.protocol, + }); + expect(newBrowser.isConnected()).toBe(true); + await newBrowser.disconnect(); + expect(newBrowser.isConnected()).toBe(false); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/browsercontext.spec.ts b/remote/test/puppeteer/test/src/browsercontext.spec.ts new file mode 100644 index 0000000000..9cbbda60a4 --- /dev/null +++ b/remote/test/puppeteer/test/src/browsercontext.spec.ts @@ -0,0 +1,368 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {TimeoutError} from 'puppeteer'; +import type {Page} from 'puppeteer-core/internal/api/Page.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {waitEvent} from './utils.js'; + +describe('BrowserContext', function () { + setupTestBrowserHooks(); + + it('should have default context', async () => { + const {browser} = await getTestState({ + skipContextCreation: true, + }); + expect(browser.browserContexts()).toHaveLength(1); + const defaultContext = browser.browserContexts()[0]!; + expect(defaultContext!.isIncognito()).toBe(false); + let error!: Error; + await defaultContext!.close().catch(error_ => { + return (error = error_); + }); + expect(browser.defaultBrowserContext()).toBe(defaultContext); + expect(error.message).toContain('cannot be closed'); + }); + it('should create new incognito context', async () => { + const {browser} = await getTestState({ + skipContextCreation: true, + }); + + expect(browser.browserContexts()).toHaveLength(1); + const context = await browser.createIncognitoBrowserContext(); + expect(context.isIncognito()).toBe(true); + expect(browser.browserContexts()).toHaveLength(2); + expect(browser.browserContexts().indexOf(context) !== -1).toBe(true); + await context.close(); + expect(browser.browserContexts()).toHaveLength(1); + }); + it('should close all belonging targets once closing context', async () => { + const {browser} = await getTestState({ + skipContextCreation: true, + }); + + expect(await browser.pages()).toHaveLength(1); + + const context = await browser.createIncognitoBrowserContext(); + await context.newPage(); + expect(await browser.pages()).toHaveLength(2); + expect(await context.pages()).toHaveLength(1); + + await context.close(); + expect(await browser.pages()).toHaveLength(1); + }); + it('window.open should use parent tab context', async () => { + const {browser, server, page, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [popupTarget] = await Promise.all([ + waitEvent(browser, 'targetcreated'), + page.evaluate(url => { + return window.open(url); + }, server.EMPTY_PAGE), + ]); + expect(popupTarget.browserContext()).toBe(context); + }); + it('should fire target events', async () => { + const {server, context} = await getTestState(); + + const events: string[] = []; + context.on('targetcreated', target => { + events.push('CREATED: ' + target.url()); + }); + context.on('targetchanged', target => { + events.push('CHANGED: ' + target.url()); + }); + context.on('targetdestroyed', target => { + events.push('DESTROYED: ' + target.url()); + }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.close(); + expect(events).toEqual([ + 'CREATED: about:blank', + `CHANGED: ${server.EMPTY_PAGE}`, + `DESTROYED: ${server.EMPTY_PAGE}`, + ]); + }); + it('should wait for a target', async () => { + const {server, context} = await getTestState(); + + let resolved = false; + + const targetPromise = context.waitForTarget(target => { + return target.url() === server.EMPTY_PAGE; + }); + targetPromise + .then(() => { + return (resolved = true); + }) + .catch(error => { + resolved = true; + if (error instanceof TimeoutError) { + console.error(error); + } else { + throw error; + } + }); + const page = await context.newPage(); + expect(resolved).toBe(false); + await page.goto(server.EMPTY_PAGE); + try { + const target = await targetPromise; + expect(await target.page()).toBe(page); + } catch (error) { + if (error instanceof TimeoutError) { + console.error(error); + } else { + throw error; + } + } + }); + + it('should timeout waiting for a non-existent target', async () => { + const {browser, server} = await getTestState(); + + const context = await browser.createIncognitoBrowserContext(); + const error = await context + .waitForTarget( + target => { + return target.url() === server.EMPTY_PAGE; + }, + { + timeout: 1, + } + ) + .catch(error_ => { + return error_; + }); + expect(error).toBeInstanceOf(TimeoutError); + await context.close(); + }); + + it('should isolate localStorage and cookies', async () => { + const {browser, server} = await getTestState({ + skipContextCreation: true, + }); + + // Create two incognito contexts. + const context1 = await browser.createIncognitoBrowserContext(); + const context2 = await browser.createIncognitoBrowserContext(); + expect(context1.targets()).toHaveLength(0); + expect(context2.targets()).toHaveLength(0); + + // Create a page in first incognito context. + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page1.evaluate(() => { + localStorage.setItem('name', 'page1'); + document.cookie = 'name=page1'; + }); + + expect(context1.targets()).toHaveLength(1); + expect(context2.targets()).toHaveLength(0); + + // Create a page in second incognito context. + const page2 = await context2.newPage(); + await page2.goto(server.EMPTY_PAGE); + await page2.evaluate(() => { + localStorage.setItem('name', 'page2'); + document.cookie = 'name=page2'; + }); + + expect(context1.targets()).toHaveLength(1); + expect(context1.targets()[0]).toBe(page1.target()); + expect(context2.targets()).toHaveLength(1); + expect(context2.targets()[0]).toBe(page2.target()); + + // Make sure pages don't share localstorage or cookies. + expect( + await page1.evaluate(() => { + return localStorage.getItem('name'); + }) + ).toBe('page1'); + expect( + await page1.evaluate(() => { + return document.cookie; + }) + ).toBe('name=page1'); + expect( + await page2.evaluate(() => { + return localStorage.getItem('name'); + }) + ).toBe('page2'); + expect( + await page2.evaluate(() => { + return document.cookie; + }) + ).toBe('name=page2'); + + // Cleanup contexts. + await Promise.all([context1.close(), context2.close()]); + expect(browser.browserContexts()).toHaveLength(1); + }); + + it('should work across sessions', async () => { + const {browser, puppeteer} = await getTestState({ + skipContextCreation: true, + }); + + expect(browser.browserContexts()).toHaveLength(1); + const context = await browser.createIncognitoBrowserContext(); + expect(browser.browserContexts()).toHaveLength(2); + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + protocol: browser.protocol, + }); + const contexts = remoteBrowser.browserContexts(); + expect(contexts).toHaveLength(2); + await remoteBrowser.disconnect(); + await context.close(); + }); + + it('should provide a context id', async () => { + const {browser} = await getTestState({ + skipContextCreation: true, + }); + + expect(browser.browserContexts()).toHaveLength(1); + expect(browser.browserContexts()[0]!.id).toBeUndefined(); + + const context = await browser.createIncognitoBrowserContext(); + expect(browser.browserContexts()).toHaveLength(2); + expect(browser.browserContexts()[1]!.id).toBeDefined(); + await context.close(); + }); + + describe('BrowserContext.overridePermissions', function () { + function getPermission(page: Page, name: PermissionName) { + return page.evaluate(name => { + return navigator.permissions.query({name}).then(result => { + return result.state; + }); + }, name); + } + + it('should be prompt by default', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + expect(await getPermission(page, 'geolocation')).toBe('prompt'); + }); + it('should deny permission when not listed', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await context.overridePermissions(server.EMPTY_PAGE, []); + expect(await getPermission(page, 'geolocation')).toBe('denied'); + }); + it('should fail when bad permission is given', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + let error!: Error; + await context + // @ts-expect-error purposeful bad input for test + .overridePermissions(server.EMPTY_PAGE, ['foo']) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe('Unknown permission: foo'); + }); + it('should grant permission when listed', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await context.overridePermissions(server.EMPTY_PAGE, ['geolocation']); + expect(await getPermission(page, 'geolocation')).toBe('granted'); + }); + it('should reset permissions', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await context.overridePermissions(server.EMPTY_PAGE, ['geolocation']); + expect(await getPermission(page, 'geolocation')).toBe('granted'); + await context.clearPermissionOverrides(); + expect(await getPermission(page, 'geolocation')).toBe('prompt'); + }); + it('should trigger permission onchange', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + (globalThis as any).events = []; + return navigator.permissions + .query({name: 'geolocation'}) + .then(function (result) { + (globalThis as any).events.push(result.state); + result.onchange = function () { + (globalThis as any).events.push(result.state); + }; + }); + }); + expect( + await page.evaluate(() => { + return (globalThis as any).events; + }) + ).toEqual(['prompt']); + await context.overridePermissions(server.EMPTY_PAGE, []); + expect( + await page.evaluate(() => { + return (globalThis as any).events; + }) + ).toEqual(['prompt', 'denied']); + await context.overridePermissions(server.EMPTY_PAGE, ['geolocation']); + expect( + await page.evaluate(() => { + return (globalThis as any).events; + }) + ).toEqual(['prompt', 'denied', 'granted']); + await context.clearPermissionOverrides(); + expect( + await page.evaluate(() => { + return (globalThis as any).events; + }) + ).toEqual(['prompt', 'denied', 'granted', 'prompt']); + }); + it('should isolate permissions between browser contexts', async () => { + const {page, server, context, browser} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const otherContext = await browser.createIncognitoBrowserContext(); + const otherPage = await otherContext.newPage(); + await otherPage.goto(server.EMPTY_PAGE); + expect(await getPermission(page, 'geolocation')).toBe('prompt'); + expect(await getPermission(otherPage, 'geolocation')).toBe('prompt'); + + await context.overridePermissions(server.EMPTY_PAGE, []); + await otherContext.overridePermissions(server.EMPTY_PAGE, [ + 'geolocation', + ]); + expect(await getPermission(page, 'geolocation')).toBe('denied'); + expect(await getPermission(otherPage, 'geolocation')).toBe('granted'); + + await context.clearPermissionOverrides(); + expect(await getPermission(page, 'geolocation')).toBe('prompt'); + expect(await getPermission(otherPage, 'geolocation')).toBe('granted'); + + await otherContext.close(); + }); + it('should grant persistent-storage', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + expect(await getPermission(page, 'persistent-storage')).not.toBe( + 'granted' + ); + await context.overridePermissions(server.EMPTY_PAGE, [ + 'persistent-storage', + ]); + expect(await getPermission(page, 'persistent-storage')).toBe('granted'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/cdp/CDPSession.spec.ts b/remote/test/puppeteer/test/src/cdp/CDPSession.spec.ts new file mode 100644 index 0000000000..2000c0e435 --- /dev/null +++ b/remote/test/puppeteer/test/src/cdp/CDPSession.spec.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import type {Target} from 'puppeteer-core/internal/api/Target.js'; +import {isErrorLike} from 'puppeteer-core/internal/util/ErrorLike.js'; + +import {getTestState, setupTestBrowserHooks} from '../mocha-utils.js'; +import {waitEvent} from '../utils.js'; + +describe('Target.createCDPSession', function () { + setupTestBrowserHooks(); + + it('should work', async () => { + const {page} = await getTestState(); + + const client = await page.createCDPSession(); + + await Promise.all([ + client.send('Runtime.enable'), + client.send('Runtime.evaluate', {expression: 'window.foo = "bar"'}), + ]); + const foo = await page.evaluate(() => { + return (globalThis as any).foo; + }); + expect(foo).toBe('bar'); + }); + + it('should not report created targets for custom CDP sessions', async () => { + const {browser} = await getTestState(); + let called = 0; + const handler = async (target: Target) => { + called++; + if (called > 1) { + throw new Error('Too many targets created'); + } + await target.createCDPSession(); + }; + browser.browserContexts()[0]!.on('targetcreated', handler); + await browser.newPage(); + browser.browserContexts()[0]!.off('targetcreated', handler); + }); + + it('should send events', async () => { + const {page, server} = await getTestState(); + + const client = await page.createCDPSession(); + await client.send('Network.enable'); + const events: unknown[] = []; + client.on('Network.requestWillBeSent', event => { + events.push(event); + }); + await Promise.all([ + waitEvent(client, 'Network.requestWillBeSent'), + page.goto(server.EMPTY_PAGE), + ]); + expect(events).toHaveLength(1); + }); + it('should enable and disable domains independently', async () => { + const {page} = await getTestState(); + + const client = await page.createCDPSession(); + await client.send('Runtime.enable'); + await client.send('Debugger.enable'); + // JS coverage enables and then disables Debugger domain. + await page.coverage.startJSCoverage(); + await page.coverage.stopJSCoverage(); + // generate a script in page and wait for the event. + const [event] = await Promise.all([ + waitEvent(client, 'Debugger.scriptParsed'), + page.evaluate('//# sourceURL=foo.js'), + ]); + // expect events to be dispatched. + expect(event.url).toBe('foo.js'); + }); + it('should be able to detach session', async () => { + const {page} = await getTestState(); + + const client = await page.createCDPSession(); + await client.send('Runtime.enable'); + const evalResponse = await client.send('Runtime.evaluate', { + expression: '1 + 2', + returnByValue: true, + }); + expect(evalResponse.result.value).toBe(3); + await client.detach(); + let error!: Error; + try { + await client.send('Runtime.evaluate', { + expression: '3 + 1', + returnByValue: true, + }); + } catch (error_) { + if (isErrorLike(error_)) { + error = error_ as Error; + } + } + expect(error.message).toContain('Session closed.'); + }); + it('should throw nice errors', async () => { + const {page} = await getTestState(); + + const client = await page.createCDPSession(); + const error = await theSourceOfTheProblems().catch(error => { + return error; + }); + expect(error.stack).toContain('theSourceOfTheProblems'); + expect(error.message).toContain('ThisCommand.DoesNotExist'); + + async function theSourceOfTheProblems() { + // @ts-expect-error This fails in TS as it knows that command does not + // exist but we want to have this tests for our users who consume in JS + // not TS. + await client.send('ThisCommand.DoesNotExist'); + } + }); + + it('should respect custom timeout', async () => { + const {page} = await getTestState(); + + const client = await page.createCDPSession(); + await expect( + client.send( + 'Runtime.evaluate', + { + expression: 'new Promise(resolve => {})', + awaitPromise: true, + }, + { + timeout: 50, + } + ) + ).rejects.toThrowError( + `Runtime.evaluate timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.` + ); + }); + + it('should expose the underlying connection', async () => { + const {page} = await getTestState(); + + const client = await page.createCDPSession(); + expect(client.connection()).toBeTruthy(); + }); +}); diff --git a/remote/test/puppeteer/test/src/cdp/TargetManager.spec.ts b/remote/test/puppeteer/test/src/cdp/TargetManager.spec.ts new file mode 100644 index 0000000000..d1f8992530 --- /dev/null +++ b/remote/test/puppeteer/test/src/cdp/TargetManager.spec.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import type {CdpBrowser} from 'puppeteer-core/internal/cdp/Browser.js'; + +import {getTestState, launch} from '../mocha-utils.js'; +import {attachFrame} from '../utils.js'; + +describe('TargetManager', () => { + /* We use a special browser for this test as we need the --site-per-process flag */ + let state: Awaited<ReturnType<typeof launch>> & { + browser: CdpBrowser; + }; + + beforeEach(async () => { + const {defaultBrowserOptions} = await getTestState({ + skipLaunch: true, + }); + state = (await launch( + Object.assign({}, defaultBrowserOptions, { + args: (defaultBrowserOptions.args || []).concat([ + '--site-per-process', + '--remote-debugging-port=21222', + '--host-rules=MAP * 127.0.0.1', + ]), + }), + {createPage: false} + )) as Awaited<ReturnType<typeof launch>> & { + browser: CdpBrowser; + }; + }); + + afterEach(async () => { + await state.close(); + }); + + // CDP-specific test. + it('should handle targets', async () => { + const {server, context, browser} = state; + + const targetManager = browser._targetManager(); + expect(targetManager.getAvailableTargets().size).toBe(3); + + expect(await context.pages()).toHaveLength(0); + expect(targetManager.getAvailableTargets().size).toBe(3); + + const page = await context.newPage(); + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(5); + + await page.goto(server.EMPTY_PAGE); + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(5); + + // attach a local iframe. + let framePromise = page.waitForFrame(frame => { + return frame.url().endsWith('/empty.html'); + }); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await framePromise; + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(5); + expect(page.frames()).toHaveLength(2); + + // // attach a remote frame iframe. + framePromise = page.waitForFrame(frame => { + return frame.url() === server.CROSS_PROCESS_PREFIX + '/empty.html'; + }); + await attachFrame( + page, + 'frame2', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + await framePromise; + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(6); + expect(page.frames()).toHaveLength(3); + + framePromise = page.waitForFrame(frame => { + return frame.url() === server.CROSS_PROCESS_PREFIX + '/empty.html'; + }); + await attachFrame( + page, + 'frame3', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + await framePromise; + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(7); + expect(page.frames()).toHaveLength(4); + }); +}); diff --git a/remote/test/puppeteer/test/src/cdp/bfcache.spec.ts b/remote/test/puppeteer/test/src/cdp/bfcache.spec.ts new file mode 100644 index 0000000000..211f93cd6b --- /dev/null +++ b/remote/test/puppeteer/test/src/cdp/bfcache.spec.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {PageEvent} from 'puppeteer-core'; + +import {launch} from '../mocha-utils.js'; +import {waitEvent} from '../utils.js'; + +describe('BFCache', function () { + it('can navigate to a BFCached page', async () => { + const {httpsServer, page, close} = await launch({ + ignoreHTTPSErrors: true, + }); + + try { + page.setDefaultTimeout(3000); + + await page.goto(httpsServer.PREFIX + '/cached/bfcache/index.html'); + + await Promise.all([page.waitForNavigation(), page.locator('a').click()]); + + expect(page.url()).toContain('target.html'); + + await Promise.all([page.waitForNavigation(), page.goBack()]); + + expect( + await page.evaluate(() => { + return document.body.innerText; + }) + ).toBe('BFCachednext'); + } finally { + await close(); + } + }); + + it('can navigate to a BFCached page containing an OOPIF and a worker', async () => { + const {httpsServer, page, close} = await launch({ + ignoreHTTPSErrors: true, + }); + try { + page.setDefaultTimeout(3000); + const [worker1] = await Promise.all([ + waitEvent(page, PageEvent.WorkerCreated), + page.goto( + httpsServer.PREFIX + '/cached/bfcache/worker-iframe-container.html' + ), + ]); + expect(await worker1.evaluate('1 + 1')).toBe(2); + await Promise.all([page.waitForNavigation(), page.locator('a').click()]); + + const [worker2] = await Promise.all([ + waitEvent(page, PageEvent.WorkerCreated), + page.waitForNavigation(), + page.goBack(), + ]); + expect(await worker2.evaluate('1 + 1')).toBe(2); + } finally { + await close(); + } + }); +}); diff --git a/remote/test/puppeteer/test/src/cdp/devtools.spec.ts b/remote/test/puppeteer/test/src/cdp/devtools.spec.ts new file mode 100644 index 0000000000..c158481af2 --- /dev/null +++ b/remote/test/puppeteer/test/src/cdp/devtools.spec.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import type {PuppeteerLaunchOptions} from 'puppeteer-core/internal/node/PuppeteerNode.js'; + +import {getTestState, launch} from '../mocha-utils.js'; + +describe('DevTools', function () { + /* These tests fire up an actual browser so let's + * allow a higher timeout + */ + this.timeout(20_000); + + let launchOptions: PuppeteerLaunchOptions & { + devtools: boolean; + }; + const browsers: Array<() => Promise<void>> = []; + + beforeEach(async () => { + const {defaultBrowserOptions} = await getTestState({ + skipLaunch: true, + }); + launchOptions = Object.assign({}, defaultBrowserOptions, { + devtools: true, + }); + }); + + async function launchBrowser(options: typeof launchOptions) { + const {browser, close} = await launch(options, {createContext: false}); + browsers.push(close); + return browser; + } + + afterEach(async () => { + await Promise.all( + browsers.map((close, index) => { + delete browsers[index]; + return close(); + }) + ); + }); + + it('target.page() should return a DevTools page if custom isPageTarget is provided', async function () { + const {puppeteer} = await getTestState({skipLaunch: true}); + const originalBrowser = await launchBrowser(launchOptions); + + const browserWSEndpoint = originalBrowser.wsEndpoint(); + + const browser = await puppeteer.connect({ + browserWSEndpoint, + _isPageTarget(target) { + return ( + target.type() === 'other' && target.url().startsWith('devtools://') + ); + }, + }); + const devtoolsPageTarget = await browser.waitForTarget(target => { + return target.type() === 'other'; + }); + const page = (await devtoolsPageTarget.page())!; + expect( + await page.evaluate(() => { + return 2 * 3; + }) + ).toBe(6); + expect(await browser.pages()).toContainEqual(page); + }); + it('target.page() should return a DevTools page if asPage is used', async function () { + const {puppeteer} = await getTestState({skipLaunch: true}); + const originalBrowser = await launchBrowser(launchOptions); + + const browserWSEndpoint = originalBrowser.wsEndpoint(); + + const browser = await puppeteer.connect({ + browserWSEndpoint, + }); + const devtoolsPageTarget = await browser.waitForTarget(target => { + return target.type() === 'other'; + }); + const page = (await devtoolsPageTarget.asPage())!; + expect( + await page.evaluate(() => { + return 2 * 3; + }) + ).toBe(6); + expect(await browser.pages()).toContainEqual(page); + }); + it('should open devtools when "devtools: true" option is given', async () => { + const browser = await launchBrowser( + Object.assign({devtools: true}, launchOptions) + ); + const context = await browser.createIncognitoBrowserContext(); + await Promise.all([ + context.newPage(), + browser.waitForTarget((target: {url: () => string | string[]}) => { + return target.url().includes('devtools://'); + }), + ]); + await browser.close(); + }); + it('should expose DevTools as a page', async () => { + const browser = await launchBrowser( + Object.assign({devtools: true}, launchOptions) + ); + const context = await browser.createIncognitoBrowserContext(); + const [target] = await Promise.all([ + browser.waitForTarget((target: {url: () => string | string[]}) => { + return target.url().includes('devtools://'); + }), + context.newPage(), + ]); + const page = await target.page(); + await page!.waitForFunction(() => { + // @ts-expect-error wrong context. + return Boolean(DevToolsAPI); + }); + await browser.close(); + }); +}); diff --git a/remote/test/puppeteer/test/src/cdp/extensions.spec.ts b/remote/test/puppeteer/test/src/cdp/extensions.spec.ts new file mode 100644 index 0000000000..6db9f931ad --- /dev/null +++ b/remote/test/puppeteer/test/src/cdp/extensions.spec.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; + +import expect from 'expect'; +import type {PuppeteerLaunchOptions} from 'puppeteer-core/internal/node/PuppeteerNode.js'; + +import {getTestState, launch} from '../mocha-utils.js'; + +const extensionPath = path.join( + __dirname, + '..', + '..', + 'assets', + 'simple-extension' +); +const serviceWorkerExtensionPath = path.join( + __dirname, + '..', + '..', + 'assets', + 'serviceworkers', + 'extension' +); + +describe('extensions', function () { + /* These tests fire up an actual browser so let's + * allow a higher timeout + */ + this.timeout(20_000); + + let extensionOptions: PuppeteerLaunchOptions & { + args: string[]; + }; + const browsers: Array<() => Promise<void>> = []; + + beforeEach(async () => { + const {defaultBrowserOptions} = await getTestState({ + skipLaunch: true, + }); + + extensionOptions = Object.assign({}, defaultBrowserOptions, { + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }); + }); + + async function launchBrowser(options: typeof extensionOptions) { + const {browser, close} = await launch(options, {createContext: false}); + browsers.push(close); + return browser; + } + + afterEach(async () => { + await Promise.all( + browsers.map((close, index) => { + delete browsers[index]; + return close(); + }) + ); + }); + + it('background_page target type should be available', async () => { + const browserWithExtension = await launchBrowser(extensionOptions); + const page = await browserWithExtension.newPage(); + const backgroundPageTarget = await browserWithExtension.waitForTarget( + target => { + return target.type() === 'background_page'; + } + ); + await page.close(); + await browserWithExtension.close(); + expect(backgroundPageTarget).toBeTruthy(); + }); + + it('service_worker target type should be available', async () => { + const browserWithExtension = await launchBrowser({ + args: [ + `--disable-extensions-except=${serviceWorkerExtensionPath}`, + `--load-extension=${serviceWorkerExtensionPath}`, + ], + }); + const page = await browserWithExtension.newPage(); + const serviceWorkerTarget = await browserWithExtension.waitForTarget( + target => { + return target.type() === 'service_worker'; + } + ); + await page.close(); + await browserWithExtension.close(); + expect(serviceWorkerTarget).toBeTruthy(); + }); + + it('target.page() should return a background_page', async function () { + const browserWithExtension = await launchBrowser(extensionOptions); + const backgroundPageTarget = await browserWithExtension.waitForTarget( + target => { + return target.type() === 'background_page'; + } + ); + const page = (await backgroundPageTarget.page())!; + expect( + await page.evaluate(() => { + return 2 * 3; + }) + ).toBe(6); + expect( + await page.evaluate(() => { + return (globalThis as any).MAGIC; + }) + ).toBe(42); + await browserWithExtension.close(); + }); +}); diff --git a/remote/test/puppeteer/test/src/cdp/prerender.spec.ts b/remote/test/puppeteer/test/src/cdp/prerender.spec.ts new file mode 100644 index 0000000000..4e0fb30da9 --- /dev/null +++ b/remote/test/puppeteer/test/src/cdp/prerender.spec.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {statSync} from 'fs'; + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from '../mocha-utils.js'; +import {getUniqueVideoFilePlaceholder} from '../utils.js'; + +describe('Prerender', function () { + setupTestBrowserHooks(); + + it('can navigate to a prerendered page via input', async () => { + const {page, server} = await getTestState(); + await page.goto(server.PREFIX + '/prerender/index.html'); + + using button = await page.waitForSelector('button'); + await button?.click(); + + using link = await page.waitForSelector('a'); + await Promise.all([page.waitForNavigation(), link?.click()]); + expect( + await page.evaluate(() => { + return document.body.innerText; + }) + ).toBe('target'); + }); + + it('can navigate to a prerendered page via Puppeteer', async () => { + const {page, server} = await getTestState(); + await page.goto(server.PREFIX + '/prerender/index.html'); + + using button = await page.waitForSelector('button'); + await button?.click(); + + await page.goto(server.PREFIX + '/prerender/target.html'); + expect( + await page.evaluate(() => { + return document.body.innerText; + }) + ).toBe('target'); + }); + + describe('via frame', () => { + it('can navigate to a prerendered page via input', async () => { + const {page, server} = await getTestState(); + await page.goto(server.PREFIX + '/prerender/index.html'); + + using button = await page.waitForSelector('button'); + await button?.click(); + + const mainFrame = page.mainFrame(); + using link = await mainFrame.waitForSelector('a'); + await Promise.all([mainFrame.waitForNavigation(), link?.click()]); + expect(mainFrame).toBe(page.mainFrame()); + expect( + await mainFrame.evaluate(() => { + return document.body.innerText; + }) + ).toBe('target'); + expect(mainFrame).toBe(page.mainFrame()); + }); + + it('can navigate to a prerendered page via Puppeteer', async () => { + const {page, server} = await getTestState(); + await page.goto(server.PREFIX + '/prerender/index.html'); + + using button = await page.waitForSelector('button'); + await button?.click(); + + const mainFrame = page.mainFrame(); + await mainFrame.goto(server.PREFIX + '/prerender/target.html'); + expect( + await mainFrame.evaluate(() => { + return document.body.innerText; + }) + ).toBe('target'); + expect(mainFrame).toBe(page.mainFrame()); + }); + }); + + it('can screencast', async () => { + using file = getUniqueVideoFilePlaceholder(); + + const {page, server} = await getTestState(); + + const recorder = await page.screencast({ + path: file.filename, + scale: 0.5, + crop: {width: 100, height: 100, x: 0, y: 0}, + speed: 0.5, + }); + + await page.goto(server.PREFIX + '/prerender/index.html'); + + using button = await page.waitForSelector('button'); + await button?.click(); + + using link = await page.locator('a').waitHandle(); + await Promise.all([page.waitForNavigation(), link.click()]); + using input = await page.locator('input').waitHandle(); + await input.type('ab', {delay: 100}); + + await recorder.stop(); + + expect(statSync(file.filename).size).toBeGreaterThan(0); + }); + + describe('with network requests', () => { + it('can receive requests from the prerendered page', async () => { + const {page, server} = await getTestState(); + + const urls: string[] = []; + page.on('request', request => { + urls.push(request.url()); + }); + + await page.goto(server.PREFIX + '/prerender/index.html'); + using button = await page.waitForSelector('button'); + await button?.click(); + const mainFrame = page.mainFrame(); + using link = await mainFrame.waitForSelector('a'); + await Promise.all([mainFrame.waitForNavigation(), link?.click()]); + expect(mainFrame).toBe(page.mainFrame()); + expect( + await mainFrame.evaluate(() => { + return document.body.innerText; + }) + ).toBe('target'); + expect(mainFrame).toBe(page.mainFrame()); + expect( + urls.find(url => { + return url.endsWith('prerender/target.html'); + }) + ).toBeTruthy(); + expect( + urls.find(url => { + return url.includes('prerender/index.html'); + }) + ).toBeTruthy(); + expect( + urls.find(url => { + return url.includes('prerender/target.html?fromPrerendered'); + }) + ).toBeTruthy(); + }); + }); + + describe('with emulation', () => { + it('can configure viewport for prerendered pages', async () => { + const {page, server} = await getTestState(); + await page.setViewport({ + width: 300, + height: 400, + }); + await page.goto(server.PREFIX + '/prerender/index.html'); + using button = await page.waitForSelector('button'); + await button?.click(); + using link = await page.waitForSelector('a'); + await Promise.all([page.waitForNavigation(), link?.click()]); + const result = await page.evaluate(() => { + return { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight, + dpr: window.devicePixelRatio, + }; + }); + expect({ + width: result.width, + height: result.height, + }).toStrictEqual({ + width: 300 * result.dpr, + height: 400 * result.dpr, + }); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/cdp/queryObjects.spec.ts b/remote/test/puppeteer/test/src/cdp/queryObjects.spec.ts new file mode 100644 index 0000000000..405303fb6b --- /dev/null +++ b/remote/test/puppeteer/test/src/cdp/queryObjects.spec.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from '../mocha-utils.js'; + +describe('page.queryObjects', function () { + setupTestBrowserHooks(); + + it('should work', async () => { + const {page} = await getTestState(); + + // Create a custom class + using classHandle = await page.evaluateHandle(() => { + return class CustomClass {}; + }); + + // Create an instance. + await page.evaluate(CustomClass => { + // @ts-expect-error: Different context. + self.customClass = new CustomClass(); + }, classHandle); + + // Validate only one has been added. + using prototypeHandle = await page.evaluateHandle(CustomClass => { + return CustomClass.prototype; + }, classHandle); + using objectsHandle = await page.queryObjects(prototypeHandle); + await expect( + page.evaluate(objects => { + return objects.length; + }, objectsHandle) + ).resolves.toBe(1); + + // Check that instances. + await expect( + page.evaluate(objects => { + // @ts-expect-error: Different context. + return objects[0] === self.customClass; + }, objectsHandle) + ).resolves.toBeTruthy(); + }); + it('should work for non-trivial page', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + + // Create a custom class + using classHandle = await page.evaluateHandle(() => { + return class CustomClass {}; + }); + + // Create an instance. + await page.evaluate(CustomClass => { + // @ts-expect-error: Different context. + self.customClass = new CustomClass(); + }, classHandle); + + // Validate only one has been added. + using prototypeHandle = await page.evaluateHandle(CustomClass => { + return CustomClass.prototype; + }, classHandle); + using objectsHandle = await page.queryObjects(prototypeHandle); + await expect( + page.evaluate(objects => { + return objects.length; + }, objectsHandle) + ).resolves.toBe(1); + + // Check that instances. + await expect( + page.evaluate(objects => { + // @ts-expect-error: Different context. + return objects[0] === self.customClass; + }, objectsHandle) + ).resolves.toBeTruthy(); + }); + it('should fail for disposed handles', async () => { + const {page} = await getTestState(); + + using prototypeHandle = await page.evaluateHandle(() => { + return HTMLBodyElement.prototype; + }); + // We want to dispose early. + await prototypeHandle.dispose(); + let error!: Error; + await page.queryObjects(prototypeHandle).catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe('Prototype JSHandle is disposed!'); + }); + it('should fail primitive values as prototypes', async () => { + const {page} = await getTestState(); + + using prototypeHandle = await page.evaluateHandle(() => { + return 42; + }); + let error!: Error; + await page.queryObjects(prototypeHandle).catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe( + 'Prototype JSHandle must not be referencing primitive value' + ); + }); +}); diff --git a/remote/test/puppeteer/test/src/chromiumonly.spec.ts b/remote/test/puppeteer/test/src/chromiumonly.spec.ts new file mode 100644 index 0000000000..e0c41317aa --- /dev/null +++ b/remote/test/puppeteer/test/src/chromiumonly.spec.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {IncomingMessage} from 'http'; + +import expect from 'expect'; +import {Deferred} from 'puppeteer-core/internal/util/Deferred.js'; + +import {getTestState, setupTestBrowserHooks, launch} from './mocha-utils.js'; +import {waitEvent} from './utils.js'; +// TODO: rename this test suite to launch/connect test suite as it actually +// works across browsers. +describe('Chromium-Specific Launcher tests', function () { + describe('Puppeteer.launch |browserURL| option', function () { + it('should be able to connect using browserUrl, with and without trailing slash', async () => { + const {close, puppeteer} = await launch({ + args: ['--remote-debugging-port=21222'], + }); + try { + const browserURL = 'http://127.0.0.1:21222'; + + const browser1 = await puppeteer.connect({browserURL}); + const page1 = await browser1.newPage(); + expect( + await page1.evaluate(() => { + return 7 * 8; + }) + ).toBe(56); + await browser1.disconnect(); + + const browser2 = await puppeteer.connect({ + browserURL: browserURL + '/', + }); + const page2 = await browser2.newPage(); + expect( + await page2.evaluate(() => { + return 8 * 7; + }) + ).toBe(56); + await browser2.disconnect(); + } finally { + await close(); + } + }); + it('should throw when using both browserWSEndpoint and browserURL', async () => { + const {browser, close, puppeteer} = await launch({ + args: ['--remote-debugging-port=21222'], + }); + try { + const browserURL = 'http://127.0.0.1:21222'; + + let error!: Error; + await puppeteer + .connect({ + browserURL, + browserWSEndpoint: browser.wsEndpoint(), + }) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain( + 'Exactly one of browserWSEndpoint, browserURL or transport' + ); + } finally { + await close(); + } + }); + it('should throw when trying to connect to non-existing browser', async () => { + const {close, puppeteer} = await launch({ + args: ['--remote-debugging-port=21222'], + }); + try { + const browserURL = 'http://127.0.0.1:32333'; + + let error!: Error; + await puppeteer.connect({browserURL}).catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain( + 'Failed to fetch browser webSocket URL from' + ); + } finally { + await close(); + } + }); + }); + + describe('Puppeteer.launch |pipe| option', function () { + it('should support the pipe option', async () => { + const {browser, close} = await launch({pipe: true}, {createPage: false}); + try { + expect(await browser.pages()).toHaveLength(1); + expect(browser.wsEndpoint()).toBe(''); + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + } finally { + await close(); + } + }); + it('should support the pipe argument', async () => { + const {defaultBrowserOptions} = await getTestState({skipLaunch: true}); + const options = Object.assign({}, defaultBrowserOptions); + options.args = ['--remote-debugging-pipe'].concat(options.args || []); + const {browser, close} = await launch(options); + try { + expect(browser.wsEndpoint()).toBe(''); + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + } finally { + await close(); + } + }); + it('should fire "disconnected" when closing with pipe', async function () { + const {browser, close} = await launch({pipe: true}); + try { + const disconnectedEventPromise = waitEvent(browser, 'disconnected'); + // Emulate user exiting browser. + browser.process()!.kill(); + await Deferred.race([ + disconnectedEventPromise, + Deferred.create({ + message: `Failed in after Hook`, + timeout: this.timeout() - 1000, + }), + ]); + } finally { + await close(); + } + }); + }); +}); + +describe('Chromium-Specific Page Tests', function () { + setupTestBrowserHooks(); + + it('Page.setRequestInterception should work with intervention headers', async () => { + const {server, page} = await getTestState(); + + server.setRoute('/intervention', (_req, res) => { + return res.end(` + <script> + document.write('<script src="${server.CROSS_PROCESS_PREFIX}/intervention.js">' + '</scr' + 'ipt>'); + </script> + `); + }); + server.setRedirect('/intervention.js', '/redirect.js'); + let serverRequest: IncomingMessage | undefined; + server.setRoute('/redirect.js', (req, res) => { + serverRequest = req; + res.end('console.log(1);'); + }); + + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue(); + }); + await page.goto(server.PREFIX + '/intervention'); + // Check for feature URL substring rather than https://www.chromestatus.com to + // make it work with Edgium. + expect(serverRequest!.headers['intervention']).toContain( + 'feature/5718547946799104' + ); + }); +}); diff --git a/remote/test/puppeteer/test/src/click.spec.ts b/remote/test/puppeteer/test/src/click.spec.ts new file mode 100644 index 0000000000..cdc0e6c133 --- /dev/null +++ b/remote/test/puppeteer/test/src/click.spec.ts @@ -0,0 +1,478 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {KnownDevices} from 'puppeteer'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {attachFrame} from './utils.js'; + +describe('Page.click', function () { + setupTestBrowserHooks(); + + it('should click the button', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + it('should click svg', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <svg height="100" width="100"> + <circle onclick="javascript:window.__CLICKED=42" cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" /> + </svg> + `); + await page.click('circle'); + expect( + await page.evaluate(() => { + return (globalThis as any).__CLICKED; + }) + ).toBe(42); + }); + it('should click the button if window.Node is removed', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => { + // @ts-expect-error Expected. + return delete window.Node; + }); + await page.click('button'); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4281 + it('should click on a span with an inline element inside', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <style> + span::before { + content: 'q'; + } + </style> + <span onclick='javascript:window.CLICKED=42'></span> + `); + await page.click('span'); + expect( + await page.evaluate(() => { + return (globalThis as any).CLICKED; + }) + ).toBe(42); + }); + it('should not throw UnhandledPromiseRejection when page closes', async () => { + const {page} = await getTestState(); + + const newPage = await page.browser().newPage(); + await Promise.all([newPage.close(), newPage.mouse.click(1, 2)]).catch( + () => {} + ); + }); + it('should click the button after navigation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + it('should click with disabled javascript', async () => { + const {page, server} = await getTestState(); + + await page.setJavaScriptEnabled(false); + await page.goto(server.PREFIX + '/wrappedlink.html'); + await Promise.all([page.click('a'), page.waitForNavigation()]); + expect(page.url()).toBe(server.PREFIX + '/wrappedlink.html#clicked'); + }); + it('should scroll and click with disabled javascript', async () => { + const {page, server} = await getTestState(); + + await page.setJavaScriptEnabled(false); + await page.goto(server.PREFIX + '/wrappedlink.html'); + using body = await page.waitForSelector('body'); + await body!.evaluate(el => { + el.style.paddingTop = '3000px'; + }); + await Promise.all([page.click('a'), page.waitForNavigation()]); + expect(page.url()).toBe(server.PREFIX + '/wrappedlink.html#clicked'); + }); + it('should click when one of inline box children is outside of viewport', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <style> + i { + position: absolute; + top: -1000px; + } + </style> + <span onclick='javascript:window.CLICKED = 42;'><i>woof</i><b>doggo</b></span> + `); + await page.click('span'); + expect( + await page.evaluate(() => { + return (globalThis as any).CLICKED; + }) + ).toBe(42); + }); + it('should select the text by triple clicking', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + const text = + "This is the text that we are going to try to select. Let's see how it goes."; + await page.keyboard.type(text); + await page.evaluate(() => { + (window as any).clicks = []; + window.addEventListener('click', event => { + return (window as any).clicks.push(event.detail); + }); + }); + await page.click('textarea', {count: 3}); + expect( + await page.evaluate(() => { + return (window as any).clicks; + }) + ).toMatchObject({0: 1, 1: 2, 2: 3}); + expect( + await page.evaluate(() => { + const textarea = document.querySelector('textarea'); + return textarea!.value.substring( + textarea!.selectionStart, + textarea!.selectionEnd + ); + }) + ).toBe(text); + }); + it('should click offscreen buttons', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + const messages: string[] = []; + page.on('console', msg => { + if (msg.type() === 'log') { + return messages.push(msg.text()); + } + return; + }); + for (let i = 0; i < 11; ++i) { + // We might've scrolled to click a button - reset to (0, 0). + await page.evaluate(() => { + return window.scrollTo(0, 0); + }); + await page.click(`#btn${i}`); + } + expect(messages).toEqual([ + 'button #0 clicked', + 'button #1 clicked', + 'button #2 clicked', + 'button #3 clicked', + 'button #4 clicked', + 'button #5 clicked', + 'button #6 clicked', + 'button #7 clicked', + 'button #8 clicked', + 'button #9 clicked', + 'button #10 clicked', + ]); + }); + + it('should click wrapped links', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/wrappedlink.html'); + await page.click('a'); + expect( + await page.evaluate(() => { + return (globalThis as any).__clicked; + }) + ).toBe(true); + }); + + it('should click on checkbox input and toggle', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/checkbox.html'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.check; + }) + ).toBe(null); + await page.click('input#agree'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.check; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return (globalThis as any).result.events; + }) + ).toEqual([ + 'mouseover', + 'mouseenter', + 'mousemove', + 'mousedown', + 'mouseup', + 'click', + 'input', + 'change', + ]); + await page.click('input#agree'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.check; + }) + ).toBe(false); + }); + + it('should click on checkbox label and toggle', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/checkbox.html'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.check; + }) + ).toBe(null); + await page.click('label[for="agree"]'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.check; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return (globalThis as any).result.events; + }) + ).toEqual(['click', 'input', 'change']); + await page.click('label[for="agree"]'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.check; + }) + ).toBe(false); + }); + + it('should fail to click a missing button', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + let error!: Error; + await page.click('button.does-not-exist').catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe( + 'No element found for selector: button.does-not-exist' + ); + }); + // @see https://github.com/puppeteer/puppeteer/issues/161 + it('should not hang with touch-enabled viewports', async () => { + const {page} = await getTestState(); + + await page.setViewport(KnownDevices['iPhone 6'].viewport); + await page.mouse.down(); + await page.mouse.move(100, 10); + await page.mouse.up(); + }); + it('should scroll and click the button', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-5'); + expect( + await page.evaluate(() => { + return document.querySelector('#button-5')!.textContent; + }) + ).toBe('clicked'); + await page.click('#button-80'); + expect( + await page.evaluate(() => { + return document.querySelector('#button-80')!.textContent; + }) + ).toBe('clicked'); + }); + it('should double click the button', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => { + (globalThis as any).double = false; + const button = document.querySelector('button'); + button!.addEventListener('dblclick', () => { + (globalThis as any).double = true; + }); + }); + using button = (await page.$('button'))!; + await button!.click({count: 2}); + expect(await page.evaluate('double')).toBe(true); + expect(await page.evaluate('result')).toBe('Clicked'); + }); + it('should click a partially obscured button', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => { + const button = document.querySelector('button'); + button!.textContent = 'Some really long text that will go offscreen'; + button!.style.position = 'absolute'; + button!.style.left = '368px'; + }); + await page.click('button'); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + it('should click a rotated button', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/rotatedButton.html'); + await page.click('button'); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + it('should fire contextmenu event on right click', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-8', {button: 'right'}); + expect( + await page.evaluate(() => { + return document.querySelector('#button-8')!.textContent; + }) + ).toBe('context menu'); + }); + it('should fire aux event on middle click', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-8', {button: 'middle'}); + expect( + await page.evaluate(() => { + return document.querySelector('#button-8')!.textContent; + }) + ).toBe('aux click'); + }); + it('should fire back click', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-8', {button: 'back'}); + expect( + await page.evaluate(() => { + return document.querySelector('#button-8')!.textContent; + }) + ).toBe('back click'); + }); + it('should fire forward click', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-8', {button: 'forward'}); + expect( + await page.evaluate(() => { + return document.querySelector('#button-8')!.textContent; + }) + ).toBe('forward click'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/206 + it('should click links which cause navigation', async () => { + const {page, server} = await getTestState(); + + await page.setContent(`<a href="${server.EMPTY_PAGE}">empty.html</a>`); + // This await should not hang. + await page.click('a'); + }); + it('should click the button inside an iframe', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent('<div style="width:100px;height:100px">spacer</div>'); + await attachFrame( + page, + 'button-test', + server.PREFIX + '/input/button.html' + ); + const frame = page.frames()[1]; + using button = await frame!.$('button'); + await button!.click(); + expect( + await frame!.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4110 + it('should click the button with fixed position inside an iframe', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setViewport({width: 500, height: 500}); + await page.setContent( + '<div style="width:100px;height:2000px">spacer</div>' + ); + await attachFrame( + page, + 'button-test', + server.CROSS_PROCESS_PREFIX + '/input/button.html' + ); + const frame = page.frames()[1]; + await frame!.$eval('button', (button: Element) => { + return (button as HTMLElement).style.setProperty('position', 'fixed'); + }); + await frame!.click('button'); + expect( + await frame!.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + it('should click the button with deviceScaleFactor set', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 400, height: 400, deviceScaleFactor: 5}); + expect( + await page.evaluate(() => { + return window.devicePixelRatio; + }) + ).toBe(5); + await page.setContent('<div style="width:100px;height:100px">spacer</div>'); + await attachFrame( + page, + 'button-test', + server.PREFIX + '/input/button.html' + ); + const frame = page.frames()[1]; + using button = await frame!.$('button'); + await button!.click(); + expect( + await frame!.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); +}); diff --git a/remote/test/puppeteer/test/src/cookies.spec.ts b/remote/test/puppeteer/test/src/cookies.spec.ts new file mode 100644 index 0000000000..f232831b72 --- /dev/null +++ b/remote/test/puppeteer/test/src/cookies.spec.ts @@ -0,0 +1,557 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import expect from 'expect'; + +import { + expectCookieEquals, + getTestState, + launch, + setupTestBrowserHooks, +} from './mocha-utils.js'; + +describe('Cookie specs', () => { + setupTestBrowserHooks(); + + describe('Page.cookies', function () { + it('should return no cookies in pristine browser context', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await expectCookieEquals(await page.cookies(), []); + }); + it('should get a cookie', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + document.cookie = 'username=John Doe'; + }); + + await expectCookieEquals(await page.cookies(), [ + { + name: 'username', + value: 'John Doe', + domain: 'localhost', + path: '/', + sameParty: false, + expires: -1, + size: 16, + httpOnly: false, + secure: false, + session: true, + sourceScheme: 'NonSecure', + }, + ]); + }); + it('should properly report httpOnly cookie', async () => { + const {page, server} = await getTestState(); + server.setRoute('/empty.html', (_req, res) => { + res.setHeader('Set-Cookie', 'a=b; HttpOnly; Path=/'); + res.end(); + }); + await page.goto(server.EMPTY_PAGE); + const cookies = await page.cookies(); + expect(cookies).toHaveLength(1); + expect(cookies[0]!.httpOnly).toBe(true); + }); + it('should properly report "Strict" sameSite cookie', async () => { + const {page, server} = await getTestState(); + server.setRoute('/empty.html', (_req, res) => { + res.setHeader('Set-Cookie', 'a=b; SameSite=Strict'); + res.end(); + }); + await page.goto(server.EMPTY_PAGE); + const cookies = await page.cookies(); + expect(cookies).toHaveLength(1); + expect(cookies[0]!.sameSite).toBe('Strict'); + }); + it('should properly report "Lax" sameSite cookie', async () => { + const {page, server} = await getTestState(); + server.setRoute('/empty.html', (_req, res) => { + res.setHeader('Set-Cookie', 'a=b; SameSite=Lax'); + res.end(); + }); + await page.goto(server.EMPTY_PAGE); + const cookies = await page.cookies(); + expect(cookies).toHaveLength(1); + expect(cookies[0]!.sameSite).toBe('Lax'); + }); + it('should get multiple cookies', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + document.cookie = 'username=John Doe'; + document.cookie = 'password=1234'; + }); + const cookies = await page.cookies(); + cookies.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + await expectCookieEquals(cookies, [ + { + name: 'password', + value: '1234', + domain: 'localhost', + path: '/', + sameParty: false, + expires: -1, + size: 12, + httpOnly: false, + secure: false, + session: true, + sourceScheme: 'NonSecure', + }, + { + name: 'username', + value: 'John Doe', + domain: 'localhost', + path: '/', + sameParty: false, + expires: -1, + size: 16, + httpOnly: false, + secure: false, + session: true, + sourceScheme: 'NonSecure', + }, + ]); + }); + it('should get cookies from multiple urls', async () => { + const {page} = await getTestState(); + await page.setCookie( + { + url: 'https://foo.com', + name: 'doggo', + value: 'woofs', + }, + { + url: 'https://bar.com', + name: 'catto', + value: 'purrs', + }, + { + url: 'https://baz.com', + name: 'birdo', + value: 'tweets', + } + ); + const cookies = await page.cookies('https://foo.com', 'https://baz.com'); + cookies.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + await expectCookieEquals(cookies, [ + { + name: 'birdo', + value: 'tweets', + domain: 'baz.com', + path: '/', + sameParty: false, + expires: -1, + size: 11, + httpOnly: false, + secure: true, + session: true, + sourcePort: 443, + sourceScheme: 'Secure', + }, + { + name: 'doggo', + value: 'woofs', + domain: 'foo.com', + path: '/', + sameParty: false, + expires: -1, + size: 10, + httpOnly: false, + secure: true, + session: true, + sourcePort: 443, + sourceScheme: 'Secure', + }, + ]); + }); + }); + describe('Page.setCookie', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + name: 'password', + value: '123456', + }); + expect( + await page.evaluate(() => { + return document.cookie; + }) + ).toEqual('password=123456'); + }); + it('should isolate cookies in browser contexts', async () => { + const {page, server, browser} = await getTestState(); + + const anotherContext = await browser.createIncognitoBrowserContext(); + const anotherPage = await anotherContext.newPage(); + + await page.goto(server.EMPTY_PAGE); + await anotherPage.goto(server.EMPTY_PAGE); + + await page.setCookie({name: 'page1cookie', value: 'page1value'}); + await anotherPage.setCookie({name: 'page2cookie', value: 'page2value'}); + + const cookies1 = await page.cookies(); + const cookies2 = await anotherPage.cookies(); + expect(cookies1).toHaveLength(1); + expect(cookies2).toHaveLength(1); + expect(cookies1[0]!.name).toBe('page1cookie'); + expect(cookies1[0]!.value).toBe('page1value'); + expect(cookies2[0]!.name).toBe('page2cookie'); + expect(cookies2[0]!.value).toBe('page2value'); + await anotherContext.close(); + }); + it('should set multiple cookies', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie( + { + name: 'password', + value: '123456', + }, + { + name: 'foo', + value: 'bar', + } + ); + const cookieStrings = await page.evaluate(() => { + const cookies = document.cookie.split(';'); + return cookies + .map(cookie => { + return cookie.trim(); + }) + .sort(); + }); + + expect(cookieStrings).toEqual(['foo=bar', 'password=123456']); + }); + it('should have |expires| set to |-1| for session cookies', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + name: 'password', + value: '123456', + }); + const cookies = await page.cookies(); + expect(cookies[0]!.session).toBe(true); + expect(cookies[0]!.expires).toBe(-1); + }); + it('should set cookie with reasonable defaults', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + name: 'password', + value: '123456', + }); + const cookies = await page.cookies(); + await expectCookieEquals( + cookies.sort((a, b) => { + return a.name.localeCompare(b.name); + }), + [ + { + name: 'password', + value: '123456', + domain: 'localhost', + path: '/', + sameParty: false, + expires: -1, + size: 14, + httpOnly: false, + secure: false, + session: true, + sourcePort: 80, + sourceScheme: 'NonSecure', + }, + ] + ); + }); + it('should set a cookie with a path', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/grid.html'); + await page.setCookie({ + name: 'gridcookie', + value: 'GRID', + path: '/grid.html', + }); + await expectCookieEquals(await page.cookies(), [ + { + name: 'gridcookie', + value: 'GRID', + domain: 'localhost', + path: '/grid.html', + sameParty: false, + expires: -1, + size: 14, + httpOnly: false, + secure: false, + session: true, + sourcePort: 80, + sourceScheme: 'NonSecure', + }, + ]); + expect(await page.evaluate('document.cookie')).toBe('gridcookie=GRID'); + await page.goto(server.EMPTY_PAGE); + await expectCookieEquals(await page.cookies(), []); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto(server.PREFIX + '/grid.html'); + expect(await page.evaluate('document.cookie')).toBe('gridcookie=GRID'); + }); + it('should not set a cookie on a blank page', async () => { + const {page} = await getTestState(); + + await page.goto('about:blank'); + let error!: Error; + try { + await page.setCookie({name: 'example-cookie', value: 'best'}); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toContain( + 'At least one of the url and domain needs to be specified' + ); + }); + it('should not set a cookie with blank page URL', async () => { + const {page, server} = await getTestState(); + + let error!: Error; + await page.goto(server.EMPTY_PAGE); + try { + await page.setCookie( + {name: 'example-cookie', value: 'best'}, + {url: 'about:blank', name: 'example-cookie-blank', value: 'best'} + ); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toEqual( + `Blank page can not have cookie "example-cookie-blank"` + ); + }); + it('should not set a cookie on a data URL page', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.goto('data:,Hello%2C%20World!'); + try { + await page.setCookie({name: 'example-cookie', value: 'best'}); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toContain( + 'At least one of the url and domain needs to be specified' + ); + }); + it('should default to setting secure cookie for HTTPS websites', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const SECURE_URL = 'https://example.com'; + await page.setCookie({ + url: SECURE_URL, + name: 'foo', + value: 'bar', + }); + const [cookie] = await page.cookies(SECURE_URL); + expect(cookie!.secure).toBe(true); + }); + it('should be able to set insecure cookie for HTTP website', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const HTTP_URL = 'http://example.com'; + await page.setCookie({ + url: HTTP_URL, + name: 'foo', + value: 'bar', + }); + const [cookie] = await page.cookies(HTTP_URL); + expect(cookie!.secure).toBe(false); + }); + it('should set a cookie on a different domain', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + url: 'https://www.example.com', + name: 'example-cookie', + value: 'best', + }); + expect(await page.evaluate('document.cookie')).toBe(''); + await expectCookieEquals(await page.cookies(), []); + await expectCookieEquals(await page.cookies('https://www.example.com'), [ + { + name: 'example-cookie', + value: 'best', + domain: 'www.example.com', + path: '/', + sameParty: false, + expires: -1, + size: 18, + httpOnly: false, + secure: true, + session: true, + sourcePort: 443, + sourceScheme: 'Secure', + }, + ]); + }); + it('should set cookies from a frame', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/grid.html'); + await page.setCookie({name: 'localhost-cookie', value: 'best'}); + await page.evaluate(src => { + let fulfill!: () => void; + const promise = new Promise<void>(x => { + return (fulfill = x); + }); + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframe.onload = fulfill; + iframe.src = src; + return promise; + }, server.CROSS_PROCESS_PREFIX); + await page.setCookie({ + name: '127-cookie', + value: 'worst', + url: server.CROSS_PROCESS_PREFIX, + }); + expect(await page.evaluate('document.cookie')).toBe( + 'localhost-cookie=best' + ); + expect(await page.frames()[1]!.evaluate('document.cookie')).toBe(''); + + await expectCookieEquals(await page.cookies(), [ + { + name: 'localhost-cookie', + value: 'best', + domain: 'localhost', + path: '/', + sameParty: false, + expires: -1, + size: 20, + httpOnly: false, + secure: false, + session: true, + sourcePort: 80, + sourceScheme: 'NonSecure', + }, + ]); + + await expectCookieEquals( + await page.cookies(server.CROSS_PROCESS_PREFIX), + [ + { + name: '127-cookie', + value: 'worst', + domain: '127.0.0.1', + path: '/', + sameParty: false, + expires: -1, + size: 15, + httpOnly: false, + secure: false, + session: true, + sourcePort: 80, + sourceScheme: 'NonSecure', + }, + ] + ); + }); + it('should set secure same-site cookies from a frame', async () => { + const {httpsServer, browser, close} = await launch({ + ignoreHTTPSErrors: true, + }); + + try { + const page = await browser.newPage(); + await page.goto(httpsServer.PREFIX + '/grid.html'); + await page.evaluate(src => { + let fulfill!: () => void; + const promise = new Promise<void>(x => { + return (fulfill = x); + }); + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframe.onload = fulfill; + iframe.src = src; + return promise; + }, httpsServer.CROSS_PROCESS_PREFIX); + await page.setCookie({ + name: '127-same-site-cookie', + value: 'best', + url: httpsServer.CROSS_PROCESS_PREFIX, + sameSite: 'None', + }); + + expect(await page.frames()[1]!.evaluate('document.cookie')).toBe( + '127-same-site-cookie=best' + ); + await expectCookieEquals( + await page.cookies(httpsServer.CROSS_PROCESS_PREFIX), + [ + { + name: '127-same-site-cookie', + value: 'best', + domain: '127.0.0.1', + path: '/', + sameParty: false, + expires: -1, + size: 24, + httpOnly: false, + sameSite: 'None', + secure: true, + session: true, + sourcePort: 443, + sourceScheme: 'Secure', + }, + ] + ); + } finally { + await close(); + } + }); + }); + + describe('Page.deleteCookie', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie( + { + name: 'cookie1', + value: '1', + }, + { + name: 'cookie2', + value: '2', + }, + { + name: 'cookie3', + value: '3', + } + ); + expect(await page.evaluate('document.cookie')).toBe( + 'cookie1=1; cookie2=2; cookie3=3' + ); + await page.deleteCookie({name: 'cookie2'}); + expect(await page.evaluate('document.cookie')).toBe( + 'cookie1=1; cookie3=3' + ); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/coverage.spec.ts b/remote/test/puppeteer/test/src/coverage.spec.ts new file mode 100644 index 0000000000..6a95db541c --- /dev/null +++ b/remote/test/puppeteer/test/src/coverage.spec.ts @@ -0,0 +1,343 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('Coverage specs', function () { + setupTestBrowserHooks(); + + describe('JSCoverage', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/simple.html', { + waitUntil: 'load', + }); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.url).toContain('/jscoverage/simple.html'); + expect(coverage[0]!.ranges).toEqual([ + {start: 0, end: 17}, + {start: 35, end: 61}, + ]); + }); + it('should report sourceURLs', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/sourceurl.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.url).toBe('nicename.js'); + }); + it('should ignore eval() scripts by default', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/eval.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(1); + }); + it('should not ignore eval() scripts if reportAnonymousScripts is true', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage({reportAnonymousScripts: true}); + await page.goto(server.PREFIX + '/jscoverage/eval.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect( + coverage.find(entry => { + return entry.url.startsWith('debugger://'); + }) + ).not.toBe(null); + expect(coverage).toHaveLength(2); + }); + it('should ignore pptr internal scripts if reportAnonymousScripts is true', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage({reportAnonymousScripts: true}); + await page.goto(server.EMPTY_PAGE); + await page.evaluate('console.log("foo")'); + await page.evaluate(() => { + return console.log('bar'); + }); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(0); + }); + it('should report multiple scripts', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/multiple.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(2); + coverage.sort((a, b) => { + return a.url.localeCompare(b.url); + }); + expect(coverage[0]!.url).toContain('/jscoverage/script1.js'); + expect(coverage[1]!.url).toContain('/jscoverage/script2.js'); + }); + it('should report right ranges', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/ranges.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(1); + const entry = coverage[0]!; + expect(entry.ranges).toHaveLength(2); + const range1 = entry.ranges[0]!; + expect(entry.text.substring(range1.start, range1.end)).toBe('\n'); + const range2 = entry.ranges[1]!; + expect(entry.text.substring(range2.start, range2.end)).toBe( + `console.log('used!');if(true===false)` + ); + }); + it('should report right ranges for "per function" scope', async () => { + const {page, server} = await getTestState(); + + const coverageOptions = { + useBlockCoverage: false, + }; + + await page.coverage.startJSCoverage(coverageOptions); + await page.goto(server.PREFIX + '/jscoverage/ranges.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(1); + const entry = coverage[0]!; + expect(entry.ranges).toHaveLength(2); + const range1 = entry.ranges[0]!; + expect(entry.text.substring(range1.start, range1.end)).toBe('\n'); + const range2 = entry.ranges[1]!; + expect(entry.text.substring(range2.start, range2.end)).toBe( + `console.log('used!');if(true===false)console.log('unused!');` + ); + }); + it('should report scripts that have no coverage', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/unused.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(1); + const entry = coverage[0]!; + expect(entry.url).toContain('unused.html'); + expect(entry.ranges).toHaveLength(0); + }); + it('should work with conditionals', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/involved.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect( + JSON.stringify(coverage, null, 2).replace(/:\d{4,5}\//g, ':<PORT>/') + ).toBeGolden('jscoverage-involved.txt'); + }); + // @see https://crbug.com/990945 + it.skip('should not hang when there is a debugger statement', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + debugger; // eslint-disable-line no-debugger + }); + await page.coverage.stopJSCoverage(); + }); + describe('resetOnNavigation', function () { + it('should report scripts across navigations when disabled', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage({resetOnNavigation: false}); + await page.goto(server.PREFIX + '/jscoverage/multiple.html'); + // TODO: navigating too fast might loose JS coverage data in the browser. + await page.waitForNetworkIdle(); + await page.goto(server.EMPTY_PAGE); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(2); + }); + + it('should NOT report scripts across navigations when enabled', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); // Enabled by default. + await page.goto(server.PREFIX + '/jscoverage/multiple.html'); + await page.goto(server.EMPTY_PAGE); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(0); + }); + }); + describe('includeRawScriptCoverage', function () { + it('should not include rawScriptCoverage field when disabled', async () => { + const {page, server} = await getTestState(); + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/simple.html', { + waitUntil: 'load', + }); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.rawScriptCoverage).toBeUndefined(); + }); + it('should include rawScriptCoverage field when enabled', async () => { + const {page, server} = await getTestState(); + await page.coverage.startJSCoverage({ + includeRawScriptCoverage: true, + }); + await page.goto(server.PREFIX + '/jscoverage/simple.html', { + waitUntil: 'load', + }); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.rawScriptCoverage).toBeTruthy(); + }); + }); + // @see https://crbug.com/990945 + it.skip('should not hang when there is a debugger statement', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + debugger; // eslint-disable-line no-debugger + }); + await page.coverage.stopJSCoverage(); + }); + }); + + describe('CSSCoverage', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/simple.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.url).toContain('/csscoverage/simple.html'); + expect(coverage[0]!.ranges).toEqual([{start: 1, end: 22}]); + const range = coverage[0]!.ranges[0]!; + expect(coverage[0]!.text.substring(range.start, range.end)).toBe( + 'div { color: green; }' + ); + }); + it('should report sourceURLs', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/sourceurl.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.url).toBe('nicename.css'); + }); + it('should report multiple stylesheets', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/multiple.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(2); + coverage.sort((a, b) => { + return a.url.localeCompare(b.url); + }); + expect(coverage[0]!.url).toContain('/csscoverage/stylesheet1.css'); + expect(coverage[1]!.url).toContain('/csscoverage/stylesheet2.css'); + }); + it('should report stylesheets that have no coverage', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/unused.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.url).toBe('unused.css'); + expect(coverage[0]!.ranges).toHaveLength(0); + }); + it('should work with media queries', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/media.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.url).toContain('/csscoverage/media.html'); + expect(coverage[0]!.ranges).toEqual([ + {start: 8, end: 15}, + {start: 17, end: 38}, + ]); + }); + it('should work with complicated usecases', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/involved.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect( + JSON.stringify(coverage, null, 2).replace(/:\d{4,5}\//g, ':<PORT>/') + ).toBeGolden('csscoverage-involved.txt'); + }); + it('should work with empty stylesheets', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/empty.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(1); + expect(coverage[0]!.text).toEqual(''); + }); + it('should ignore injected stylesheets', async () => { + const {page} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.addStyleTag({content: 'body { margin: 10px;}'}); + // trigger style recalc + const margin = await page.evaluate(() => { + return window.getComputedStyle(document.body).margin; + }); + expect(margin).toBe('10px'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(0); + }); + it('should work with a recently loaded stylesheet', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); + await page.evaluate(async url => { + document.body.textContent = 'hello, world'; + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + document.head.appendChild(link); + await new Promise(x => { + return (link.onload = x); + }); + }, server.PREFIX + '/csscoverage/stylesheet1.css'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(1); + }); + describe('resetOnNavigation', function () { + it('should report stylesheets across navigations', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage({resetOnNavigation: false}); + await page.goto(server.PREFIX + '/csscoverage/multiple.html'); + await page.goto(server.EMPTY_PAGE); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(2); + }); + it('should NOT report scripts across navigations', async () => { + const {page, server} = await getTestState(); + + await page.coverage.startCSSCoverage(); // Enabled by default. + await page.goto(server.PREFIX + '/csscoverage/multiple.html'); + await page.goto(server.EMPTY_PAGE); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage).toHaveLength(0); + }); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/debugInfo.spec.ts b/remote/test/puppeteer/test/src/debugInfo.spec.ts new file mode 100644 index 0000000000..079107cab7 --- /dev/null +++ b/remote/test/puppeteer/test/src/debugInfo.spec.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('DebugInfo', function () { + setupTestBrowserHooks(); + + describe('Browser.debugInfo', function () { + it('should work', async () => { + const {page, browser} = await getTestState(); + + const promise = page.evaluate(() => { + return new Promise(resolve => { + // @ts-expect-error another context + window.resolve = resolve; + }); + }); + try { + expect(browser.debugInfo.pendingProtocolErrors).toHaveLength(1); + } finally { + await page.evaluate(() => { + // @ts-expect-error another context + window.resolve(); + }); + } + await promise; + expect(browser.debugInfo.pendingProtocolErrors).toHaveLength(0); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/defaultbrowsercontext.spec.ts b/remote/test/puppeteer/test/src/defaultbrowsercontext.spec.ts new file mode 100644 index 0000000000..69a5a069af --- /dev/null +++ b/remote/test/puppeteer/test/src/defaultbrowsercontext.spec.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import expect from 'expect'; + +import { + expectCookieEquals, + getTestState, + setupTestBrowserHooks, +} from './mocha-utils.js'; + +describe('DefaultBrowserContext', function () { + setupTestBrowserHooks(); + + it('page.cookies() should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + document.cookie = 'username=John Doe'; + }); + await expectCookieEquals(await page.cookies(), [ + { + name: 'username', + value: 'John Doe', + domain: 'localhost', + path: '/', + sameParty: false, + expires: -1, + size: 16, + httpOnly: false, + secure: false, + session: true, + sourceScheme: 'NonSecure', + }, + ]); + }); + it('page.setCookie() should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + name: 'username', + value: 'John Doe', + }); + expect( + await page.evaluate(() => { + return document.cookie; + }) + ).toBe('username=John Doe'); + await expectCookieEquals(await page.cookies(), [ + { + name: 'username', + value: 'John Doe', + domain: 'localhost', + path: '/', + sameParty: false, + expires: -1, + size: 16, + httpOnly: false, + secure: false, + session: true, + sourcePort: 80, + sourceScheme: 'NonSecure', + }, + ]); + }); + it('page.deleteCookie() should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie( + { + name: 'cookie1', + value: '1', + }, + { + name: 'cookie2', + value: '2', + } + ); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); + await page.deleteCookie({name: 'cookie2'}); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await expectCookieEquals(await page.cookies(), [ + { + name: 'cookie1', + value: '1', + domain: 'localhost', + path: '/', + sameParty: false, + expires: -1, + size: 8, + httpOnly: false, + secure: false, + session: true, + sourcePort: 80, + sourceScheme: 'NonSecure', + }, + ]); + }); +}); diff --git a/remote/test/puppeteer/test/src/device-request-prompt.spec.ts b/remote/test/puppeteer/test/src/device-request-prompt.spec.ts new file mode 100644 index 0000000000..e6e2cdd65e --- /dev/null +++ b/remote/test/puppeteer/test/src/device-request-prompt.spec.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import expect from 'expect'; +import {TimeoutError} from 'puppeteer'; + +import {launch} from './mocha-utils.js'; + +describe('device request prompt', function () { + let state: Awaited<ReturnType<typeof launch>>; + + before(async () => { + state = await launch( + { + args: ['--enable-features=WebBluetoothNewPermissionsBackend'], + ignoreHTTPSErrors: true, + }, + { + after: 'all', + } + ); + }); + + after(async () => { + await state.close(); + }); + + beforeEach(async () => { + state.context = await state.browser.createIncognitoBrowserContext(); + state.page = await state.context.newPage(); + }); + + afterEach(async () => { + await state.context.close(); + }); + + // Bug: #11072 + it('does not crash', async function () { + this.timeout(1_000); + + const {page, httpsServer} = state; + + await page.goto(httpsServer.EMPTY_PAGE); + + await expect( + page.waitForDevicePrompt({ + timeout: 10, + }) + ).rejects.toThrow(TimeoutError); + }); +}); diff --git a/remote/test/puppeteer/test/src/dialog.spec.ts b/remote/test/puppeteer/test/src/dialog.spec.ts new file mode 100644 index 0000000000..e137ccf517 --- /dev/null +++ b/remote/test/puppeteer/test/src/dialog.spec.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import expect from 'expect'; +import sinon from 'sinon'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('Page.Events.Dialog', function () { + setupTestBrowserHooks(); + + it('should fire', async () => { + const {page} = await getTestState(); + + const onDialog = sinon.stub().callsFake(dialog => { + dialog.accept(); + }); + page.on('dialog', onDialog); + + await page.evaluate(() => { + return alert('yo'); + }); + + expect(onDialog.callCount).toEqual(1); + const dialog = onDialog.firstCall.args[0]!; + expect(dialog.type()).toBe('alert'); + expect(dialog.defaultValue()).toBe(''); + expect(dialog.message()).toBe('yo'); + }); + + it('should allow accepting prompts', async () => { + const {page} = await getTestState(); + + const onDialog = sinon.stub().callsFake(dialog => { + dialog.accept('answer!'); + }); + page.on('dialog', onDialog); + + const result = await page.evaluate(() => { + return prompt('question?', 'yes.'); + }); + + expect(onDialog.callCount).toEqual(1); + const dialog = onDialog.firstCall.args[0]!; + expect(dialog.type()).toBe('prompt'); + expect(dialog.defaultValue()).toBe('yes.'); + expect(dialog.message()).toBe('question?'); + + expect(result).toBe('answer!'); + }); + it('should dismiss the prompt', async () => { + const {page} = await getTestState(); + + page.on('dialog', dialog => { + void dialog.dismiss(); + }); + const result = await page.evaluate(() => { + return prompt('question?'); + }); + expect(result).toBe(null); + }); +}); diff --git a/remote/test/puppeteer/test/src/diffstyle.css b/remote/test/puppeteer/test/src/diffstyle.css new file mode 100644 index 0000000000..202e85f41a --- /dev/null +++ b/remote/test/puppeteer/test/src/diffstyle.css @@ -0,0 +1,13 @@ +body { + font-family: monospace; + white-space: pre; +} + +ins { + background-color: #9cffa0; + text-decoration: none; +} + +del { + background-color: #ff9e9e; +} diff --git a/remote/test/puppeteer/test/src/drag-and-drop.spec.ts b/remote/test/puppeteer/test/src/drag-and-drop.spec.ts new file mode 100644 index 0000000000..cfe18b55a4 --- /dev/null +++ b/remote/test/puppeteer/test/src/drag-and-drop.spec.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2021 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +async function getDragState() { + const {page} = await getTestState({skipLaunch: true}); + return parseInt( + await page.$eval('#drag-state', element => { + return element.innerHTML; + }), + 10 + ); +} + +describe("Legacy Drag n' Drop", function () { + setupTestBrowserHooks(); + + it('should emit a dragIntercepted event when dragged', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/drag-and-drop.html'); + expect(page.isDragInterceptionEnabled()).toBe(false); + await page.setDragInterception(true); + expect(page.isDragInterceptionEnabled()).toBe(true); + using draggable = (await page.$('#drag'))!; + const data = await draggable.drag({x: 1, y: 1}); + + assert(data instanceof Object); + expect(data.items).toHaveLength(1); + expect(await getDragState()).toBe(1); + }); + it('should emit a dragEnter', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/drag-and-drop.html'); + expect(page.isDragInterceptionEnabled()).toBe(false); + await page.setDragInterception(true); + expect(page.isDragInterceptionEnabled()).toBe(true); + using draggable = (await page.$('#drag'))!; + const data = await draggable.drag({x: 1, y: 1}); + assert(data instanceof Object); + using dropzone = (await page.$('#drop'))!; + await dropzone.dragEnter(data); + + expect(await getDragState()).toBe(12); + }); + it('should emit a dragOver event', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/drag-and-drop.html'); + expect(page.isDragInterceptionEnabled()).toBe(false); + await page.setDragInterception(true); + expect(page.isDragInterceptionEnabled()).toBe(true); + using draggable = (await page.$('#drag'))!; + const data = await draggable.drag({x: 1, y: 1}); + assert(data instanceof Object); + using dropzone = (await page.$('#drop'))!; + await dropzone.dragEnter(data); + await dropzone.dragOver(data); + + expect(await getDragState()).toBe(123); + }); + it('can be dropped', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/drag-and-drop.html'); + expect(page.isDragInterceptionEnabled()).toBe(false); + await page.setDragInterception(true); + expect(page.isDragInterceptionEnabled()).toBe(true); + using draggable = (await page.$('#drag'))!; + using dropzone = (await page.$('#drop'))!; + const data = await draggable.drag({x: 1, y: 1}); + assert(data instanceof Object); + await dropzone.dragEnter(data); + await dropzone.dragOver(data); + await dropzone.drop(data); + + expect(await getDragState()).toBe(12334); + }); + it('can be dragged and dropped with a single function', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/drag-and-drop.html'); + expect(page.isDragInterceptionEnabled()).toBe(false); + await page.setDragInterception(true); + expect(page.isDragInterceptionEnabled()).toBe(true); + using draggable = (await page.$('#drag'))!; + using dropzone = (await page.$('#drop'))!; + await draggable.dragAndDrop(dropzone); + + expect(await getDragState()).toBe(12334); + }); +}); + +describe("Drag n' Drop", () => { + setupTestBrowserHooks(); + + it('should drop', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/drag-and-drop.html'); + + using draggable = await page.$('#drag'); + assert(draggable); + using dropzone = await page.$('#drop'); + assert(dropzone); + + await dropzone.drop(draggable); + + expect(await getDragState()).toBe(1234); + }); + it('should drop using mouse', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/drag-and-drop.html'); + + using draggable = await page.$('#drag'); + assert(draggable); + using dropzone = await page.$('#drop'); + assert(dropzone); + + await draggable.hover(); + await page.mouse.down(); + await dropzone.hover(); + + expect(await getDragState()).toBe(123); + + await page.mouse.up(); + expect(await getDragState()).toBe(1234); + }); + it('should drag and drop', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/drag-and-drop.html'); + + using draggable = await page.$('#drag'); + assert(draggable); + using dropzone = await page.$('#drop'); + assert(dropzone); + + await draggable.drag(dropzone); + await dropzone.drop(draggable); + + expect(await getDragState()).toBe(1234); + }); +}); diff --git a/remote/test/puppeteer/test/src/elementhandle.spec.ts b/remote/test/puppeteer/test/src/elementhandle.spec.ts new file mode 100644 index 0000000000..9aaf914224 --- /dev/null +++ b/remote/test/puppeteer/test/src/elementhandle.spec.ts @@ -0,0 +1,953 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {Puppeteer} from 'puppeteer'; +import {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js'; +import { + asyncDisposeSymbol, + disposeSymbol, +} from 'puppeteer-core/internal/util/disposable.js'; +import sinon from 'sinon'; + +import { + getTestState, + setupTestBrowserHooks, + shortWaitForArrayToHaveAtLeastNElements, +} from './mocha-utils.js'; +import {attachFrame} from './utils.js'; + +describe('ElementHandle specs', function () { + setupTestBrowserHooks(); + + describe('ElementHandle.boundingBox', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + using elementHandle = (await page.$('.box:nth-of-type(13)'))!; + const box = await elementHandle.boundingBox(); + expect(box).toEqual({x: 100, y: 50, width: 50, height: 50}); + }); + it('should handle nested frames', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + const nestedFrame = page.frames()[1]!.childFrames()[1]!; + using elementHandle = (await nestedFrame.$('div'))!; + const box = await elementHandle.boundingBox(); + if (isChrome) { + expect(box).toEqual({x: 28, y: 182, width: 264, height: 18}); + } else { + expect(box).toEqual({x: 28, y: 182, width: 254, height: 18}); + } + }); + it('should return null for invisible elements', async () => { + const {page} = await getTestState(); + + await page.setContent('<div style="display:none">hi</div>'); + using element = (await page.$('div'))!; + expect(await element.boundingBox()).toBe(null); + }); + it('should force a layout', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent( + '<div style="width: 100px; height: 100px">hello</div>' + ); + using elementHandle = (await page.$('div'))!; + await page.evaluate((element: HTMLElement) => { + return (element.style.height = '200px'); + }, elementHandle); + const box = await elementHandle.boundingBox(); + expect(box).toEqual({x: 8, y: 8, width: 100, height: 200}); + }); + it('should work with SVG nodes', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"> + <rect id="theRect" x="30" y="50" width="200" height="300"></rect> + </svg> + `); + using element = (await page.$( + '#therect' + )) as ElementHandle<SVGRectElement>; + const pptrBoundingBox = await element.boundingBox(); + const webBoundingBox = await page.evaluate(e => { + const rect = e.getBoundingClientRect(); + return {x: rect.x, y: rect.y, width: rect.width, height: rect.height}; + }, element); + expect(pptrBoundingBox).toEqual(webBoundingBox); + }); + }); + + describe('ElementHandle.boxModel', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/resetcss.html'); + + // Step 1: Add Frame and position it absolutely. + await attachFrame(page, 'frame1', server.PREFIX + '/resetcss.html'); + await page.evaluate(() => { + const frame = document.querySelector<HTMLElement>('#frame1')!; + frame.style.position = 'absolute'; + frame.style.left = '1px'; + frame.style.top = '2px'; + }); + + // Step 2: Add div and position it absolutely inside frame. + const frame = page.frames()[1]!; + using divHandle = ( + await frame.evaluateHandle(() => { + const div = document.createElement('div'); + document.body.appendChild(div); + div.style.boxSizing = 'border-box'; + div.style.position = 'absolute'; + div.style.borderLeft = '1px solid black'; + div.style.paddingLeft = '2px'; + div.style.marginLeft = '3px'; + div.style.left = '4px'; + div.style.top = '5px'; + div.style.width = '6px'; + div.style.height = '7px'; + return div; + }) + ).asElement()!; + + // Step 3: query div's boxModel and assert box values. + const box = (await divHandle.boxModel())!; + expect(box.width).toBe(6); + expect(box.height).toBe(7); + expect(box.margin[0]).toEqual({ + x: 1 + 4, // frame.left + div.left + y: 2 + 5, + }); + expect(box.border[0]).toEqual({ + x: 1 + 4 + 3, // frame.left + div.left + div.margin-left + y: 2 + 5, + }); + expect(box.padding[0]).toEqual({ + x: 1 + 4 + 3 + 1, // frame.left + div.left + div.marginLeft + div.borderLeft + y: 2 + 5, + }); + expect(box.content[0]).toEqual({ + x: 1 + 4 + 3 + 1 + 2, // frame.left + div.left + div.marginLeft + div.borderLeft + div.paddingLeft + y: 2 + 5, + }); + }); + + it('should return null for invisible elements', async () => { + const {page} = await getTestState(); + + await page.setContent('<div style="display:none">hi</div>'); + using element = (await page.$('div'))!; + expect(await element.boxModel()).toBe(null); + }); + }); + + describe('ElementHandle.contentFrame', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + using elementHandle = (await page.$('#frame1'))!; + const frame = await elementHandle.contentFrame(); + expect(frame).toBe(page.frames()[1]); + }); + }); + + describe('ElementHandle.isVisible and ElementHandle.isHidden', function () { + it('should work', async () => { + const {page} = await getTestState(); + await page.setContent('<div style="display: none">text</div>'); + using element = (await page.waitForSelector('div'))!; + await expect(element.isVisible()).resolves.toBeFalsy(); + await expect(element.isHidden()).resolves.toBeTruthy(); + await element.evaluate(e => { + e.style.removeProperty('display'); + }); + await expect(element.isVisible()).resolves.toBeTruthy(); + await expect(element.isHidden()).resolves.toBeFalsy(); + }); + }); + + describe('ElementHandle.click', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + using button = (await page.$('button'))!; + await button.click(); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + it('should return Point data', async () => { + const {page} = await getTestState(); + + const clicks: Array<[x: number, y: number]> = []; + + await page.exposeFunction('reportClick', (x: number, y: number): void => { + clicks.push([x, y]); + }); + + await page.evaluate(() => { + document.body.style.padding = '0'; + document.body.style.margin = '0'; + document.body.innerHTML = ` + <div style="cursor: pointer; width: 120px; height: 60px; margin: 30px; padding: 15px;"></div> + `; + document.body.addEventListener('click', e => { + (window as any).reportClick(e.clientX, e.clientY); + }); + }); + + using divHandle = (await page.$('div'))!; + await divHandle.click(); + await divHandle.click({ + offset: { + x: 10, + y: 15, + }, + }); + await shortWaitForArrayToHaveAtLeastNElements(clicks, 2); + expect(clicks).toEqual([ + [45 + 60, 45 + 30], // margin + middle point offset + [30 + 10, 30 + 15], // margin + offset + ]); + }); + it('should work for Shadow DOM v1', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/shadow.html'); + using buttonHandle = await page.evaluateHandle(() => { + // @ts-expect-error button is expected to be in the page's scope. + return button as HTMLButtonElement; + }); + await buttonHandle.click(); + expect( + await page.evaluate(() => { + // @ts-expect-error clicked is expected to be in the page's scope. + return clicked; + }) + ).toBe(true); + }); + it('should not work for TextNodes', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + using buttonTextNode = await page.evaluateHandle(() => { + return document.querySelector('button')!.firstChild as HTMLElement; + }); + let error!: Error; + await buttonTextNode.click().catch(error_ => { + return (error = error_); + }); + expect(error.message).atLeastOneToContain([ + 'Node is not of type HTMLElement', + 'no such node', + ]); + }); + it('should throw for detached nodes', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + using button = (await page.$('button'))!; + await page.evaluate((button: HTMLElement) => { + return button.remove(); + }, button); + let error!: Error; + await button.click().catch(error_ => { + return (error = error_); + }); + expect(error.message).atLeastOneToContain([ + 'Node is detached from document', + 'no such node', + ]); + }); + it('should throw for hidden nodes', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + using button = (await page.$('button'))!; + await page.evaluate((button: HTMLElement) => { + return (button.style.display = 'none'); + }, button); + const error = await button.click().catch(error_ => { + return error_; + }); + expect(error.message).atLeastOneToContain([ + 'Node is either not clickable or not an Element', + 'no such element', + ]); + }); + it('should throw for recursively hidden nodes', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + using button = (await page.$('button'))!; + await page.evaluate((button: HTMLElement) => { + return (button.parentElement!.style.display = 'none'); + }, button); + const error = await button.click().catch(error_ => { + return error_; + }); + expect(error.message).atLeastOneToContain([ + 'Node is either not clickable or not an Element', + 'no such element', + ]); + }); + it('should throw for <br> elements', async () => { + const {page} = await getTestState(); + + await page.setContent('hello<br>goodbye'); + using br = (await page.$('br'))!; + const error = await br.click().catch(error_ => { + return error_; + }); + expect(error.message).atLeastOneToContain([ + 'Node is either not clickable or not an Element', + 'no such node', + ]); + }); + }); + + describe('ElementHandle.clickablePoint', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.evaluate(() => { + document.body.style.padding = '0'; + document.body.style.margin = '0'; + document.body.innerHTML = ` + <div style="cursor: pointer; width: 120px; height: 60px; margin: 30px; padding: 15px;"></div> + `; + }); + await page.evaluate(async () => { + return await new Promise(resolve => { + return window.requestAnimationFrame(resolve); + }); + }); + using divHandle = (await page.$('div'))!; + expect(await divHandle.clickablePoint()).toEqual({ + x: 45 + 60, // margin + middle point offset + y: 45 + 30, // margin + middle point offset + }); + expect( + await divHandle.clickablePoint({ + x: 10, + y: 15, + }) + ).toEqual({ + x: 30 + 10, // margin + offset + y: 30 + 15, // margin + offset + }); + }); + + it('should not work if the click box is not visible', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<button style="width: 10px; height: 10px; position: absolute; left: -20px"></button>' + ); + using handle = await page.locator('button').waitHandle(); + await expect(handle.clickablePoint()).rejects.toBeInstanceOf(Error); + + await page.setContent( + '<button style="width: 10px; height: 10px; position: absolute; right: -20px"></button>' + ); + using handle2 = await page.locator('button').waitHandle(); + await expect(handle2.clickablePoint()).rejects.toBeInstanceOf(Error); + + await page.setContent( + '<button style="width: 10px; height: 10px; position: absolute; top: -20px"></button>' + ); + using handle3 = await page.locator('button').waitHandle(); + await expect(handle3.clickablePoint()).rejects.toBeInstanceOf(Error); + + await page.setContent( + '<button style="width: 10px; height: 10px; position: absolute; bottom: -20px"></button>' + ); + using handle4 = await page.locator('button').waitHandle(); + await expect(handle4.clickablePoint()).rejects.toBeInstanceOf(Error); + }); + + it('should not work if the click box is not visible due to the iframe', async () => { + const {page} = await getTestState(); + + await page.setContent( + `<iframe name='frame' style='position: absolute; left: -100px' srcdoc="<button style='width: 10px; height: 10px;'></button>"></iframe>` + ); + const frame = await page.waitForFrame(frame => { + return frame.name() === 'frame'; + }); + + using handle = await frame.locator('button').waitHandle(); + await expect(handle.clickablePoint()).rejects.toBeInstanceOf(Error); + + await page.setContent( + `<iframe name='frame2' style='position: absolute; top: -100px' srcdoc="<button style='width: 10px; height: 10px;'></button>"></iframe>` + ); + const frame2 = await page.waitForFrame(frame => { + return frame.name() === 'frame2'; + }); + + using handle2 = await frame2.locator('button').waitHandle(); + await expect(handle2.clickablePoint()).rejects.toBeInstanceOf(Error); + }); + + it('should work for iframes', async () => { + const {page} = await getTestState(); + await page.evaluate(() => { + document.body.style.padding = '10px'; + document.body.style.margin = '10px'; + document.body.innerHTML = ` + <iframe style="border: none; margin: 0; padding: 0;" seamless sandbox srcdoc="<style>* { margin: 0; padding: 0;}</style><div style='cursor: pointer; width: 120px; height: 60px; margin: 30px; padding: 15px;' />"></iframe> + `; + }); + await page.evaluate(async () => { + return await new Promise(resolve => { + return window.requestAnimationFrame(resolve); + }); + }); + const frame = page.frames()[1]!; + using divHandle = (await frame.$('div'))!; + expect(await divHandle.clickablePoint()).toEqual({ + x: 20 + 45 + 60, // iframe pos + margin + middle point offset + y: 20 + 45 + 30, // iframe pos + margin + middle point offset + }); + expect( + await divHandle.clickablePoint({ + x: 10, + y: 15, + }) + ).toEqual({ + x: 20 + 30 + 10, // iframe pos + margin + offset + y: 20 + 30 + 15, // iframe pos + margin + offset + }); + }); + }); + + describe('Element.waitForSelector', () => { + it('should wait correctly with waitForSelector on an element', async () => { + const {page} = await getTestState(); + const waitFor = page.waitForSelector('.foo').catch(err => { + return err; + }) as Promise<ElementHandle<HTMLDivElement>>; + // Set the page content after the waitFor has been started. + await page.setContent( + '<div id="not-foo"></div><div class="bar">bar2</div><div class="foo">Foo1</div>' + ); + using element = (await waitFor)!; + if (element instanceof Error) { + throw element; + } + expect(element).toBeDefined(); + + const innerWaitFor = element.waitForSelector('.bar').catch(err => { + return err; + }) as Promise<ElementHandle<HTMLDivElement>>; + await element.evaluate(el => { + el.innerHTML = '<div class="bar">bar1</div>'; + }); + using element2 = (await innerWaitFor)!; + if (element2 instanceof Error) { + throw element2; + } + expect(element2).toBeDefined(); + expect( + await element2.evaluate(el => { + return (el as HTMLElement).innerText; + }) + ).toStrictEqual('bar1'); + }); + }); + + describe('Element.waitForXPath', () => { + it('should wait correctly with waitForXPath on an element', async () => { + const {page} = await getTestState(); + // Set the page content after the waitFor has been started. + await page.setContent( + `<div id=el1> + el1 + <div id=el2> + el2 + </div> + </div> + <div id=el3> + el3 + </div>` + ); + + using el1 = (await page.waitForSelector( + '#el1' + )) as ElementHandle<HTMLDivElement>; + + for (const path of ['//div', './/div']) { + using e = (await el1.waitForXPath( + path + )) as ElementHandle<HTMLDivElement>; + expect( + await e.evaluate(el => { + return el.id; + }) + ).toStrictEqual('el2'); + } + }); + }); + + describe('ElementHandle.hover', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + using button = (await page.$('#button-6'))!; + await button.hover(); + expect( + await page.evaluate(() => { + return document.querySelector('button:hover')!.id; + }) + ).toBe('button-6'); + }); + }); + + describe('ElementHandle.isIntersectingViewport', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + async function getVisibilityForButton(selector: string) { + using button = (await page.$(selector))!; + return await button.isIntersectingViewport(); + } + + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + const buttonsPromises = []; + // Firefox seems slow when using `isIntersectingViewport` + // so we do all the tasks asynchronously + for (let i = 0; i < 11; ++i) { + buttonsPromises.push(getVisibilityForButton('#btn' + i)); + } + const buttonVisibility = await Promise.all(buttonsPromises); + for (let i = 0; i < 11; ++i) { + // All but last button are visible. + const visible = i < 10; + expect(buttonVisibility[i]).toBe(visible); + } + }); + it('should work with threshold', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + // a button almost cannot be seen + // sometimes we expect to return false by isIntersectingViewport1 + using button = (await page.$('#btn11'))!; + expect( + await button.isIntersectingViewport({ + threshold: 0.001, + }) + ).toBe(false); + }); + it('should work with threshold of 1', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + // a button almost cannot be seen + // sometimes we expect to return false by isIntersectingViewport1 + using button = (await page.$('#btn0'))!; + expect( + await button.isIntersectingViewport({ + threshold: 1, + }) + ).toBe(true); + }); + it('should work with svg elements', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/inline-svg.html'); + const [visibleCircle, visibleSvg] = await Promise.all([ + page.$('circle'), + page.$('svg'), + ]); + + // Firefox seems slow when using `isIntersectingViewport` + // so we do all the tasks asynchronously + const [ + circleThresholdOne, + circleThresholdZero, + svgThresholdOne, + svgThresholdZero, + ] = await Promise.all([ + visibleCircle!.isIntersectingViewport({ + threshold: 1, + }), + visibleCircle!.isIntersectingViewport({ + threshold: 0, + }), + visibleSvg!.isIntersectingViewport({ + threshold: 1, + }), + visibleSvg!.isIntersectingViewport({ + threshold: 0, + }), + ]); + + expect(circleThresholdOne).toBe(true); + expect(circleThresholdZero).toBe(true); + expect(svgThresholdOne).toBe(true); + expect(svgThresholdZero).toBe(true); + + const [invisibleCircle, invisibleSvg] = await Promise.all([ + page.$('div circle'), + page.$('div svg'), + ]); + + // Firefox seems slow when using `isIntersectingViewport` + // so we do all the tasks asynchronously + const [ + invisibleCircleThresholdOne, + invisibleCircleThresholdZero, + invisibleSvgThresholdOne, + invisibleSvgThresholdZero, + ] = await Promise.all([ + invisibleCircle!.isIntersectingViewport({ + threshold: 1, + }), + invisibleCircle!.isIntersectingViewport({ + threshold: 0, + }), + invisibleSvg!.isIntersectingViewport({ + threshold: 1, + }), + invisibleSvg!.isIntersectingViewport({ + threshold: 0, + }), + ]); + + expect(invisibleCircleThresholdOne).toBe(false); + expect(invisibleCircleThresholdZero).toBe(false); + expect(invisibleSvgThresholdOne).toBe(false); + expect(invisibleSvgThresholdZero).toBe(false); + }); + }); + + describe('Custom queries', function () { + afterEach(() => { + Puppeteer.clearCustomQueryHandlers(); + }); + it('should register and unregister', async () => { + const {page} = await getTestState(); + await page.setContent('<div id="not-foo"></div><div id="foo"></div>'); + + // Register. + Puppeteer.registerCustomQueryHandler('getById', { + queryOne: (_element, selector) => { + return document.querySelector(`[id="${selector}"]`); + }, + }); + using element = (await page.$( + 'getById/foo' + )) as ElementHandle<HTMLDivElement>; + expect( + await page.evaluate(element => { + return element.id; + }, element) + ).toBe('foo'); + const handlerNamesAfterRegistering = Puppeteer.customQueryHandlerNames(); + expect(handlerNamesAfterRegistering.includes('getById')).toBeTruthy(); + + // Unregister. + Puppeteer.unregisterCustomQueryHandler('getById'); + try { + await page.$('getById/foo'); + throw new Error('Custom query handler name not set - throw expected'); + } catch (error) { + expect(error).not.toStrictEqual( + new Error('Custom query handler name not set - throw expected') + ); + } + const handlerNamesAfterUnregistering = + Puppeteer.customQueryHandlerNames(); + expect(handlerNamesAfterUnregistering.includes('getById')).toBeFalsy(); + }); + it('should throw with invalid query names', async () => { + try { + Puppeteer.registerCustomQueryHandler('1/2/3', { + queryOne: () => { + return document.querySelector('foo'); + }, + }); + throw new Error( + 'Custom query handler name was invalid - throw expected' + ); + } catch (error) { + expect(error).toStrictEqual( + new Error('Custom query handler names may only contain [a-zA-Z]') + ); + } + }); + it('should work for multiple elements', async () => { + const {page} = await getTestState(); + await page.setContent( + '<div id="not-foo"></div><div class="foo">Foo1</div><div class="foo baz">Foo2</div>' + ); + Puppeteer.registerCustomQueryHandler('getByClass', { + queryAll: (_element, selector) => { + return [...document.querySelectorAll(`.${selector}`)]; + }, + }); + const elements = (await page.$$('getByClass/foo')) as Array< + ElementHandle<HTMLDivElement> + >; + const classNames = await Promise.all( + elements.map(async element => { + return await page.evaluate(element => { + return element.className; + }, element); + }) + ); + + expect(classNames).toStrictEqual(['foo', 'foo baz']); + }); + it('should eval correctly', async () => { + const {page} = await getTestState(); + await page.setContent( + '<div id="not-foo"></div><div class="foo">Foo1</div><div class="foo baz">Foo2</div>' + ); + Puppeteer.registerCustomQueryHandler('getByClass', { + queryAll: (_element, selector) => { + return [...document.querySelectorAll(`.${selector}`)]; + }, + }); + const elements = await page.$$eval('getByClass/foo', divs => { + return divs.length; + }); + + expect(elements).toBe(2); + }); + it('should wait correctly with waitForSelector', async () => { + const {page} = await getTestState(); + Puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => { + return (element as Element).querySelector(`.${selector}`); + }, + }); + const waitFor = page.waitForSelector('getByClass/foo').catch(err => { + return err; + }); + + // Set the page content after the waitFor has been started. + await page.setContent( + '<div id="not-foo"></div><div class="foo">Foo1</div>' + ); + const element = await waitFor; + + if (element instanceof Error) { + throw element; + } + + expect(element).toBeDefined(); + }); + + it('should wait correctly with waitForSelector on an element', async () => { + const {page} = await getTestState(); + Puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => { + return (element as Element).querySelector(`.${selector}`); + }, + }); + const waitFor = page.waitForSelector('getByClass/foo').catch(err => { + return err; + }) as Promise<ElementHandle<HTMLElement>>; + + // Set the page content after the waitFor has been started. + await page.setContent( + '<div id="not-foo"></div><div class="bar">bar2</div><div class="foo">Foo1</div>' + ); + using element = (await waitFor)!; + if (element instanceof Error) { + throw element; + } + expect(element).toBeDefined(); + + const innerWaitFor = element + .waitForSelector('getByClass/bar') + .catch(err => { + return err; + }) as Promise<ElementHandle<HTMLElement>>; + + await element.evaluate(el => { + el.innerHTML = '<div class="bar">bar1</div>'; + }); + + using element2 = (await innerWaitFor)!; + if (element2 instanceof Error) { + throw element2; + } + expect(element2).toBeDefined(); + expect( + await element2.evaluate(el => { + return el.innerText; + }) + ).toStrictEqual('bar1'); + }); + + it('should wait correctly with waitFor', async () => { + /* page.waitFor is deprecated so we silence the warning to avoid test noise */ + sinon.stub(console, 'warn').callsFake(() => {}); + const {page} = await getTestState(); + Puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => { + return (element as Element).querySelector(`.${selector}`); + }, + }); + const waitFor = page.waitForSelector('getByClass/foo').catch(err => { + return err; + }); + + // Set the page content after the waitFor has been started. + await page.setContent( + '<div id="not-foo"></div><div class="foo">Foo1</div>' + ); + const element = await waitFor; + + if (element instanceof Error) { + throw element; + } + + expect(element).toBeDefined(); + }); + it('should work when both queryOne and queryAll are registered', async () => { + const {page} = await getTestState(); + await page.setContent( + '<div id="not-foo"></div><div class="foo"><div id="nested-foo" class="foo"/></div><div class="foo baz">Foo2</div>' + ); + Puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => { + return (element as Element).querySelector(`.${selector}`); + }, + queryAll: (element, selector) => { + return [...(element as Element).querySelectorAll(`.${selector}`)]; + }, + }); + + using element = (await page.$('getByClass/foo'))!; + expect(element).toBeDefined(); + + const elements = await page.$$('getByClass/foo'); + expect(elements).toHaveLength(3); + }); + it('should eval when both queryOne and queryAll are registered', async () => { + const {page} = await getTestState(); + await page.setContent( + '<div id="not-foo"></div><div class="foo">text</div><div class="foo baz">content</div>' + ); + Puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => { + return (element as Element).querySelector(`.${selector}`); + }, + queryAll: (element, selector) => { + return [...(element as Element).querySelectorAll(`.${selector}`)]; + }, + }); + + const txtContent = await page.$eval('getByClass/foo', div => { + return div.textContent; + }); + expect(txtContent).toBe('text'); + + const txtContents = await page.$$eval('getByClass/foo', divs => { + return divs + .map(d => { + return d.textContent; + }) + .join(''); + }); + expect(txtContents).toBe('textcontent'); + }); + + it('should work with function shorthands', async () => { + const {page} = await getTestState(); + await page.setContent('<div id="not-foo"></div><div id="foo"></div>'); + + Puppeteer.registerCustomQueryHandler('getById', { + // This is a function shorthand + queryOne(_element, selector) { + return document.querySelector(`[id="${selector}"]`); + }, + }); + + using element = (await page.$( + 'getById/foo' + )) as ElementHandle<HTMLDivElement>; + expect( + await page.evaluate(element => { + return element.id; + }, element) + ).toBe('foo'); + }); + }); + + describe('ElementHandle.toElement', () => { + it('should work', async () => { + const {page} = await getTestState(); + await page.setContent('<div class="foo">Foo1</div>'); + using element = await page.$('.foo'); + using div = await element?.toElement('div'); + expect(div).toBeDefined(); + }); + }); + + describe('ElementHandle[Symbol.dispose]', () => { + it('should work', async () => { + const {page} = await getTestState(); + using handle = await page.evaluateHandle('document'); + const spy = sinon.spy(handle, disposeSymbol); + { + using _ = handle; + } + expect(handle).toBeInstanceOf(ElementHandle); + expect(spy.calledOnce).toBeTruthy(); + expect(handle.disposed).toBeTruthy(); + }); + }); + + describe('ElementHandle[Symbol.asyncDispose]', () => { + it('should work', async () => { + const {page} = await getTestState(); + using handle = await page.evaluateHandle('document'); + const spy = sinon.spy(handle, asyncDisposeSymbol); + { + await using _ = handle; + } + expect(handle).toBeInstanceOf(ElementHandle); + expect(spy.calledOnce).toBeTruthy(); + expect(handle.disposed).toBeTruthy(); + }); + }); + + describe('ElementHandle.move', () => { + it('should work', async () => { + const {page} = await getTestState(); + using handle = await page.evaluateHandle('document'); + const spy = sinon.spy(handle, disposeSymbol); + { + using _ = handle; + handle.move(); + } + expect(handle).toBeInstanceOf(ElementHandle); + expect(spy.calledOnce).toBeTruthy(); + expect(handle.disposed).toBeFalsy(); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/emulation.spec.ts b/remote/test/puppeteer/test/src/emulation.spec.ts new file mode 100644 index 0000000000..823061c450 --- /dev/null +++ b/remote/test/puppeteer/test/src/emulation.spec.ts @@ -0,0 +1,553 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {KnownDevices, PredefinedNetworkConditions} from 'puppeteer'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +const iPhone = KnownDevices['iPhone 6']; +const iPhoneLandscape = KnownDevices['iPhone 6 landscape']; + +describe('Emulation', () => { + setupTestBrowserHooks(); + + describe('Page.viewport', function () { + it('should get the proper viewport size', async () => { + const {page} = await getTestState(); + + expect(page.viewport()).toEqual({width: 800, height: 600}); + await page.setViewport({width: 123, height: 456}); + expect(page.viewport()).toEqual({width: 123, height: 456}); + }); + it('should support mobile emulation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + expect( + await page.evaluate(() => { + return window.innerWidth; + }) + ).toBe(800); + await page.setViewport(iPhone.viewport); + expect( + await page.evaluate(() => { + return window.innerWidth; + }) + ).toBe(375); + await page.setViewport({width: 400, height: 300}); + expect( + await page.evaluate(() => { + return window.innerWidth; + }) + ).toBe(400); + }); + it('should support touch emulation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + expect( + await page.evaluate(() => { + return 'ontouchstart' in window; + }) + ).toBe(false); + await page.setViewport(iPhone.viewport); + expect( + await page.evaluate(() => { + return 'ontouchstart' in window; + }) + ).toBe(true); + expect(await page.evaluate(dispatchTouch)).toBe('Received touch'); + await page.setViewport({width: 100, height: 100}); + expect( + await page.evaluate(() => { + return 'ontouchstart' in window; + }) + ).toBe(false); + + function dispatchTouch() { + let fulfill!: (value: string) => void; + const promise = new Promise(x => { + fulfill = x; + }); + window.ontouchstart = () => { + fulfill('Received touch'); + }; + window.dispatchEvent(new Event('touchstart')); + + fulfill('Did not receive touch'); + + return promise; + } + }); + it('should be detectable by Modernizr', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/detect-touch.html'); + expect( + await page.evaluate(() => { + return document.body.textContent!.trim(); + }) + ).toBe('NO'); + await page.setViewport(iPhone.viewport); + await page.goto(server.PREFIX + '/detect-touch.html'); + expect( + await page.evaluate(() => { + return document.body.textContent!.trim(); + }) + ).toBe('YES'); + }); + it('should detect touch when applying viewport with touches', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 800, height: 600, hasTouch: true}); + await page.addScriptTag({url: server.PREFIX + '/modernizr.js'}); + expect( + await page.evaluate(() => { + return (globalThis as any).Modernizr.touchevents; + }) + ).toBe(true); + }); + it('should support landscape emulation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + expect( + await page.evaluate(() => { + return screen.orientation.type; + }) + ).toBe('portrait-primary'); + await page.setViewport(iPhoneLandscape.viewport); + expect( + await page.evaluate(() => { + return screen.orientation.type; + }) + ).toBe('landscape-primary'); + await page.setViewport({width: 100, height: 100}); + expect( + await page.evaluate(() => { + return screen.orientation.type; + }) + ).toBe('portrait-primary'); + }); + it('should update media queries when resoltion changes', async () => { + const {page, server} = await getTestState(); + + async function getFontSize() { + return await page.evaluate(() => { + return parseInt( + window.getComputedStyle(document.querySelector('p')!).fontSize, + 10 + ); + }); + } + + for (const dpr of [1, 2, 3]) { + await page.setViewport({ + width: 800, + height: 600, + deviceScaleFactor: dpr, + }); + + await page.goto(server.PREFIX + '/resolution.html'); + + await expect(getFontSize()).resolves.toEqual(dpr); + + const screenshot = await page.screenshot({ + fullPage: false, + }); + expect(screenshot).toBeGolden(`device-pixel-ratio${dpr}.png`); + } + }); + it('should load correct pictures when emulation dpr', async () => { + const {page, server} = await getTestState(); + + async function getCurrentSrc() { + return await page.evaluate(() => { + return document.querySelector('img')!.currentSrc; + }); + } + + for (const dpr of [1, 2, 3]) { + await page.setViewport({ + width: 800, + height: 600, + deviceScaleFactor: dpr, + }); + + await page.goto(server.PREFIX + '/picture.html'); + + await expect(getCurrentSrc()).resolves.toMatch( + new RegExp(`logo-${dpr}x.png`) + ); + } + }); + }); + + describe('Page.emulate', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + await page.emulate(iPhone); + expect( + await page.evaluate(() => { + return window.innerWidth; + }) + ).toBe(375); + expect( + await page.evaluate(() => { + return navigator.userAgent; + }) + ).toContain('iPhone'); + }); + it('should support clicking', async () => { + const {page, server} = await getTestState(); + + await page.emulate(iPhone); + await page.goto(server.PREFIX + '/input/button.html'); + using button = (await page.$('button'))!; + await page.evaluate((button: HTMLElement) => { + return (button.style.marginTop = '200px'); + }, button); + await button.click(); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe('Clicked'); + }); + }); + + describe('Page.emulateMediaType', function () { + it('should work', async () => { + const {page} = await getTestState(); + + expect( + await page.evaluate(() => { + return matchMedia('screen').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('print').matches; + }) + ).toBe(false); + await page.emulateMediaType('print'); + expect( + await page.evaluate(() => { + return matchMedia('screen').matches; + }) + ).toBe(false); + expect( + await page.evaluate(() => { + return matchMedia('print').matches; + }) + ).toBe(true); + await page.emulateMediaType(); + expect( + await page.evaluate(() => { + return matchMedia('screen').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('print').matches; + }) + ).toBe(false); + }); + it('should throw in case of bad argument', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.emulateMediaType('bad').catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe('Unsupported media type: bad'); + }); + }); + + describe('Page.emulateMediaFeatures', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.emulateMediaFeatures([ + {name: 'prefers-reduced-motion', value: 'reduce'}, + ]); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-reduced-motion: reduce)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-reduced-motion: no-preference)').matches; + }) + ).toBe(false); + await page.emulateMediaFeatures([ + {name: 'prefers-color-scheme', value: 'light'}, + ]); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-color-scheme: light)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-color-scheme: dark)').matches; + }) + ).toBe(false); + await page.emulateMediaFeatures([ + {name: 'prefers-color-scheme', value: 'dark'}, + ]); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-color-scheme: dark)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-color-scheme: light)').matches; + }) + ).toBe(false); + await page.emulateMediaFeatures([ + {name: 'prefers-reduced-motion', value: 'reduce'}, + {name: 'prefers-color-scheme', value: 'light'}, + ]); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-reduced-motion: reduce)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-reduced-motion: no-preference)').matches; + }) + ).toBe(false); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-color-scheme: light)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(prefers-color-scheme: dark)').matches; + }) + ).toBe(false); + await page.emulateMediaFeatures([{name: 'color-gamut', value: 'srgb'}]); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: p3)').matches; + }) + ).toBe(false); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: srgb)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: rec2020)').matches; + }) + ).toBe(false); + await page.emulateMediaFeatures([{name: 'color-gamut', value: 'p3'}]); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: p3)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: srgb)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: rec2020)').matches; + }) + ).toBe(false); + await page.emulateMediaFeatures([ + {name: 'color-gamut', value: 'rec2020'}, + ]); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: p3)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: srgb)').matches; + }) + ).toBe(true); + expect( + await page.evaluate(() => { + return matchMedia('(color-gamut: rec2020)').matches; + }) + ).toBe(true); + }); + it('should throw in case of bad argument', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .emulateMediaFeatures([{name: 'bad', value: ''}]) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe('Unsupported media feature: bad'); + }); + }); + + describe('Page.emulateTimezone', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.evaluate(() => { + (globalThis as any).date = new Date(1479579154987); + }); + await page.emulateTimezone('America/Jamaica'); + expect( + await page.evaluate(() => { + return (globalThis as any).date.toString(); + }) + ).toBe('Sat Nov 19 2016 13:12:34 GMT-0500 (Eastern Standard Time)'); + + await page.emulateTimezone('Pacific/Honolulu'); + expect( + await page.evaluate(() => { + return (globalThis as any).date.toString(); + }) + ).toBe( + 'Sat Nov 19 2016 08:12:34 GMT-1000 (Hawaii-Aleutian Standard Time)' + ); + + await page.emulateTimezone('America/Buenos_Aires'); + expect( + await page.evaluate(() => { + return (globalThis as any).date.toString(); + }) + ).toBe('Sat Nov 19 2016 15:12:34 GMT-0300 (Argentina Standard Time)'); + + await page.emulateTimezone('Europe/Berlin'); + expect( + await page.evaluate(() => { + return (globalThis as any).date.toString(); + }) + ).toBe( + 'Sat Nov 19 2016 19:12:34 GMT+0100 (Central European Standard Time)' + ); + }); + + it('should throw for invalid timezone IDs', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.emulateTimezone('Foo/Bar').catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe('Invalid timezone ID: Foo/Bar'); + await page.emulateTimezone('Baz/Qux').catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe('Invalid timezone ID: Baz/Qux'); + }); + }); + + describe('Page.emulateVisionDeficiency', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + + { + await page.emulateVisionDeficiency('none'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('screenshot-sanity.png'); + } + + { + await page.emulateVisionDeficiency('achromatopsia'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-achromatopsia.png'); + } + + { + await page.emulateVisionDeficiency('blurredVision'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-blurredVision.png'); + } + + { + await page.emulateVisionDeficiency('deuteranopia'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-deuteranopia.png'); + } + + { + await page.emulateVisionDeficiency('protanopia'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-protanopia.png'); + } + + { + await page.emulateVisionDeficiency('tritanopia'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-tritanopia.png'); + } + + { + await page.emulateVisionDeficiency('none'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('screenshot-sanity.png'); + } + }); + + it('should throw for invalid vision deficiencies', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + // @ts-expect-error deliberately passing invalid deficiency + .emulateVisionDeficiency('invalid') + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe('Unsupported vision deficiency: invalid'); + }); + }); + + describe('Page.emulateNetworkConditions', function () { + it('should change navigator.connection.effectiveType', async () => { + const {page} = await getTestState(); + + const slow3G = PredefinedNetworkConditions['Slow 3G']!; + const fast3G = PredefinedNetworkConditions['Fast 3G']!; + + expect( + await page.evaluate('window.navigator.connection.effectiveType') + ).toBe('4g'); + await page.emulateNetworkConditions(fast3G); + expect( + await page.evaluate('window.navigator.connection.effectiveType') + ).toBe('3g'); + await page.emulateNetworkConditions(slow3G); + expect( + await page.evaluate('window.navigator.connection.effectiveType') + ).toBe('2g'); + await page.emulateNetworkConditions(null); + }); + }); + + describe('Page.emulateCPUThrottling', function () { + it('should change the CPU throttling rate successfully', async () => { + const {page} = await getTestState(); + + await page.emulateCPUThrottling(100); + await page.emulateCPUThrottling(null); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/evaluation.spec.ts b/remote/test/puppeteer/test/src/evaluation.spec.ts new file mode 100644 index 0000000000..3305b59cc2 --- /dev/null +++ b/remote/test/puppeteer/test/src/evaluation.spec.ts @@ -0,0 +1,607 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {attachFrame} from './utils.js'; + +describe('Evaluation specs', function () { + setupTestBrowserHooks(); + + describe('Page.evaluate', function () { + it('should work', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + return 7 * 3; + }); + expect(result).toBe(21); + }); + it('should transfer BigInt', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate((a: bigint) => { + return a; + }, BigInt(42)); + expect(result).toBe(BigInt(42)); + }); + it('should transfer NaN', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(a => { + return a; + }, NaN); + expect(Object.is(result, NaN)).toBe(true); + }); + it('should transfer -0', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(a => { + return a; + }, -0); + expect(Object.is(result, -0)).toBe(true); + }); + it('should transfer Infinity', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(a => { + return a; + }, Infinity); + expect(Object.is(result, Infinity)).toBe(true); + }); + it('should transfer -Infinity', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(a => { + return a; + }, -Infinity); + expect(Object.is(result, -Infinity)).toBe(true); + }); + it('should transfer arrays', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate( + a => { + return a; + }, + [1, 2, 3] + ); + expect(result).toEqual([1, 2, 3]); + }); + it('should transfer arrays as arrays, not objects', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate( + a => { + return Array.isArray(a); + }, + [1, 2, 3] + ); + expect(result).toBe(true); + }); + it('should modify global environment', async () => { + const {page} = await getTestState(); + + await page.evaluate(() => { + return ((globalThis as any).globalVar = 123); + }); + expect(await page.evaluate('globalVar')).toBe(123); + }); + it('should evaluate in the page context', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/global-var.html'); + expect(await page.evaluate('globalVar')).toBe(123); + }); + it('should replace symbols with undefined', async () => { + const {page} = await getTestState(); + + expect( + await page.evaluate(() => { + return [Symbol('foo4'), 'foo']; + }) + ).toEqual([undefined, 'foo']); + }); + it('should work with function shorthands', async () => { + const {page} = await getTestState(); + + const a = { + sum(a: number, b: number) { + return a + b; + }, + + async mult(a: number, b: number) { + return a * b; + }, + }; + expect(await page.evaluate(a.sum, 1, 2)).toBe(3); + expect(await page.evaluate(a.mult, 2, 4)).toBe(8); + }); + it('should work with unicode chars', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate( + a => { + return a['中文字符']; + }, + { + 中文字符: 42, + } + ); + expect(result).toBe(42); + }); + it('should throw when evaluation triggers reload', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .evaluate(() => { + location.reload(); + return new Promise(() => {}); + }) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('Protocol error'); + }); + it('should await promise', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + return Promise.resolve(8 * 7); + }); + expect(result).toBe(56); + }); + it('should work right after framenavigated', async () => { + const {page, server} = await getTestState(); + + let frameEvaluation = null; + page.on('framenavigated', async frame => { + frameEvaluation = frame.evaluate(() => { + return 6 * 7; + }); + }); + await page.goto(server.EMPTY_PAGE); + expect(await frameEvaluation).toBe(42); + }); + it('should work from-inside an exposed function', async () => { + const {page} = await getTestState(); + + // Setup inpage callback, which calls Page.evaluate + await page.exposeFunction( + 'callController', + async function (a: number, b: number) { + return await page.evaluate( + (a: number, b: number): number => { + return a * b; + }, + a, + b + ); + } + ); + const result = await page.evaluate(async function () { + return (globalThis as any).callController(9, 3); + }); + expect(result).toBe(27); + }); + it('should reject promise with exception', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .evaluate(() => { + // @ts-expect-error we know the object doesn't exist + return notExistingObject.property; + }) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + expect(error.message).toContain('notExistingObject'); + }); + it('should support thrown strings as error messages', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .evaluate(() => { + throw 'qwerty'; + }) + .catch(error_ => { + return (error = error_); + }); + expect(error).toEqual('qwerty'); + }); + it('should support thrown numbers as error messages', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .evaluate(() => { + throw 100500; + }) + .catch(error_ => { + return (error = error_); + }); + expect(error).toEqual(100500); + }); + it('should return complex objects', async () => { + const {page} = await getTestState(); + + const object = {foo: 'bar!'}; + const result = await page.evaluate(a => { + return a; + }, object); + expect(result).not.toBe(object); + expect(result).toEqual(object); + }); + it('should return BigInt', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + return BigInt(42); + }); + expect(result).toBe(BigInt(42)); + }); + it('should return NaN', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + return NaN; + }); + expect(Object.is(result, NaN)).toBe(true); + }); + it('should return -0', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + return -0; + }); + expect(Object.is(result, -0)).toBe(true); + }); + it('should return Infinity', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + return Infinity; + }); + expect(Object.is(result, Infinity)).toBe(true); + }); + it('should return -Infinity', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + return -Infinity; + }); + expect(Object.is(result, -Infinity)).toBe(true); + }); + it('should accept "null" as one of multiple parameters', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate( + (a, b) => { + return Object.is(a, null) && Object.is(b, 'foo'); + }, + null, + 'foo' + ); + expect(result).toBe(true); + }); + it('should properly serialize null fields', async () => { + const {page} = await getTestState(); + + expect( + await page.evaluate(() => { + return {a: undefined}; + }) + ).toEqual({}); + }); + it('should return undefined for non-serializable objects', async () => { + const {page} = await getTestState(); + + expect( + await page.evaluate(() => { + return window; + }) + ).toBe(undefined); + }); + it('should return promise as empty object', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + return { + promise: new Promise(resolve => { + setTimeout(resolve, 1000); + }), + }; + }); + expect(result).toEqual({ + promise: {}, + }); + }); + it('should work for circular object', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + const a: Record<string, unknown> = { + c: 5, + d: { + foo: 'bar', + }, + }; + const b = {a}; + a['b'] = b; + return a; + }); + expect(result).toMatchObject({ + c: 5, + d: { + foo: 'bar', + }, + b: { + a: undefined, + }, + }); + }); + it('should accept a string', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate('1 + 2'); + expect(result).toBe(3); + }); + it('should accept a string with semi colons', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate('1 + 5;'); + expect(result).toBe(6); + }); + it('should accept a string with comments', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate('2 + 5;\n// do some math!'); + expect(result).toBe(7); + }); + it('should accept element handle as an argument', async () => { + const {page} = await getTestState(); + + await page.setContent('<section>42</section>'); + using element = (await page.$('section'))!; + const text = await page.evaluate(e => { + return e.textContent; + }, element); + expect(text).toBe('42'); + }); + it('should throw if underlying element was disposed', async () => { + const {page} = await getTestState(); + + await page.setContent('<section>39</section>'); + using element = (await page.$('section'))!; + expect(element).toBeTruthy(); + // We want to dispose early. + await element.dispose(); + let error!: Error; + await page + .evaluate((e: HTMLElement) => { + return e.textContent; + }, element) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('JSHandle is disposed'); + }); + it('should throw if elementHandles are from other frames', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + using bodyHandle = await page.frames()[1]!.$('body'); + let error!: Error; + await page + .evaluate(body => { + return body?.innerHTML; + }, bodyHandle) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'JSHandles can be evaluated only in the context they were created' + ); + }); + it('should simulate a user gesture', async () => { + const {page} = await getTestState(); + + const result = await page.evaluate(() => { + document.body.appendChild(document.createTextNode('test')); + document.execCommand('selectAll'); + return document.execCommand('copy'); + }); + expect(result).toBe(true); + }); + it('should not throw an error when evaluation does a navigation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/one-style.html'); + const onRequest = server.waitForRequest('/empty.html'); + const result = await page.evaluate(() => { + (window as any).location = '/empty.html'; + return [42]; + }); + expect(result).toEqual([42]); + await onRequest; + }); + it('should transfer 100Mb of data from page to node.js', async function () { + this.timeout(25_000); + const {page} = await getTestState(); + + const a = await page.evaluate(() => { + return Array(100 * 1024 * 1024 + 1).join('a'); + }); + expect(a.length).toBe(100 * 1024 * 1024); + }); + it('should throw error with detailed information on exception inside promise', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .evaluate(() => { + return new Promise(() => { + throw new Error('Error in promise'); + }); + }) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('Error in promise'); + }); + + it('should return properly serialize objects with unknown type fields', async () => { + const {page} = await getTestState(); + await page.setContent( + "<img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='>" + ); + + const result = await page.evaluate(async () => { + const image = document.querySelector('img')!; + const imageBitmap = await createImageBitmap(image); + + return { + a: 'foo', + b: imageBitmap, + }; + }); + + expect(result).toEqual({ + a: 'foo', + b: undefined, + }); + }); + }); + + describe('Page.evaluateOnNewDocument', function () { + it('should evaluate before anything else on the page', async () => { + const {page, server} = await getTestState(); + + await page.evaluateOnNewDocument(function () { + (globalThis as any).injected = 123; + }); + await page.goto(server.PREFIX + '/tamperable.html'); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe(123); + }); + it('should work with CSP', async () => { + const {page, server} = await getTestState(); + + server.setCSP('/empty.html', 'script-src ' + server.PREFIX); + await page.evaluateOnNewDocument(function () { + (globalThis as any).injected = 123; + }); + await page.goto(server.PREFIX + '/empty.html'); + expect( + await page.evaluate(() => { + return (globalThis as any).injected; + }) + ).toBe(123); + + // Make sure CSP works. + await page.addScriptTag({content: 'window.e = 10;'}).catch(error => { + return void error; + }); + expect( + await page.evaluate(() => { + return (window as any).e; + }) + ).toBe(undefined); + }); + }); + + describe('Page.removeScriptToEvaluateOnNewDocument', function () { + it('should remove new document script', async () => { + const {page, server} = await getTestState(); + + const {identifier} = await page.evaluateOnNewDocument(function () { + (globalThis as any).injected = 123; + }); + await page.goto(server.PREFIX + '/tamperable.html'); + expect( + await page.evaluate(() => { + return (globalThis as any).result; + }) + ).toBe(123); + + await page.removeScriptToEvaluateOnNewDocument(identifier); + await page.reload(); + expect( + await page.evaluate(() => { + return (globalThis as any).result || null; + }) + ).toBe(null); + }); + }); + + describe('Frame.evaluate', function () { + it('should have different execution contexts', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + expect(page.frames()).toHaveLength(2); + await page.frames()[0]!.evaluate(() => { + return ((globalThis as any).FOO = 'foo'); + }); + await page.frames()[1]!.evaluate(() => { + return ((globalThis as any).FOO = 'bar'); + }); + expect( + await page.frames()[0]!.evaluate(() => { + return (globalThis as any).FOO; + }) + ).toBe('foo'); + expect( + await page.frames()[1]!.evaluate(() => { + return (globalThis as any).FOO; + }) + ).toBe('bar'); + }); + it('should have correct execution contexts', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + expect(page.frames()).toHaveLength(2); + expect( + await page.frames()[0]!.evaluate(() => { + return document.body.textContent!.trim(); + }) + ).toBe(''); + expect( + await page.frames()[1]!.evaluate(() => { + return document.body.textContent!.trim(); + }) + ).toBe(`Hi, I'm frame`); + }); + it('should execute after cross-site navigation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const mainFrame = page.mainFrame(); + expect( + await mainFrame.evaluate(() => { + return window.location.href; + }) + ).toContain('localhost'); + await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect( + await mainFrame.evaluate(() => { + return window.location.href; + }) + ).toContain('127'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/fixtures.spec.ts b/remote/test/puppeteer/test/src/fixtures.spec.ts new file mode 100644 index 0000000000..ca11e94cac --- /dev/null +++ b/remote/test/puppeteer/test/src/fixtures.spec.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {spawn, execSync} from 'child_process'; +import path from 'path'; + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {waitEvent} from './utils.js'; + +describe('Fixtures', function () { + setupTestBrowserHooks(); + + it('dumpio option should work with pipe option', async () => { + const {defaultBrowserOptions, puppeteerPath, headless} = + await getTestState(); + if (headless !== 'true') { + // This test only works in the old headless mode. + return; + } + + let dumpioData = ''; + const options = Object.assign({}, defaultBrowserOptions, { + pipe: true, + dumpio: true, + }); + const res = spawn('node', [ + path.join(__dirname, '../fixtures', 'dumpio.js'), + puppeteerPath, + JSON.stringify(options), + ]); + res.stderr.on('data', data => { + dumpioData += data.toString('utf8'); + }); + await new Promise(resolve => { + return res.on('close', resolve); + }); + expect(dumpioData).toContain('message from dumpio'); + }); + it('should dump browser process stderr', async () => { + const {defaultBrowserOptions, puppeteerPath} = await getTestState(); + + let dumpioData = ''; + const options = Object.assign({}, defaultBrowserOptions, {dumpio: true}); + const res = spawn('node', [ + path.join(__dirname, '../fixtures', 'dumpio.js'), + puppeteerPath, + JSON.stringify(options), + ]); + res.stderr.on('data', data => { + dumpioData += data.toString('utf8'); + }); + await new Promise(resolve => { + return res.on('close', resolve); + }); + expect(dumpioData).toContain('DevTools listening on ws://'); + }); + it('should close the browser when the node process closes', async () => { + const {defaultBrowserOptions, puppeteerPath, puppeteer} = + await getTestState(); + + const options = Object.assign({}, defaultBrowserOptions, { + // Disable DUMPIO to cleanly read stdout. + dumpio: false, + }); + const res = spawn('node', [ + path.join(__dirname, '../fixtures', 'closeme.js'), + puppeteerPath, + JSON.stringify(options), + ]); + let killed = false; + function killProcess() { + if (killed) { + return; + } + if (process.platform === 'win32') { + execSync(`taskkill /pid ${res.pid} /T /F`); + } else { + process.kill(res.pid!); + } + killed = true; + } + try { + let wsEndPointCallback: (value: string) => void; + const wsEndPointPromise = new Promise<string>(x => { + wsEndPointCallback = x; + }); + let output = ''; + res.stdout.on('data', data => { + output += data; + if (output.indexOf('\n')) { + wsEndPointCallback(output.substring(0, output.indexOf('\n'))); + } + }); + const browser = await puppeteer.connect({ + browserWSEndpoint: await wsEndPointPromise, + }); + const promises = [ + waitEvent(browser, 'disconnected'), + new Promise(resolve => { + res.on('close', resolve); + }), + ]; + killProcess(); + await Promise.all(promises); + } finally { + killProcess(); + } + }); +}); diff --git a/remote/test/puppeteer/test/src/frame.spec.ts b/remote/test/puppeteer/test/src/frame.spec.ts new file mode 100644 index 0000000000..3b2456821a --- /dev/null +++ b/remote/test/puppeteer/test/src/frame.spec.ts @@ -0,0 +1,297 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {CDPSession} from 'puppeteer-core/internal/api/CDPSession.js'; +import type {Frame} from 'puppeteer-core/internal/api/Frame.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import { + attachFrame, + detachFrame, + dumpFrames, + navigateFrame, + waitEvent, +} from './utils.js'; + +describe('Frame specs', function () { + setupTestBrowserHooks(); + + describe('Frame.evaluateHandle', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const mainFrame = page.mainFrame(); + using windowHandle = await mainFrame.evaluateHandle(() => { + return window; + }); + expect(windowHandle).toBeTruthy(); + }); + }); + + describe('Frame.evaluate', function () { + it('should throw for detached frames', async () => { + const {page, server} = await getTestState(); + + const frame1 = (await attachFrame(page, 'frame1', server.EMPTY_PAGE))!; + await detachFrame(page, 'frame1'); + let error: Error | undefined; + try { + await frame1.evaluate(() => { + return 7 * 8; + }); + } catch (err) { + error = err as Error; + } + expect(error?.message).toContain('Attempted to use detached Frame'); + }); + + it('allows readonly array to be an argument', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + const mainFrame = page.mainFrame(); + + // This test checks if Frame.evaluate allows a readonly array to be an argument. + // See https://github.com/puppeteer/puppeteer/issues/6953. + const readonlyArray: readonly string[] = ['a', 'b', 'c']; + await mainFrame.evaluate(arr => { + return arr; + }, readonlyArray); + }); + }); + + describe('Frame.page', function () { + it('should retrieve the page from a frame', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + const mainFrame = page.mainFrame(); + expect(mainFrame.page()).toEqual(page); + }); + }); + + describe('Frame Management', function () { + it('should handle nested frames', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + expect(dumpFrames(page.mainFrame())).toEqual([ + 'http://localhost:<PORT>/frames/nested-frames.html', + ' http://localhost:<PORT>/frames/two-frames.html (2frames)', + ' http://localhost:<PORT>/frames/frame.html (uno)', + ' http://localhost:<PORT>/frames/frame.html (dos)', + ' http://localhost:<PORT>/frames/frame.html (aframe)', + ]); + }); + it('should send events when frames are manipulated dynamically', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + // validate frameattached events + const attachedFrames: Frame[] = []; + page.on('frameattached', frame => { + return attachedFrames.push(frame); + }); + await attachFrame(page, 'frame1', './assets/frame.html'); + expect(attachedFrames).toHaveLength(1); + expect(attachedFrames[0]!.url()).toContain('/assets/frame.html'); + + // validate framenavigated events + const navigatedFrames: Frame[] = []; + page.on('framenavigated', frame => { + return navigatedFrames.push(frame); + }); + await navigateFrame(page, 'frame1', './empty.html'); + expect(navigatedFrames).toHaveLength(1); + expect(navigatedFrames[0]!.url()).toBe(server.EMPTY_PAGE); + + // validate framedetached events + const detachedFrames: Frame[] = []; + page.on('framedetached', frame => { + return detachedFrames.push(frame); + }); + await detachFrame(page, 'frame1'); + expect(detachedFrames).toHaveLength(1); + expect(detachedFrames[0]!.isDetached()).toBe(true); + }); + it('should send "framenavigated" when navigating on anchor URLs', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await Promise.all([ + page.goto(server.EMPTY_PAGE + '#foo'), + waitEvent(page, 'framenavigated'), + ]); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foo'); + }); + it('should persist mainFrame on cross-process navigation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const mainFrame = page.mainFrame(); + await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect(page.mainFrame() === mainFrame).toBeTruthy(); + }); + it('should not send attach/detach events for main frame', async () => { + const {page, server} = await getTestState(); + + let hasEvents = false; + page.on('frameattached', () => { + return (hasEvents = true); + }); + page.on('framedetached', () => { + return (hasEvents = true); + }); + await page.goto(server.EMPTY_PAGE); + expect(hasEvents).toBe(false); + }); + it('should detach child frames on navigation', async () => { + const {page, server} = await getTestState(); + + let attachedFrames: Frame[] = []; + let detachedFrames: Frame[] = []; + let navigatedFrames: Frame[] = []; + page.on('frameattached', frame => { + return attachedFrames.push(frame); + }); + page.on('framedetached', frame => { + return detachedFrames.push(frame); + }); + page.on('framenavigated', frame => { + return navigatedFrames.push(frame); + }); + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + + expect(attachedFrames).toHaveLength(4); + expect(detachedFrames).toHaveLength(0); + expect(navigatedFrames).toHaveLength(5); + + attachedFrames = []; + detachedFrames = []; + navigatedFrames = []; + await page.goto(server.EMPTY_PAGE); + expect(attachedFrames).toHaveLength(0); + expect(detachedFrames).toHaveLength(4); + expect(navigatedFrames).toHaveLength(1); + }); + it('should support framesets', async () => { + const {page, server} = await getTestState(); + + let attachedFrames: Frame[] = []; + let detachedFrames: Frame[] = []; + let navigatedFrames: Frame[] = []; + page.on('frameattached', frame => { + return attachedFrames.push(frame); + }); + page.on('framedetached', frame => { + return detachedFrames.push(frame); + }); + page.on('framenavigated', frame => { + return navigatedFrames.push(frame); + }); + await page.goto(server.PREFIX + '/frames/frameset.html'); + expect(attachedFrames).toHaveLength(4); + expect(detachedFrames).toHaveLength(0); + expect(navigatedFrames).toHaveLength(5); + + attachedFrames = []; + detachedFrames = []; + navigatedFrames = []; + await page.goto(server.EMPTY_PAGE); + expect(attachedFrames).toHaveLength(0); + expect(detachedFrames).toHaveLength(4); + expect(navigatedFrames).toHaveLength(1); + }); + it('should report frame from-inside shadow DOM', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/shadow.html'); + await page.evaluate(async (url: string) => { + const frame = document.createElement('iframe'); + frame.src = url; + document.body.shadowRoot!.appendChild(frame); + await new Promise(x => { + return (frame.onload = x); + }); + }, server.EMPTY_PAGE); + expect(page.frames()).toHaveLength(2); + expect(page.frames()[1]!.url()).toBe(server.EMPTY_PAGE); + }); + it('should report frame.name()', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'theFrameId', server.EMPTY_PAGE); + await page.evaluate((url: string) => { + const frame = document.createElement('iframe'); + frame.name = 'theFrameName'; + frame.src = url; + document.body.appendChild(frame); + return new Promise(x => { + return (frame.onload = x); + }); + }, server.EMPTY_PAGE); + expect(page.frames()[0]!.name()).toBe(''); + expect(page.frames()[1]!.name()).toBe('theFrameId'); + expect(page.frames()[2]!.name()).toBe('theFrameName'); + }); + it('should report frame.parent()', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await attachFrame(page, 'frame2', server.EMPTY_PAGE); + expect(page.frames()[0]!.parentFrame()).toBe(null); + expect(page.frames()[1]!.parentFrame()).toBe(page.mainFrame()); + expect(page.frames()[2]!.parentFrame()).toBe(page.mainFrame()); + }); + it('should report different frame instance when frame re-attaches', async () => { + const {page, server} = await getTestState(); + + const frame1 = await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await page.evaluate(() => { + (globalThis as any).frame = document.querySelector('#frame1'); + (globalThis as any).frame.remove(); + }); + expect(frame1!.isDetached()).toBe(true); + const [frame2] = await Promise.all([ + waitEvent(page, 'frameattached'), + page.evaluate(() => { + return document.body.appendChild((globalThis as any).frame); + }), + ]); + expect(frame2.isDetached()).toBe(false); + expect(frame1).not.toBe(frame2); + }); + it('should support url fragment', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame-url-fragment.html'); + + expect(page.frames()).toHaveLength(2); + expect(page.frames()[1]!.url()).toBe( + server.PREFIX + '/frames/frame.html?param=value#fragment' + ); + }); + it('should support lazy frames', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 1000, height: 1000}); + await page.goto(server.PREFIX + '/frames/lazy-frame.html'); + + expect( + page.frames().map(frame => { + return frame._hasStartedLoading; + }) + ).toEqual([true, true, false]); + }); + }); + + describe('Frame.client', function () { + it('should return the client instance', async () => { + const {page} = await getTestState(); + expect(page.mainFrame().client).toBeInstanceOf(CDPSession); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/golden-utils.ts b/remote/test/puppeteer/test/src/golden-utils.ts new file mode 100644 index 0000000000..939f69c968 --- /dev/null +++ b/remote/test/puppeteer/test/src/golden-utils.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; + +import {diffLines} from 'diff'; +import jpeg from 'jpeg-js'; +import mime from 'mime'; +import pixelmatch from 'pixelmatch'; +import {PNG} from 'pngjs'; + +interface DiffFile { + diff: string | Buffer; + ext?: string; +} + +const GoldenComparators = new Map< + string, + ( + actualBuffer: string | Buffer, + expectedBuffer: string | Buffer, + mimeType: string + ) => DiffFile | undefined +>(); + +const addSuffix = ( + filePath: string, + suffix: string, + customExtension?: string +): string => { + const dirname = path.dirname(filePath); + const ext = path.extname(filePath); + const name = path.basename(filePath, ext); + return path.join(dirname, name + suffix + (customExtension || ext)); +}; + +const compareImages = ( + actualBuffer: string | Buffer, + expectedBuffer: string | Buffer, + mimeType: string +): DiffFile | undefined => { + assert(typeof actualBuffer !== 'string'); + assert(typeof expectedBuffer !== 'string'); + + const actual = + mimeType === 'image/png' + ? PNG.sync.read(actualBuffer) + : jpeg.decode(actualBuffer); + + const expected = + mimeType === 'image/png' + ? PNG.sync.read(expectedBuffer) + : jpeg.decode(expectedBuffer); + if (expected.width !== actual.width || expected.height !== actual.height) { + throw new Error( + `Sizes differ: expected image ${expected.width}px X ${expected.height}px, but got ${actual.width}px X ${actual.height}px.` + ); + } + const diff = new PNG({width: expected.width, height: expected.height}); + const count = pixelmatch( + expected.data, + actual.data, + diff.data, + expected.width, + expected.height, + {threshold: 0.1} + ); + return count > 0 ? {diff: PNG.sync.write(diff)} : undefined; +}; + +const compareText = ( + actual: string | Buffer, + expectedBuffer: string | Buffer +): DiffFile | undefined => { + assert(typeof actual === 'string'); + const expected = expectedBuffer.toString('utf-8'); + if (expected === actual) { + return; + } + const result = diffLines(expected, actual); + const html = result.reduce( + (text, change) => { + text += change.added + ? `<span class='ins'>${change.value}</span>` + : change.removed + ? `<span class='del'>${change.value}</span>` + : change.value; + return text; + }, + `<link rel="stylesheet" href="file://${path.join( + __dirname, + 'diffstyle.css' + )}">` + ); + return { + diff: html, + ext: '.html', + }; +}; + +GoldenComparators.set('image/png', compareImages); +GoldenComparators.set('image/jpeg', compareImages); +GoldenComparators.set('text/plain', compareText); + +export const compare = ( + goldenPath: string, + outputPath: string, + actual: string | Buffer, + goldenName: string +): {pass: true} | {pass: false; message: string} => { + goldenPath = path.normalize(goldenPath); + outputPath = path.normalize(outputPath); + const expectedPath = path.join(goldenPath, goldenName); + const actualPath = path.join(outputPath, goldenName); + + const messageSuffix = `Output is saved in "${path.basename( + outputPath + '" directory' + )}`; + + if (!fs.existsSync(expectedPath)) { + ensureOutputDir(); + fs.writeFileSync(actualPath, actual); + return { + pass: false, + message: `${goldenName} is missing in golden results. ${messageSuffix}`, + }; + } + const expected = fs.readFileSync(expectedPath); + const mimeType = mime.getType(goldenName); + assert(mimeType); + const comparator = GoldenComparators.get(mimeType); + if (!comparator) { + return { + pass: false, + message: `Failed to find comparator with type ${mimeType}: ${goldenName}`, + }; + } + const result = comparator(actual, expected, mimeType); + if (!result) { + return {pass: true}; + } + ensureOutputDir(); + if (goldenPath === outputPath) { + fs.writeFileSync(addSuffix(actualPath, '-actual'), actual); + } else { + fs.writeFileSync(actualPath, actual); + // Copy expected to the output/ folder for convenience. + fs.writeFileSync(addSuffix(actualPath, '-expected'), expected); + } + if (result) { + const diffPath = addSuffix(actualPath, '-diff', result.ext); + fs.writeFileSync(diffPath, result.diff); + } + + return { + pass: false, + message: `${goldenName} mismatch! ${messageSuffix}`, + }; + + function ensureOutputDir() { + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath); + } + } +}; diff --git a/remote/test/puppeteer/test/src/headful.spec.ts b/remote/test/puppeteer/test/src/headful.spec.ts new file mode 100644 index 0000000000..1e3248b4ff --- /dev/null +++ b/remote/test/puppeteer/test/src/headful.spec.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {mkdtemp} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import expect from 'expect'; +import type {PuppeteerLaunchOptions} from 'puppeteer-core/internal/node/PuppeteerNode.js'; +import {rmSync} from 'puppeteer-core/internal/node/util/fs.js'; + +import {getTestState, isHeadless, launch} from './mocha-utils.js'; + +const TMP_FOLDER = path.join(os.tmpdir(), 'pptr_tmp_folder-'); + +(!isHeadless ? describe : describe.skip)('headful tests', function () { + /* These tests fire up an actual browser so let's + * allow a higher timeout + */ + this.timeout(20_000); + + let headfulOptions: PuppeteerLaunchOptions | undefined; + let headlessOptions: PuppeteerLaunchOptions & {headless: boolean}; + + const browsers: Array<() => Promise<void>> = []; + + beforeEach(async () => { + const {defaultBrowserOptions} = await getTestState({ + skipLaunch: true, + }); + headfulOptions = Object.assign({}, defaultBrowserOptions, { + headless: false, + }); + headlessOptions = Object.assign({}, defaultBrowserOptions, { + headless: true, + }); + }); + + async function launchBrowser(options: any) { + const {browser, close} = await launch(options, {createContext: false}); + browsers.push(close); + return browser; + } + + afterEach(async () => { + await Promise.all( + browsers.map((close, index) => { + delete browsers[index]; + return close(); + }) + ); + }); + + describe('HEADFUL', function () { + it('headless should be able to read cookies written by headful', async () => { + /* Needs investigation into why but this fails consistently on Windows CI. */ + const {server} = await getTestState({skipLaunch: true}); + + const userDataDir = await mkdtemp(TMP_FOLDER); + // Write a cookie in headful chrome + const headfulBrowser = await launchBrowser( + Object.assign({userDataDir}, headfulOptions) + ); + const headfulPage = await headfulBrowser.newPage(); + await headfulPage.goto(server.EMPTY_PAGE); + await headfulPage.evaluate(() => { + return (document.cookie = + 'foo=true; expires=Fri, 31 Dec 9999 23:59:59 GMT'); + }); + await headfulBrowser.close(); + // Read the cookie from headless chrome + const headlessBrowser = await launchBrowser( + Object.assign({userDataDir}, headlessOptions) + ); + const headlessPage = await headlessBrowser.newPage(); + await headlessPage.goto(server.EMPTY_PAGE); + const cookie = await headlessPage.evaluate(() => { + return document.cookie; + }); + await headlessBrowser.close(); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + try { + rmSync(userDataDir); + } catch {} + expect(cookie).toBe('foo=true'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/idle_override.spec.ts b/remote/test/puppeteer/test/src/idle_override.spec.ts new file mode 100644 index 0000000000..cbcfd34640 --- /dev/null +++ b/remote/test/puppeteer/test/src/idle_override.spec.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import type {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js'; +import type {Page} from 'puppeteer-core/internal/api/Page.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('Emulate idle state', () => { + setupTestBrowserHooks(); + + async function getIdleState(page: Page) { + using stateElement = (await page.$('#state')) as ElementHandle<HTMLElement>; + return await page.evaluate(element => { + return element.innerText; + }, stateElement); + } + + async function verifyState(page: Page, expectedState: string) { + const actualState = await getIdleState(page); + expect(actualState).toEqual(expectedState); + } + + it('changing idle state emulation causes change of the IdleDetector state', async () => { + const {page, server, context} = await getTestState(); + await context.overridePermissions(server.PREFIX + '/idle-detector.html', [ + 'idle-detection', + ]); + + await page.goto(server.PREFIX + '/idle-detector.html'); + + // Store initial state, as soon as it is not guaranteed to be `active, unlocked`. + const initialState = await getIdleState(page); + + // Emulate Idle states and verify IdleDetector updates state accordingly. + await page.emulateIdleState({ + isUserActive: false, + isScreenUnlocked: false, + }); + await verifyState(page, 'Idle state: idle, locked.'); + + await page.emulateIdleState({ + isUserActive: true, + isScreenUnlocked: false, + }); + await verifyState(page, 'Idle state: active, locked.'); + + await page.emulateIdleState({ + isUserActive: true, + isScreenUnlocked: true, + }); + await verifyState(page, 'Idle state: active, unlocked.'); + + await page.emulateIdleState({ + isUserActive: false, + isScreenUnlocked: true, + }); + await verifyState(page, 'Idle state: idle, unlocked.'); + + // Remove Idle emulation and verify IdleDetector is in initial state. + await page.emulateIdleState(); + await verifyState(page, initialState); + + // Emulate idle state again after removing emulation. + await page.emulateIdleState({ + isUserActive: false, + isScreenUnlocked: false, + }); + await verifyState(page, 'Idle state: idle, locked.'); + + // Remove emulation second time. + await page.emulateIdleState(); + await verifyState(page, initialState); + }); +}); diff --git a/remote/test/puppeteer/test/src/ignorehttpserrors.spec.ts b/remote/test/puppeteer/test/src/ignorehttpserrors.spec.ts new file mode 100644 index 0000000000..8fb557cb88 --- /dev/null +++ b/remote/test/puppeteer/test/src/ignorehttpserrors.spec.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {TLSSocket} from 'tls'; + +import expect from 'expect'; +import type {HTTPResponse} from 'puppeteer-core/internal/api/HTTPResponse.js'; + +import {launch} from './mocha-utils.js'; + +describe('ignoreHTTPSErrors', function () { + /* Note that this test creates its own browser rather than use + * the one provided by the test set-up as we need one + * with ignoreHTTPSErrors set to true + */ + let state: Awaited<ReturnType<typeof launch>>; + + before(async () => { + state = await launch( + {ignoreHTTPSErrors: true}, + { + after: 'all', + } + ); + }); + + after(async () => { + await state.close(); + }); + + beforeEach(async () => { + state.context = await state.browser.createIncognitoBrowserContext(); + state.page = await state.context.newPage(); + }); + + afterEach(async () => { + await state.context.close(); + }); + + describe('Response.securityDetails', function () { + it('should work', async () => { + const {httpsServer, page} = state; + + const [serverRequest, response] = await Promise.all([ + httpsServer.waitForRequest('/empty.html'), + page.goto(httpsServer.EMPTY_PAGE), + ]); + const securityDetails = response!.securityDetails()!; + expect(securityDetails.issuer()).toBe('puppeteer-tests'); + const protocol = (serverRequest.socket as TLSSocket) + .getProtocol()! + .replace('v', ' '); + expect(securityDetails.protocol()).toBe(protocol); + expect(securityDetails.subjectName()).toBe('puppeteer-tests'); + expect(securityDetails.validFrom()).toBe(1589357069); + expect(securityDetails.validTo()).toBe(1904717069); + expect(securityDetails.subjectAlternativeNames()).toEqual([ + 'www.puppeteer-tests.test', + 'www.puppeteer-tests-1.test', + ]); + }); + it('should be |null| for non-secure requests', async () => { + const {server, page} = state; + + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.securityDetails()).toBe(null); + }); + it('Network redirects should report SecurityDetails', async () => { + const {httpsServer, page} = state; + + httpsServer.setRedirect('/plzredirect', '/empty.html'); + const responses: HTTPResponse[] = []; + page.on('response', response => { + return responses.push(response); + }); + const [serverRequest] = await Promise.all([ + httpsServer.waitForRequest('/plzredirect'), + page.goto(httpsServer.PREFIX + '/plzredirect'), + ]); + expect(responses).toHaveLength(2); + expect(responses[0]!.status()).toBe(302); + const securityDetails = responses[0]!.securityDetails()!; + const protocol = (serverRequest.socket as TLSSocket) + .getProtocol()! + .replace('v', ' '); + expect(securityDetails.protocol()).toBe(protocol); + }); + }); + + it('should work', async () => { + const {httpsServer, page} = state; + + let error!: Error; + const response = await page.goto(httpsServer.EMPTY_PAGE).catch(error_ => { + return (error = error_); + }); + expect(error).toBeUndefined(); + expect(response.ok()).toBe(true); + }); + it('should work with request interception', async () => { + const {httpsServer, page} = state; + + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue(); + }); + const response = (await page.goto(httpsServer.EMPTY_PAGE))!; + expect(response.status()).toBe(200); + }); + it('should work with mixed content', async () => { + const {server, httpsServer, page} = state; + + httpsServer.setRoute('/mixedcontent.html', (_req, res) => { + res.end(`<iframe src=${server.EMPTY_PAGE}></iframe>`); + }); + await page.goto(httpsServer.PREFIX + '/mixedcontent.html', { + waitUntil: 'load', + }); + expect(page.frames()).toHaveLength(2); + // Make sure blocked iframe has functional execution context + // @see https://github.com/puppeteer/puppeteer/issues/2709 + expect(await page.frames()[0]!.evaluate('1 + 2')).toBe(3); + expect(await page.frames()[1]!.evaluate('2 + 3')).toBe(5); + }); +}); diff --git a/remote/test/puppeteer/test/src/injected.spec.ts b/remote/test/puppeteer/test/src/injected.spec.ts new file mode 100644 index 0000000000..5f3696d3f6 --- /dev/null +++ b/remote/test/puppeteer/test/src/injected.spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {LazyArg} from 'puppeteer-core/internal/common/LazyArg.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('PuppeteerUtil tests', function () { + setupTestBrowserHooks(); + + it('should work', async () => { + const {page} = await getTestState(); + + const world = page.mainFrame().isolatedRealm(); + const value = await world.evaluate( + PuppeteerUtil => { + return typeof PuppeteerUtil === 'object'; + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }) + ); + expect(value).toBeTruthy(); + }); + + describe('createFunction tests', function () { + it('should work', async () => { + const {page} = await getTestState(); + + const world = page.mainFrame().isolatedRealm(); + const value = await world.evaluate( + ({createFunction}, fnString) => { + return createFunction(fnString)(4); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + (() => { + return 4; + }).toString() + ); + expect(value).toBe(4); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/input.spec.ts b/remote/test/puppeteer/test/src/input.spec.ts new file mode 100644 index 0000000000..7e4cae6709 --- /dev/null +++ b/remote/test/puppeteer/test/src/input.spec.ts @@ -0,0 +1,394 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; + +import expect from 'expect'; +import {TimeoutError} from 'puppeteer'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {waitEvent} from './utils.js'; + +const FILE_TO_UPLOAD = path.join(__dirname, '/../assets/file-to-upload.txt'); + +describe('input tests', function () { + setupTestBrowserHooks(); + + describe('input', function () { + it('should upload the file', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/fileupload.html'); + const filePath = path.relative(process.cwd(), FILE_TO_UPLOAD); + using input = (await page.$('input'))!; + await page.evaluate((e: HTMLElement) => { + (globalThis as any)._inputEvents = []; + e.addEventListener('change', ev => { + return (globalThis as any)._inputEvents.push(ev.type); + }); + e.addEventListener('input', ev => { + return (globalThis as any)._inputEvents.push(ev.type); + }); + }, input); + await input.uploadFile(filePath); + expect( + await page.evaluate((e: HTMLInputElement) => { + return e.files![0]!.name; + }, input) + ).toBe('file-to-upload.txt'); + expect( + await page.evaluate((e: HTMLInputElement) => { + return e.files![0]!.type; + }, input) + ).toBe('text/plain'); + expect( + await page.evaluate(() => { + return (globalThis as any)._inputEvents; + }) + ).toEqual(['input', 'change']); + expect( + await page.evaluate((e: HTMLInputElement) => { + const reader = new FileReader(); + const promise = new Promise(fulfill => { + return (reader.onload = fulfill); + }); + reader.readAsText(e.files![0]!); + return promise.then(() => { + return reader.result; + }); + }, input) + ).toBe('contents of the file'); + }); + }); + + describe('Page.waitForFileChooser', function () { + it('should work when file input is attached to DOM', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + expect(chooser).toBeTruthy(); + }); + it('should work when file input is not attached to DOM', async () => { + const {page} = await getTestState(); + + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.evaluate(() => { + const el = document.createElement('input'); + el.type = 'file'; + el.click(); + }), + ]); + expect(chooser).toBeTruthy(); + }); + it('should respect timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.waitForFileChooser({timeout: 1}).catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should respect default timeout when there is no custom timeout', async () => { + const {page} = await getTestState(); + + page.setDefaultTimeout(1); + let error!: Error; + await page.waitForFileChooser().catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should prioritize exact timeout over default timeout', async () => { + const {page} = await getTestState(); + + page.setDefaultTimeout(0); + let error!: Error; + await page.waitForFileChooser({timeout: 1}).catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should work with no timeout', async () => { + const {page} = await getTestState(); + + const [chooser] = await Promise.all([ + page.waitForFileChooser({timeout: 0}), + page.evaluate(() => { + return setTimeout(() => { + const el = document.createElement('input'); + el.type = 'file'; + el.click(); + }, 50); + }), + ]); + expect(chooser).toBeTruthy(); + }); + it('should return the same file chooser when there are many watchdogs simultaneously', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + const [fileChooser1, fileChooser2] = await Promise.all([ + page.waitForFileChooser(), + page.waitForFileChooser(), + page.$eval('input', input => { + return (input as HTMLInputElement).click(); + }), + ]); + expect(fileChooser1 === fileChooser2).toBe(true); + }); + }); + + describe('FileChooser.accept', function () { + it('should accept single file', async () => { + const {page} = await getTestState(); + + await page.setContent( + `<input type=file oninput='javascript:console.timeStamp()'>` + ); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + await Promise.all([ + chooser.accept([FILE_TO_UPLOAD]), + waitEvent(page, 'metrics'), + ]); + expect( + await page.$eval('input', input => { + return (input as HTMLInputElement).files!.length; + }) + ).toBe(1); + expect( + await page.$eval('input', input => { + return (input as HTMLInputElement).files![0]!.name; + }) + ).toBe('file-to-upload.txt'); + }); + it('should be able to read selected file', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + void page.waitForFileChooser().then(chooser => { + return chooser.accept([FILE_TO_UPLOAD]); + }); + expect( + await page.$eval('input', async picker => { + const pick = picker as HTMLInputElement; + pick.click(); + await new Promise(x => { + return (pick.oninput = x); + }); + const reader = new FileReader(); + const promise = new Promise(fulfill => { + return (reader.onload = fulfill); + }); + reader.readAsText(pick.files![0]!); + return await promise.then(() => { + return reader.result; + }); + }) + ).toBe('contents of the file'); + }); + it('should be able to reset selected files with empty file list', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + void page.waitForFileChooser().then(chooser => { + return chooser.accept([FILE_TO_UPLOAD]); + }); + expect( + await page.$eval('input', async picker => { + const pick = picker as HTMLInputElement; + pick.click(); + await new Promise(x => { + return (pick.oninput = x); + }); + return pick.files!.length; + }) + ).toBe(1); + void page.waitForFileChooser().then(chooser => { + return chooser.accept([]); + }); + expect( + await page.$eval('input', async picker => { + const pick = picker as HTMLInputElement; + pick.click(); + await new Promise(x => { + return (pick.oninput = x); + }); + return pick.files!.length; + }) + ).toBe(0); + }); + it('should not accept multiple files for single-file input', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + let error!: Error; + await chooser + .accept([ + path.relative( + process.cwd(), + __dirname + '/../assets/file-to-upload.txt' + ), + path.relative(process.cwd(), __dirname + '/../assets/pptr.png'), + ]) + .catch(error_ => { + return (error = error_); + }); + expect(error).not.toBe(null); + }); + it('should succeed even for non-existent files', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + let error!: Error; + await chooser.accept(['file-does-not-exist.txt']).catch(error_ => { + return (error = error_); + }); + expect(error).toBeUndefined(); + }); + it('should error on read of non-existent files', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + void page.waitForFileChooser().then(chooser => { + return chooser.accept(['file-does-not-exist.txt']); + }); + expect( + await page.$eval('input', async picker => { + const pick = picker as HTMLInputElement; + pick.click(); + await new Promise(x => { + return (pick.oninput = x); + }); + const reader = new FileReader(); + const promise = new Promise(fulfill => { + return (reader.onerror = fulfill); + }); + reader.readAsText(pick.files![0]!); + return await promise.then(() => { + return false; + }); + }) + ).toBeFalsy(); + }); + it('should fail when accepting file chooser twice', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.$eval('input', input => { + return (input as HTMLInputElement).click(); + }), + ]); + await fileChooser.accept([]); + let error!: Error; + await fileChooser.accept([]).catch(error_ => { + return (error = error_); + }); + expect(error.message).toBe( + 'Cannot accept FileChooser which is already handled!' + ); + }); + }); + + describe('FileChooser.cancel', function () { + it('should cancel dialog', async () => { + const {page} = await getTestState(); + + // Consider file chooser canceled if we can summon another one. + // There's no reliable way in WebPlatform to see that FileChooser was + // canceled. + await page.setContent(`<input type=file>`); + const [fileChooser1] = await Promise.all([ + page.waitForFileChooser(), + page.$eval('input', input => { + return (input as HTMLInputElement).click(); + }), + ]); + await fileChooser1.cancel(); + // If this resolves, than we successfully canceled file chooser. + await Promise.all([ + page.waitForFileChooser(), + page.$eval('input', input => { + return (input as HTMLInputElement).click(); + }), + ]); + }); + it('should fail when canceling file chooser twice', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.$eval('input', input => { + return (input as HTMLElement).click(); + }), + ]); + await fileChooser.cancel(); + let error!: Error; + + try { + await fileChooser.cancel(); + } catch (error_) { + error = error_ as Error; + } + + expect(error.message).toBe( + 'Cannot cancel FileChooser which is already handled!' + ); + }); + }); + + describe('FileChooser.isMultiple', () => { + it('should work for single file pick', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + expect(chooser.isMultiple()).toBe(false); + }); + it('should work for "multiple"', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input multiple type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + expect(chooser.isMultiple()).toBe(true); + }); + it('should work for "webkitdirectory"', async () => { + const {page} = await getTestState(); + + await page.setContent(`<input multiple webkitdirectory type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + expect(chooser.isMultiple()).toBe(true); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/jshandle.spec.ts b/remote/test/puppeteer/test/src/jshandle.spec.ts new file mode 100644 index 0000000000..28097811e4 --- /dev/null +++ b/remote/test/puppeteer/test/src/jshandle.spec.ts @@ -0,0 +1,373 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {JSHandle} from 'puppeteer-core/internal/api/JSHandle.js'; +import { + asyncDisposeSymbol, + disposeSymbol, +} from 'puppeteer-core/internal/util/disposable.js'; +import sinon from 'sinon'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('JSHandle', function () { + setupTestBrowserHooks(); + + describe('Page.evaluateHandle', function () { + it('should work', async () => { + const {page} = await getTestState(); + + using windowHandle = await page.evaluateHandle(() => { + return window; + }); + expect(windowHandle).toBeTruthy(); + }); + it('should return the RemoteObject', async () => { + const {page} = await getTestState(); + + using windowHandle = await page.evaluateHandle(() => { + return window; + }); + expect(windowHandle.remoteObject()).toBeTruthy(); + }); + it('should accept object handle as an argument', async () => { + const {page} = await getTestState(); + + using navigatorHandle = await page.evaluateHandle(() => { + return navigator; + }); + const text = await page.evaluate(e => { + return e.userAgent; + }, navigatorHandle); + expect(text).toContain('Mozilla'); + }); + it('should accept object handle to primitive types', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return 5; + }); + const isFive = await page.evaluate(e => { + return Object.is(e, 5); + }, aHandle); + expect(isFive).toBeTruthy(); + }); + it('should warn about recursive objects', async () => { + const {page} = await getTestState(); + + const test: {obj?: unknown} = {}; + test.obj = test; + let error!: Error; + await page + .evaluateHandle(opts => { + return opts; + }, test) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('Recursive objects are not allowed.'); + }); + it('should accept object handle to unserializable value', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return Infinity; + }); + expect( + await page.evaluate(e => { + return Object.is(e, Infinity); + }, aHandle) + ).toBe(true); + }); + it('should use the same JS wrappers', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + (globalThis as any).FOO = 123; + return window; + }); + expect( + await page.evaluate(e => { + return (e as any).FOO; + }, aHandle) + ).toBe(123); + }); + }); + + describe('JSHandle.getProperty', function () { + it('should work', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return { + one: 1, + two: 2, + three: 3, + }; + }); + using twoHandle = await aHandle.getProperty('two'); + expect(await twoHandle.jsonValue()).toEqual(2); + }); + }); + + describe('JSHandle.jsonValue', function () { + it('should work', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return {foo: 'bar'}; + }); + const json = await aHandle.jsonValue(); + expect(json).toEqual({foo: 'bar'}); + }); + + it('works with jsonValues that are not objects', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return ['a', 'b']; + }); + const json = await aHandle.jsonValue(); + expect(json).toEqual(['a', 'b']); + }); + + it('works with jsonValues that are primitives', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return 'foo'; + }); + expect(await aHandle.jsonValue()).toEqual('foo'); + + using bHandle = await page.evaluateHandle(() => { + return undefined; + }); + expect(await bHandle.jsonValue()).toEqual(undefined); + }); + + it('should work with dates', async () => { + const {page} = await getTestState(); + + using dateHandle = await page.evaluateHandle(() => { + return new Date('2017-09-26T00:00:00.000Z'); + }); + const date = await dateHandle.jsonValue(); + expect(date).toBeInstanceOf(Date); + expect(date.toISOString()).toEqual('2017-09-26T00:00:00.000Z'); + }); + it('should not throw for circular objects', async () => { + const {page} = await getTestState(); + + using handle = await page.evaluateHandle(() => { + const t: {t?: unknown; g: number} = {g: 1}; + t.t = t; + return t; + }); + await handle.jsonValue(); + }); + }); + + describe('JSHandle.getProperties', function () { + it('should work', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return { + foo: 'bar', + }; + }); + const properties = await aHandle.getProperties(); + using foo = properties.get('foo')!; + expect(foo).toBeTruthy(); + expect(await foo.jsonValue()).toBe('bar'); + }); + it('should return even non-own properties', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + class A { + a: string; + constructor() { + this.a = '1'; + } + } + class B extends A { + b: string; + constructor() { + super(); + this.b = '2'; + } + } + return new B(); + }); + const properties = await aHandle.getProperties(); + expect(await properties.get('a')!.jsonValue()).toBe('1'); + expect(await properties.get('b')!.jsonValue()).toBe('2'); + }); + }); + + describe('JSHandle.asElement', function () { + it('should work', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return document.body; + }); + using element = aHandle.asElement(); + expect(element).toBeTruthy(); + }); + it('should return null for non-elements', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return 2; + }); + using element = aHandle.asElement(); + expect(element).toBeFalsy(); + }); + it('should return ElementHandle for TextNodes', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>ee!</div>'); + using aHandle = await page.evaluateHandle(() => { + return document.querySelector('div')!.firstChild; + }); + using element = aHandle.asElement(); + expect(element).toBeTruthy(); + expect( + await page.evaluate(e => { + return e?.nodeType === Node.TEXT_NODE; + }, element) + ); + }); + }); + + describe('JSHandle.toString', function () { + it('should work for primitives', async () => { + const {page} = await getTestState(); + + using numberHandle = await page.evaluateHandle(() => { + return 2; + }); + expect(numberHandle.toString()).toBe('JSHandle:2'); + using stringHandle = await page.evaluateHandle(() => { + return 'a'; + }); + expect(stringHandle.toString()).toBe('JSHandle:a'); + }); + it('should work for complicated objects', async () => { + const {page} = await getTestState(); + + using aHandle = await page.evaluateHandle(() => { + return window; + }); + expect(aHandle.toString()).atLeastOneToContain([ + 'JSHandle@object', + 'JSHandle@window', + ]); + }); + it('should work with different subtypes', async () => { + const {page} = await getTestState(); + + expect((await page.evaluateHandle('(function(){})')).toString()).toBe( + 'JSHandle@function' + ); + expect((await page.evaluateHandle('12')).toString()).toBe('JSHandle:12'); + expect((await page.evaluateHandle('true')).toString()).toBe( + 'JSHandle:true' + ); + expect((await page.evaluateHandle('undefined')).toString()).toBe( + 'JSHandle:undefined' + ); + expect((await page.evaluateHandle('"foo"')).toString()).toBe( + 'JSHandle:foo' + ); + expect((await page.evaluateHandle('Symbol()')).toString()).toBe( + 'JSHandle@symbol' + ); + expect((await page.evaluateHandle('new Map()')).toString()).toBe( + 'JSHandle@map' + ); + expect((await page.evaluateHandle('new Set()')).toString()).toBe( + 'JSHandle@set' + ); + expect((await page.evaluateHandle('[]')).toString()).toBe( + 'JSHandle@array' + ); + expect((await page.evaluateHandle('null')).toString()).toBe( + 'JSHandle:null' + ); + expect((await page.evaluateHandle('/foo/')).toString()).toBe( + 'JSHandle@regexp' + ); + expect((await page.evaluateHandle('document.body')).toString()).toBe( + 'JSHandle@node' + ); + expect((await page.evaluateHandle('new Date()')).toString()).toBe( + 'JSHandle@date' + ); + expect((await page.evaluateHandle('new WeakMap()')).toString()).toBe( + 'JSHandle@weakmap' + ); + expect((await page.evaluateHandle('new WeakSet()')).toString()).toBe( + 'JSHandle@weakset' + ); + expect((await page.evaluateHandle('new Error()')).toString()).toBe( + 'JSHandle@error' + ); + expect((await page.evaluateHandle('new Int32Array()')).toString()).toBe( + 'JSHandle@typedarray' + ); + expect((await page.evaluateHandle('new Proxy({}, {})')).toString()).toBe( + 'JSHandle@proxy' + ); + }); + }); + + describe('JSHandle[Symbol.dispose]', () => { + it('should work', async () => { + const {page} = await getTestState(); + using handle = await page.evaluateHandle('new Set()'); + const spy = sinon.spy(handle, disposeSymbol); + { + using _ = handle; + } + expect(handle).toBeInstanceOf(JSHandle); + expect(spy.calledOnce).toBeTruthy(); + expect(handle.disposed).toBeTruthy(); + }); + }); + + describe('JSHandle[Symbol.asyncDispose]', () => { + it('should work', async () => { + const {page} = await getTestState(); + using handle = await page.evaluateHandle('new Set()'); + const spy = sinon.spy(handle, asyncDisposeSymbol); + { + await using _ = handle; + } + expect(handle).toBeInstanceOf(JSHandle); + expect(spy.calledOnce).toBeTruthy(); + expect(handle.disposed).toBeTruthy(); + }); + }); + + describe('JSHandle.move', () => { + it('should work', async () => { + const {page} = await getTestState(); + using handle = await page.evaluateHandle('new Set()'); + const spy = sinon.spy(handle, disposeSymbol); + { + using _ = handle; + handle.move(); + } + expect(handle).toBeInstanceOf(JSHandle); + expect(spy.calledOnce).toBeTruthy(); + expect(handle.disposed).toBeFalsy(); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/keyboard.spec.ts b/remote/test/puppeteer/test/src/keyboard.spec.ts new file mode 100644 index 0000000000..9157465242 --- /dev/null +++ b/remote/test/puppeteer/test/src/keyboard.spec.ts @@ -0,0 +1,550 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'os'; + +import expect from 'expect'; +import type {KeyInput} from 'puppeteer-core/internal/common/USKeyboardLayout.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {attachFrame} from './utils.js'; + +describe('Keyboard', function () { + setupTestBrowserHooks(); + + it('should type into a textarea', async () => { + const {page} = await getTestState(); + + await page.evaluate(() => { + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + textarea.focus(); + }); + const text = 'Hello world. I am the text that was typed!'; + await page.keyboard.type(text); + expect( + await page.evaluate(() => { + return document.querySelector('textarea')!.value; + }) + ).toBe(text); + }); + it('should move with the arrow keys', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.type('textarea', 'Hello World!'); + expect( + await page.evaluate(() => { + return document.querySelector('textarea')!.value; + }) + ).toBe('Hello World!'); + for (const _ of 'World!') { + await page.keyboard.press('ArrowLeft'); + } + await page.keyboard.type('inserted '); + expect( + await page.evaluate(() => { + return document.querySelector('textarea')!.value; + }) + ).toBe('Hello inserted World!'); + await page.keyboard.down('Shift'); + for (const _ of 'inserted ') { + await page.keyboard.press('ArrowLeft'); + } + await page.keyboard.up('Shift'); + await page.keyboard.press('Backspace'); + expect( + await page.evaluate(() => { + return document.querySelector('textarea')!.value; + }) + ).toBe('Hello World!'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/1313 + it('should trigger commands of keyboard shortcuts', async () => { + const {page, server} = await getTestState(); + const cmdKey = os.platform() === 'darwin' ? 'Meta' : 'Control'; + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.type('textarea', 'hello'); + + await page.keyboard.down(cmdKey); + await page.keyboard.press('a', {commands: ['SelectAll']}); + await page.keyboard.up(cmdKey); + + await page.keyboard.down(cmdKey); + await page.keyboard.down('c', {commands: ['Copy']}); + await page.keyboard.up('c'); + await page.keyboard.up(cmdKey); + + await page.keyboard.down(cmdKey); + await page.keyboard.press('v', {commands: ['Paste']}); + await page.keyboard.press('v', {commands: ['Paste']}); + await page.keyboard.up(cmdKey); + + expect( + await page.evaluate(() => { + return document.querySelector('textarea')!.value; + }) + ).toBe('hellohello'); + }); + it('should send a character with ElementHandle.press', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + using textarea = (await page.$('textarea'))!; + await textarea.press('a'); + expect( + await page.evaluate(() => { + return document.querySelector('textarea')!.value; + }) + ).toBe('a'); + + await page.evaluate(() => { + return window.addEventListener( + 'keydown', + e => { + return e.preventDefault(); + }, + true + ); + }); + + await textarea.press('b'); + expect( + await page.evaluate(() => { + return document.querySelector('textarea')!.value; + }) + ).toBe('a'); + }); + it('ElementHandle.press should not support |text| option', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + using textarea = (await page.$('textarea'))!; + await textarea.press('a', {text: 'ё'}); + expect( + await page.evaluate(() => { + return document.querySelector('textarea')!.value; + }) + ).toBe('a'); + }); + it('should send a character with sendCharacter', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + + await page.evaluate(() => { + (globalThis as any).inputCount = 0; + (globalThis as any).keyDownCount = 0; + window.addEventListener( + 'input', + () => { + (globalThis as any).inputCount += 1; + }, + true + ); + window.addEventListener( + 'keydown', + () => { + (globalThis as any).keyDownCount += 1; + }, + true + ); + }); + + await page.keyboard.sendCharacter('嗨'); + expect( + await page.$eval('textarea', textarea => { + return { + value: textarea.value, + inputs: (globalThis as any).inputCount, + keyDowns: (globalThis as any).keyDownCount, + }; + }) + ).toMatchObject({value: '嗨', inputs: 1, keyDowns: 0}); + + await page.keyboard.sendCharacter('a'); + expect( + await page.$eval('textarea', textarea => { + return { + value: textarea.value, + inputs: (globalThis as any).inputCount, + keyDowns: (globalThis as any).keyDownCount, + }; + }) + ).toMatchObject({value: '嗨a', inputs: 2, keyDowns: 0}); + }); + it('should send a character with sendCharacter in iframe', async () => { + this.timeout(2000); + + const {page} = await getTestState(); + + await page.setContent(` + <iframe srcdoc="<iframe name='test' srcdoc='<textarea></textarea>'></iframe>"</iframe> + `); + const frame = await page.waitForFrame(frame => { + return frame.name() === 'test'; + }); + await frame.focus('textarea'); + + await frame.evaluate(() => { + (globalThis as any).inputCount = 0; + (globalThis as any).keyDownCount = 0; + window.addEventListener( + 'input', + () => { + (globalThis as any).inputCount += 1; + }, + true + ); + window.addEventListener( + 'keydown', + () => { + (globalThis as any).keyDownCount += 1; + }, + true + ); + }); + + await page.keyboard.sendCharacter('嗨'); + expect( + await frame.$eval('textarea', textarea => { + return { + value: textarea.value, + inputs: (globalThis as any).inputCount, + keyDowns: (globalThis as any).keyDownCount, + }; + }) + ).toMatchObject({value: '嗨', inputs: 1, keyDowns: 0}); + + await page.keyboard.sendCharacter('a'); + expect( + await frame.$eval('textarea', textarea => { + return { + value: textarea.value, + inputs: (globalThis as any).inputCount, + keyDowns: (globalThis as any).keyDownCount, + }; + }) + ).toMatchObject({value: '嗨a', inputs: 2, keyDowns: 0}); + }); + it('should report shiftKey', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + const codeForKey = new Set<KeyInput>(['Shift', 'Alt', 'Control']); + for (const modifierKey of codeForKey) { + await keyboard.down(modifierKey); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe(`Keydown: ${modifierKey} ${modifierKey}Left [${modifierKey}]`); + await keyboard.down('!'); + if (modifierKey === 'Shift') { + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe( + `Keydown: ! Digit1 [${modifierKey}]\n` + `input: ! insertText false` + ); + } else { + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe(`Keydown: ! Digit1 [${modifierKey}]`); + } + + await keyboard.up('!'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe(`Keyup: ! Digit1 [${modifierKey}]`); + await keyboard.up(modifierKey); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe(`Keyup: ${modifierKey} ${modifierKey}Left []`); + } + }); + it('should report multiple modifiers', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + await keyboard.down('Control'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe('Keydown: Control ControlLeft [Control]'); + await keyboard.down('Alt'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe('Keydown: Alt AltLeft [Alt Control]'); + await keyboard.down(';'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe('Keydown: ; Semicolon [Alt Control]'); + await keyboard.up(';'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe('Keyup: ; Semicolon [Alt Control]'); + await keyboard.up('Control'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe('Keyup: Control ControlLeft [Alt]'); + await keyboard.up('Alt'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe('Keyup: Alt AltLeft []'); + }); + it('should send proper codes while typing', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/keyboard.html'); + await page.keyboard.type('!'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe( + [ + 'Keydown: ! Digit1 []', + 'input: ! insertText false', + 'Keyup: ! Digit1 []', + ].join('\n') + ); + await page.keyboard.type('^'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe( + [ + 'Keydown: ^ Digit6 []', + 'input: ^ insertText false', + 'Keyup: ^ Digit6 []', + ].join('\n') + ); + }); + it('should send proper codes while typing with shift', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + await keyboard.down('Shift'); + await page.keyboard.type('~'); + expect( + await page.evaluate(() => { + return (globalThis as any).getResult(); + }) + ).toBe( + [ + 'Keydown: Shift ShiftLeft [Shift]', + 'Keydown: ~ Backquote [Shift]', + 'input: ~ insertText false', + 'Keyup: ~ Backquote [Shift]', + ].join('\n') + ); + await keyboard.up('Shift'); + }); + it('should not type canceled events', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + await page.evaluate(() => { + window.addEventListener( + 'keydown', + event => { + event.stopPropagation(); + event.stopImmediatePropagation(); + if (event.key === 'l') { + event.preventDefault(); + } + if (event.key === 'o') { + event.preventDefault(); + } + }, + false + ); + }); + await page.keyboard.type('Hello World!'); + expect( + await page.evaluate(() => { + return (globalThis as any).textarea.value; + }) + ).toBe('He Wrd!'); + }); + it('should specify repeat property', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + await page.evaluate(() => { + return document.querySelector('textarea')!.addEventListener( + 'keydown', + e => { + return ((globalThis as any).lastEvent = e); + }, + true + ); + }); + await page.keyboard.down('a'); + expect( + await page.evaluate(() => { + return (globalThis as any).lastEvent.repeat; + }) + ).toBe(false); + await page.keyboard.press('a'); + expect( + await page.evaluate(() => { + return (globalThis as any).lastEvent.repeat; + }) + ).toBe(true); + + await page.keyboard.down('b'); + expect( + await page.evaluate(() => { + return (globalThis as any).lastEvent.repeat; + }) + ).toBe(false); + await page.keyboard.down('b'); + expect( + await page.evaluate(() => { + return (globalThis as any).lastEvent.repeat; + }) + ).toBe(true); + + await page.keyboard.up('a'); + await page.keyboard.down('a'); + expect( + await page.evaluate(() => { + return (globalThis as any).lastEvent.repeat; + }) + ).toBe(false); + }); + it('should type all kinds of characters', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + const text = 'This text goes onto two lines.\nThis character is 嗨.'; + await page.keyboard.type(text); + expect(await page.evaluate('result')).toBe(text); + }); + it('should specify location', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.evaluate(() => { + window.addEventListener( + 'keydown', + event => { + return ((globalThis as any).keyLocation = event.location); + }, + true + ); + }); + using textarea = (await page.$('textarea'))!; + + await textarea.press('Digit5'); + expect(await page.evaluate('keyLocation')).toBe(0); + + await textarea.press('ControlLeft'); + expect(await page.evaluate('keyLocation')).toBe(1); + + await textarea.press('ControlRight'); + expect(await page.evaluate('keyLocation')).toBe(2); + + await textarea.press('NumpadSubtract'); + expect(await page.evaluate('keyLocation')).toBe(3); + }); + it('should throw on unknown keys', async () => { + const {page} = await getTestState(); + + const error = await page.keyboard + // @ts-expect-error bad input + .press('NotARealKey') + .catch(error_ => { + return error_; + }); + expect(error.message).toBe('Unknown key: "NotARealKey"'); + }); + it('should type emoji', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.type('textarea', '👹 Tokyo street Japan 🇯🇵'); + expect( + await page.$eval('textarea', textarea => { + return textarea.value; + }) + ).toBe('👹 Tokyo street Japan 🇯🇵'); + }); + it('should type emoji into an iframe', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await attachFrame( + page, + 'emoji-test', + server.PREFIX + '/input/textarea.html' + ); + const frame = page.frames()[1]!; + using textarea = (await frame.$('textarea'))!; + await textarea.type('👹 Tokyo street Japan 🇯🇵'); + expect( + await frame.$eval('textarea', textarea => { + return textarea.value; + }) + ).toBe('👹 Tokyo street Japan 🇯🇵'); + }); + it('should press the meta key', async () => { + // This test only makes sense on macOS. + if (os.platform() !== 'darwin') { + return; + } + const {page} = await getTestState(); + + await page.evaluate(() => { + (globalThis as any).result = null; + document.addEventListener('keydown', event => { + (globalThis as any).result = [event.key, event.code, event.metaKey]; + }); + }); + await page.keyboard.press('Meta'); + // Have to do this because we lose a lot of type info when evaluating a + // string not a function. This is why functions are recommended rather than + // using strings (although we'll leave this test so we have coverage of both + // approaches.) + const [key, code, metaKey] = (await page.evaluate('result')) as [ + string, + string, + boolean, + ]; + expect(key).toBe('Meta'); + expect(code).toBe('MetaLeft'); + expect(metaKey).toBe(true); + }); +}); diff --git a/remote/test/puppeteer/test/src/launcher.spec.ts b/remote/test/puppeteer/test/src/launcher.spec.ts new file mode 100644 index 0000000000..f31b22b1e5 --- /dev/null +++ b/remote/test/puppeteer/test/src/launcher.spec.ts @@ -0,0 +1,1025 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert'; +import fs from 'fs'; +import {mkdtemp, readFile, writeFile} from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import type {TLSSocket} from 'tls'; + +import expect from 'expect'; +import {TimeoutError} from 'puppeteer'; +import type {Page} from 'puppeteer-core/internal/api/Page.js'; +import {rmSync} from 'puppeteer-core/internal/node/util/fs.js'; +import sinon from 'sinon'; + +import {getTestState, isHeadless, launch} from './mocha-utils.js'; +import {dumpFrames, waitEvent} from './utils.js'; + +const TMP_FOLDER = path.join(os.tmpdir(), 'pptr_tmp_folder-'); +const FIREFOX_TIMEOUT = 30_000; + +describe('Launcher specs', function () { + this.timeout(FIREFOX_TIMEOUT); + + describe('Puppeteer', function () { + describe('Browser.disconnect', function () { + it('should reject navigation when browser closes', async () => { + const {browser, close, puppeteer, server} = await launch({}); + server.setRoute('/one-style.css', () => {}); + try { + const remote = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + protocol: browser.protocol, + }); + const page = await remote.newPage(); + const navigationPromise = page + .goto(server.PREFIX + '/one-style.html', {timeout: 60000}) + .catch(error_ => { + return error_; + }); + await server.waitForRequest('/one-style.css'); + await remote.disconnect(); + const error = await navigationPromise; + expect( + [ + 'Navigating frame was detached', + 'Protocol error (Page.navigate): Target closed.', + ].includes(error.message) + ).toBeTruthy(); + } finally { + await close(); + } + }); + it('should reject waitForSelector when browser closes', async () => { + const {browser, close, server, puppeteer} = await launch({}); + server.setRoute('/empty.html', () => {}); + try { + const remote = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + protocol: browser.protocol, + }); + const page = await remote.newPage(); + const watchdog = page + .waitForSelector('div', {timeout: 60000}) + .catch(error_ => { + return error_; + }); + await remote.disconnect(); + const error = await watchdog; + expect(error.message).toContain('Session closed.'); + } finally { + await close(); + } + }); + }); + describe('Browser.close', function () { + it('should terminate network waiters', async () => { + const {browser, close, server, puppeteer} = await launch({}); + try { + const remote = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + protocol: browser.protocol, + }); + const newPage = await remote.newPage(); + const results = await Promise.all([ + newPage.waitForRequest(server.EMPTY_PAGE).catch(error => { + return error; + }), + newPage.waitForResponse(server.EMPTY_PAGE).catch(error => { + return error; + }), + browser.close(), + ]); + for (let i = 0; i < 2; i++) { + const message = results[i].message; + expect(message).atLeastOneToContain([ + 'Target closed', + 'Page closed!', + ]); + expect(message).not.toContain('Timeout'); + } + } finally { + await close(); + } + }); + }); + describe('Puppeteer.launch', function () { + it('can launch and close the browser', async () => { + const {close} = await launch({}); + await close(); + }); + it('should have default url when launching browser', async function () { + const {browser, close} = await launch({}, {createContext: false}); + try { + const pages = (await browser.pages()).map( + (page: {url: () => any}) => { + return page.url(); + } + ); + expect(pages).toEqual(['about:blank']); + } finally { + await close(); + } + }); + it('should close browser with beforeunload page', async () => { + const {browser, server, close} = await launch( + {}, + {createContext: false} + ); + try { + const page = await browser.newPage(); + + await page.goto(server.PREFIX + '/beforeunload.html'); + // We have to interact with a page so that 'beforeunload' handlers + // fire. + await page.click('body'); + } finally { + await close(); + } + }); + it('should reject all promises when browser is closed', async () => { + const {page, close} = await launch({}); + let error!: Error; + const neverResolves = page + .evaluate(() => { + return new Promise(() => {}); + }) + .catch(error_ => { + return (error = error_); + }); + await close(); + await neverResolves; + expect(error.message).toContain('Protocol error'); + }); + it('should reject if executable path is invalid', async () => { + let waitError!: Error; + await launch({ + executablePath: 'random-invalid-path', + }).catch(error => { + return (waitError = error); + }); + expect(waitError.message).toContain('Failed to launch'); + }); + it('userDataDir option', async () => { + const userDataDir = await mkdtemp(TMP_FOLDER); + const {context, close} = await launch({userDataDir}); + // Open a page to make sure its functional. + try { + await context.newPage(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + } finally { + await close(); + } + + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + try { + rmSync(userDataDir); + } catch {} + }); + it('tmp profile should be cleaned up', async () => { + const {puppeteer} = await getTestState({skipLaunch: true}); + + // Set a custom test tmp dir so that we can validate that + // the profile dir is created and then cleaned up. + const testTmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'puppeteer_test_chrome_profile-') + ); + const oldTmpDir = puppeteer.configuration.temporaryDirectory; + puppeteer.configuration.temporaryDirectory = testTmpDir; + + // Path should be empty before starting the browser. + expect(fs.readdirSync(testTmpDir)).toHaveLength(0); + const {context, close} = await launch({}); + try { + // One profile folder should have been created at this moment. + const profiles = fs.readdirSync(testTmpDir); + expect(profiles).toHaveLength(1); + expect(profiles[0]?.startsWith('puppeteer_dev_chrome_profile-')).toBe( + true + ); + + // Open a page to make sure its functional. + await context.newPage(); + } finally { + await close(); + } + + // Profile should be deleted after closing the browser + expect(fs.readdirSync(testTmpDir)).toHaveLength(0); + + // Restore env var + puppeteer.configuration.temporaryDirectory = oldTmpDir; + }); + it('userDataDir option restores preferences', async () => { + const userDataDir = await mkdtemp(TMP_FOLDER); + + const prefsJSPath = path.join(userDataDir, 'prefs.js'); + const prefsJSContent = 'user_pref("browser.warnOnQuit", true)'; + await writeFile(prefsJSPath, prefsJSContent); + + const {context, close} = await launch({userDataDir}); + try { + // Open a page to make sure its functional. + await context.newPage(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + await close(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + + expect(await readFile(prefsJSPath, 'utf8')).toBe(prefsJSContent); + } finally { + await close(); + } + + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + try { + rmSync(userDataDir); + } catch {} + }); + it('userDataDir argument', async () => { + const {isChrome, defaultBrowserOptions: options} = await getTestState({ + skipLaunch: true, + }); + + const userDataDir = await mkdtemp(TMP_FOLDER); + if (isChrome) { + options.args = [ + ...(options.args || []), + `--user-data-dir=${userDataDir}`, + ]; + } else { + options.args = [...(options.args || []), '-profile', userDataDir]; + } + const {close} = await launch(options); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + await close(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + try { + rmSync(userDataDir); + } catch {} + }); + it('userDataDir argument with non-existent dir', async () => { + const {isChrome, defaultBrowserOptions} = await getTestState({ + skipLaunch: true, + }); + + const userDataDir = await mkdtemp(TMP_FOLDER); + rmSync(userDataDir); + const options = Object.assign({}, defaultBrowserOptions); + if (isChrome) { + options.args = [ + ...(defaultBrowserOptions.args || []), + `--user-data-dir=${userDataDir}`, + ]; + } else { + options.args = [ + ...(defaultBrowserOptions.args || []), + '-profile', + userDataDir, + ]; + } + const {close} = await launch(options); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + await close(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + try { + rmSync(userDataDir); + } catch {} + }); + it('userDataDir option should restore state', async () => { + const userDataDir = await mkdtemp(TMP_FOLDER); + const {server, browser, close} = await launch({userDataDir}); + try { + const page = await browser.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + return (localStorage['hey'] = 'hello'); + }); + } finally { + await close(); + } + + const {browser: browser2, close: close2} = await launch({userDataDir}); + + try { + const page2 = await browser2.newPage(); + await page2.goto(server.EMPTY_PAGE); + expect( + await page2.evaluate(() => { + return localStorage['hey']; + }) + ).toBe('hello'); + } finally { + await close2(); + } + + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + try { + rmSync(userDataDir); + } catch {} + }); + it('userDataDir option should restore cookies', async () => { + const userDataDir = await mkdtemp(TMP_FOLDER); + const {server, browser, close} = await launch({userDataDir}); + try { + const page = await browser.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + return (document.cookie = + 'doSomethingOnlyOnce=true; expires=Fri, 31 Dec 9999 23:59:59 GMT'); + }); + } finally { + await close(); + } + + const {browser: browser2, close: close2} = await launch({userDataDir}); + try { + const page2 = await browser2.newPage(); + await page2.goto(server.EMPTY_PAGE); + expect( + await page2.evaluate(() => { + return document.cookie; + }) + ).toBe('doSomethingOnlyOnce=true'); + } finally { + await close2(); + } + + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + try { + rmSync(userDataDir); + } catch {} + }); + it('should return the default arguments', async () => { + const {isChrome, isFirefox, puppeteer} = await getTestState({ + skipLaunch: true, + }); + + if (isChrome) { + expect(puppeteer.defaultArgs()).toContain('--no-first-run'); + expect(puppeteer.defaultArgs()).toContain('--headless'); + expect(puppeteer.defaultArgs({headless: false})).not.toContain( + '--headless' + ); + expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain( + `--user-data-dir=${path.resolve('foo')}` + ); + } else if (isFirefox) { + expect(puppeteer.defaultArgs()).toContain('--headless'); + expect(puppeteer.defaultArgs()).toContain('--no-remote'); + if (os.platform() === 'darwin') { + expect(puppeteer.defaultArgs()).toContain('--foreground'); + } else { + expect(puppeteer.defaultArgs()).not.toContain('--foreground'); + } + expect(puppeteer.defaultArgs({headless: false})).not.toContain( + '--headless' + ); + expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain( + '--profile' + ); + expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain('foo'); + } else { + expect(puppeteer.defaultArgs()).toContain('-headless'); + expect(puppeteer.defaultArgs({headless: false})).not.toContain( + '-headless' + ); + expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain( + '-profile' + ); + expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain( + path.resolve('foo') + ); + } + }); + it('should report the correct product', async () => { + const {isChrome, isFirefox, puppeteer} = await getTestState({ + skipLaunch: true, + }); + if (isChrome) { + expect(puppeteer.product).toBe('chrome'); + } else if (isFirefox) { + expect(puppeteer.product).toBe('firefox'); + } + }); + (!isHeadless ? it : it.skip)( + 'should work with no default arguments', + async () => { + const {context, close} = await launch({ + ignoreDefaultArgs: true, + }); + try { + const page = await context.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + } finally { + await close(); + } + } + ); + it('should filter out ignored default arguments in Chrome', async () => { + const {defaultBrowserOptions, puppeteer} = await getTestState({ + skipLaunch: true, + }); + // Make sure we launch with `--enable-automation` by default. + const defaultArgs = puppeteer.defaultArgs(); + const {browser, close} = await launch( + Object.assign({}, defaultBrowserOptions, { + // Ignore first and third default argument. + ignoreDefaultArgs: [defaultArgs[0]!, defaultArgs[2]], + }) + ); + try { + const spawnargs = browser.process()!.spawnargs; + if (!spawnargs) { + throw new Error('spawnargs not present'); + } + expect(spawnargs.indexOf(defaultArgs[0]!)).toBe(-1); + expect(spawnargs.indexOf(defaultArgs[1]!)).not.toBe(-1); + expect(spawnargs.indexOf(defaultArgs[2]!)).toBe(-1); + } finally { + await close(); + } + }); + it('should filter out ignored default argument in Firefox', async () => { + const {defaultBrowserOptions, puppeteer} = await getTestState({ + skipLaunch: true, + }); + + const defaultArgs = puppeteer.defaultArgs(); + const {browser, close} = await launch( + Object.assign({}, defaultBrowserOptions, { + // Only the first argument is fixed, others are optional. + ignoreDefaultArgs: [defaultArgs[0]!], + }) + ); + try { + const spawnargs = browser.process()!.spawnargs; + if (!spawnargs) { + throw new Error('spawnargs not present'); + } + expect(spawnargs.indexOf(defaultArgs[0]!)).toBe(-1); + expect(spawnargs.indexOf(defaultArgs[1]!)).not.toBe(-1); + } finally { + await close(); + } + }); + it('should have default URL when launching browser', async function () { + const {browser, close} = await launch( + {}, + { + createContext: false, + } + ); + try { + const pages = (await browser.pages()).map(page => { + return page.url(); + }); + expect(pages).toEqual(['about:blank']); + } finally { + await close(); + } + }); + it('should have custom URL when launching browser', async () => { + const {server, defaultBrowserOptions} = await getTestState({ + skipLaunch: true, + }); + + const options = Object.assign({}, defaultBrowserOptions); + options.args = [server.EMPTY_PAGE].concat(options.args || []); + const {browser, close} = await launch(options, { + createContext: false, + }); + try { + const pages = await browser.pages(); + expect(pages).toHaveLength(1); + const page = pages[0]!; + if (page.url() !== server.EMPTY_PAGE) { + await page.waitForNavigation(); + } + expect(page.url()).toBe(server.EMPTY_PAGE); + } finally { + await close(); + } + }); + it('should pass the timeout parameter to browser.waitForTarget', async () => { + const options = { + timeout: 1, + }; + let error!: Error; + await launch(options).catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should work with timeout = 0', async () => { + const {close} = await launch({ + timeout: 0, + }); + await close(); + }); + it('should set the default viewport', async () => { + const {context, close} = await launch({ + defaultViewport: { + width: 456, + height: 789, + }, + }); + + try { + const page = await context.newPage(); + expect(await page.evaluate('window.innerWidth')).toBe(456); + expect(await page.evaluate('window.innerHeight')).toBe(789); + } finally { + await close(); + } + }); + it('should disable the default viewport', async () => { + const {context, close} = await launch({ + defaultViewport: null, + }); + try { + const page = await context.newPage(); + expect(page.viewport()).toBe(null); + } finally { + await close(); + } + }); + it('should take fullPage screenshots when defaultViewport is null', async () => { + const {server, context, close} = await launch({ + defaultViewport: null, + }); + try { + const page = await context.newPage(); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + fullPage: true, + }); + expect(screenshot).toBeInstanceOf(Buffer); + } finally { + await close(); + } + }); + it('should set the debugging port', async () => { + const {browser, close} = await launch({ + defaultViewport: null, + debuggingPort: 9999, + }); + try { + const url = new URL(browser.wsEndpoint()); + expect(url.port).toBe('9999'); + } finally { + await close(); + } + }); + it('should not allow setting debuggingPort and pipe', async () => { + const options = { + defaultViewport: null, + debuggingPort: 9999, + pipe: true, + }; + let error!: Error; + await launch(options).catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('either pipe or debugging port'); + }); + (!isHeadless ? it : it.skip)( + 'should launch Chrome properly with --no-startup-window and waitForInitialPage=false', + async () => { + const {defaultBrowserOptions} = await getTestState({ + skipLaunch: true, + }); + const options = { + waitForInitialPage: false, + // This is needed to prevent Puppeteer from adding an initial blank page. + // See also https://github.com/puppeteer/puppeteer/blob/ad6b736039436fcc5c0a262e5b575aa041427be3/src/node/Launcher.ts#L200 + ignoreDefaultArgs: true, + ...defaultBrowserOptions, + args: ['--no-startup-window'], + }; + const {browser, close} = await launch(options, { + createContext: false, + }); + try { + const pages = await browser.pages(); + expect(pages).toHaveLength(0); + } finally { + await close(); + } + } + ); + }); + + describe('Puppeteer.launch', function () { + it('should be able to launch Chrome', async () => { + const {browser, close} = await launch({product: 'chrome'}); + try { + const userAgent = await browser.userAgent(); + expect(userAgent).toContain('Chrome'); + } finally { + await close(); + } + }); + + it('should be able to launch Firefox', async function () { + this.timeout(FIREFOX_TIMEOUT); + const {browser, close} = await launch({product: 'firefox'}); + try { + const userAgent = await browser.userAgent(); + expect(userAgent).toContain('Firefox'); + } finally { + await close(); + } + }); + }); + + describe('Puppeteer.connect', function () { + it('should be able to connect multiple times to the same browser', async () => { + const {puppeteer, browser, close} = await launch({}); + try { + const otherBrowser = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + protocol: browser.protocol, + }); + const page = await otherBrowser.newPage(); + expect( + await page.evaluate(() => { + return 7 * 8; + }) + ).toBe(56); + await otherBrowser.disconnect(); + + const secondPage = await browser.newPage(); + expect( + await secondPage.evaluate(() => { + return 7 * 6; + }) + ).toBe(42); + } finally { + await close(); + } + }); + it('should be able to close remote browser', async () => { + const {puppeteer, browser, close} = await launch({}); + try { + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + protocol: browser.protocol, + }); + await Promise.all([ + waitEvent(browser, 'disconnected'), + remoteBrowser.close(), + ]); + } finally { + await close(); + } + }); + it('should be able to connect to a browser with no page targets', async () => { + const {puppeteer, browser, close} = await launch({}); + + try { + const pages = await browser.pages(); + await Promise.all( + pages.map(page => { + return page.close(); + }) + ); + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + protocol: browser.protocol, + }); + await Promise.all([ + waitEvent(browser, 'disconnected'), + remoteBrowser.close(), + ]); + } finally { + await close(); + } + }); + it('should support ignoreHTTPSErrors option', async () => { + const {puppeteer, httpsServer, browser, close} = await launch( + {}, + { + createContext: false, + } + ); + + try { + const browserWSEndpoint = browser.wsEndpoint(); + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint, + ignoreHTTPSErrors: true, + protocol: browser.protocol, + }); + const page = await remoteBrowser.newPage(); + const [serverRequest, response] = await Promise.all([ + httpsServer.waitForRequest('/empty.html'), + page.goto(httpsServer.EMPTY_PAGE), + ]); + expect(response!.ok()).toBe(true); + expect(response!.securityDetails()).toBeTruthy(); + const protocol = (serverRequest.socket as TLSSocket) + .getProtocol()! + .replace('v', ' '); + expect(response!.securityDetails()!.protocol()).toBe(protocol); + await page.close(); + await remoteBrowser.close(); + } finally { + await close(); + } + }); + + it('should support targetFilter option in puppeteer.launch', async () => { + const {browser, close} = await launch( + { + targetFilter: target => { + return target.type() !== 'page'; + }, + waitForInitialPage: false, + }, + {createContext: false} + ); + try { + const targets = browser.targets(); + expect(targets).toHaveLength(1); + expect( + targets.find(target => { + return target.type() === 'page'; + }) + ).toBeUndefined(); + } finally { + await close(); + } + }); + + // @see https://github.com/puppeteer/puppeteer/issues/4197 + it('should support targetFilter option', async () => { + const {puppeteer, server, browser, close} = await launch( + {}, + { + createContext: false, + } + ); + try { + const browserWSEndpoint = browser.wsEndpoint(); + const page1 = await browser.newPage(); + await page1.goto(server.EMPTY_PAGE); + + const page2 = await browser.newPage(); + await page2.goto(server.EMPTY_PAGE + '?should-be-ignored'); + + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint, + targetFilter: target => { + return !target.url().includes('should-be-ignored'); + }, + protocol: browser.protocol, + }); + + const pages = await remoteBrowser.pages(); + + expect( + pages + .map((p: Page) => { + return p.url(); + }) + .sort() + ).toEqual(['about:blank', server.EMPTY_PAGE]); + + await page2.close(); + await page1.close(); + await remoteBrowser.disconnect(); + await browser.close(); + } finally { + await close(); + } + }); + it('should be able to reconnect to a disconnected browser', async () => { + const {puppeteer, server, browser, close} = await launch({}); + try { + const browserWSEndpoint = browser.wsEndpoint(); + const page = await browser.newPage(); + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + await browser.disconnect(); + + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint, + protocol: browser.protocol, + }); + const pages = await remoteBrowser.pages(); + const restoredPage = pages.find(page => { + return page.url() === server.PREFIX + '/frames/nested-frames.html'; + })!; + expect(dumpFrames(restoredPage.mainFrame())).toEqual([ + 'http://localhost:<PORT>/frames/nested-frames.html', + ' http://localhost:<PORT>/frames/two-frames.html (2frames)', + ' http://localhost:<PORT>/frames/frame.html (uno)', + ' http://localhost:<PORT>/frames/frame.html (dos)', + ' http://localhost:<PORT>/frames/frame.html (aframe)', + ]); + expect( + await restoredPage.evaluate(() => { + return 7 * 8; + }) + ).toBe(56); + await remoteBrowser.close(); + } finally { + await close(); + } + }); + // @see https://github.com/puppeteer/puppeteer/issues/4197#issuecomment-481793410 + it('should be able to connect to the same page simultaneously', async () => { + const {puppeteer, browser: browserOne, close} = await launch({}); + + try { + const browserTwo = await puppeteer.connect({ + browserWSEndpoint: browserOne.wsEndpoint(), + protocol: browserOne.protocol, + }); + const [page1, page2] = await Promise.all([ + new Promise<Page | null>(x => { + return browserOne.once('targetcreated', target => { + x(target.page()); + }); + }), + browserTwo.newPage(), + ]); + assert(page1); + expect( + await page1.evaluate(() => { + return 7 * 8; + }) + ).toBe(56); + expect( + await page2.evaluate(() => { + return 7 * 6; + }) + ).toBe(42); + } finally { + await close(); + } + }); + it('should be able to reconnect', async () => { + const { + puppeteer, + server, + browser: browserOne, + close, + } = await launch({}); + try { + const browserWSEndpoint = browserOne.wsEndpoint(); + const pageOne = await browserOne.newPage(); + await pageOne.goto(server.EMPTY_PAGE); + await browserOne.disconnect(); + + const browserTwo = await puppeteer.connect({ + browserWSEndpoint, + protocol: browserOne.protocol, + }); + const pages = await browserTwo.pages(); + const pageTwo = pages.find(page => { + return page.url() === server.EMPTY_PAGE; + })!; + await pageTwo.reload(); + using _ = await pageTwo.waitForSelector('body', { + timeout: 10000, + }); + await browserTwo.close(); + } finally { + await close(); + } + }); + }); + describe('Puppeteer.executablePath', function () { + it('should work', async () => { + const {puppeteer} = await getTestState({ + skipLaunch: true, + }); + + const executablePath = puppeteer.executablePath(); + expect(fs.existsSync(executablePath)).toBe(true); + expect(fs.realpathSync(executablePath)).toBe(executablePath); + }); + it('returns executablePath for channel', async () => { + const {puppeteer} = await getTestState({ + skipLaunch: true, + }); + + const executablePath = puppeteer.executablePath('chrome'); + expect(executablePath).toBeTruthy(); + }); + describe('when executable path is configured', () => { + const sandbox = sinon.createSandbox(); + + beforeEach(async () => { + const {puppeteer} = await getTestState({ + skipLaunch: true, + }); + sandbox + .stub(puppeteer.configuration, 'executablePath') + .value('SOME_CUSTOM_EXECUTABLE'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('its value is used', async () => { + const {puppeteer} = await getTestState({ + skipLaunch: true, + }); + try { + puppeteer.executablePath(); + } catch (error) { + expect((error as Error).message).toContain( + 'SOME_CUSTOM_EXECUTABLE' + ); + } + }); + }); + }); + }); + + describe('Browser target events', function () { + it('should work', async () => { + const {browser, server, close} = await launch({}); + + try { + const events: string[] = []; + browser.on('targetcreated', () => { + events.push('CREATED'); + }); + browser.on('targetchanged', () => { + events.push('CHANGED'); + }); + browser.on('targetdestroyed', () => { + events.push('DESTROYED'); + }); + const page = await browser.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.close(); + expect(events).toEqual(['CREATED', 'CHANGED', 'DESTROYED']); + } finally { + await close(); + } + }); + }); + + describe('Browser.Events.disconnected', function () { + it('should be emitted when: browser gets closed, disconnected or underlying websocket gets closed', async () => { + const {puppeteer, browser, close} = await launch({}); + try { + const browserWSEndpoint = browser.wsEndpoint(); + const remoteBrowser1 = await puppeteer.connect({ + browserWSEndpoint, + protocol: browser.protocol, + }); + const remoteBrowser2 = await puppeteer.connect({ + browserWSEndpoint, + protocol: browser.protocol, + }); + + let disconnectedOriginal = 0; + let disconnectedRemote1 = 0; + let disconnectedRemote2 = 0; + browser.on('disconnected', () => { + ++disconnectedOriginal; + }); + remoteBrowser1.on('disconnected', () => { + ++disconnectedRemote1; + }); + remoteBrowser2.on('disconnected', () => { + ++disconnectedRemote2; + }); + + await Promise.all([ + waitEvent(remoteBrowser2, 'disconnected'), + remoteBrowser2.disconnect(), + ]); + + expect(disconnectedOriginal).toBe(0); + expect(disconnectedRemote1).toBe(0); + expect(disconnectedRemote2).toBe(1); + + await Promise.all([ + waitEvent(remoteBrowser1, 'disconnected'), + waitEvent(browser, 'disconnected'), + browser.close(), + ]); + + expect(disconnectedOriginal).toBe(1); + expect(disconnectedRemote1).toBe(1); + expect(disconnectedRemote2).toBe(1); + } finally { + await close(); + } + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/locator.spec.ts b/remote/test/puppeteer/test/src/locator.spec.ts new file mode 100644 index 0000000000..9b00cc2d7c --- /dev/null +++ b/remote/test/puppeteer/test/src/locator.spec.ts @@ -0,0 +1,763 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {TimeoutError} from 'puppeteer-core'; +import { + Locator, + LocatorEvent, +} from 'puppeteer-core/internal/api/locators/locators.js'; +import sinon from 'sinon'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('Locator', function () { + setupTestBrowserHooks(); + + it('should work with a frame', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button onclick="this.innerText = 'clicked';">test</button> + `); + let willClick = false; + await page + .mainFrame() + .locator('button') + .on(LocatorEvent.Action, () => { + willClick = true; + }) + .click(); + using button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + expect(willClick).toBe(true); + }); + + it('should work without preconditions', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button onclick="this.innerText = 'clicked';">test</button> + `); + let willClick = false; + await page + .locator('button') + .setEnsureElementIsInTheViewport(false) + .setTimeout(0) + .setVisibility(null) + .setWaitForEnabled(false) + .setWaitForStableBoundingBox(false) + .on(LocatorEvent.Action, () => { + willClick = true; + }) + .click(); + using button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + expect(willClick).toBe(true); + }); + + describe('Locator.click', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button onclick="this.innerText = 'clicked';">test</button> + `); + let willClick = false; + await page + .locator('button') + .on(LocatorEvent.Action, () => { + willClick = true; + }) + .click(); + using button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + expect(willClick).toBe(true); + }); + + it('should work for multiple selectors', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button onclick="this.innerText = 'clicked';">test</button> + `); + let clicked = false; + await page + .locator('::-p-text(test), ::-p-xpath(/button)') + .on(LocatorEvent.Action, () => { + clicked = true; + }) + .click(); + using button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + expect(clicked).toBe(true); + }); + + it('should work if the element is out of viewport', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button style="margin-top: 600px;" onclick="this.innerText = 'clicked';">test</button> + `); + await page.locator('button').click(); + using button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + }); + + it('should work if the element becomes visible later', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button style="display: none;" onclick="this.innerText = 'clicked';">test</button> + `); + using button = await page.$('button'); + const result = page + .locator('button') + .click() + .catch(err => { + return err; + }); + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('test'); + await button?.evaluate(el => { + el.style.display = 'block'; + }); + const maybeError = await result; + if (maybeError instanceof Error) { + throw maybeError; + } + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('clicked'); + }); + + it('should work if the element becomes enabled later', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button disabled onclick="this.innerText = 'clicked';">test</button> + `); + using button = await page.$('button'); + const result = page.locator('button').click(); + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('test'); + await button?.evaluate(el => { + el.disabled = false; + }); + await result; + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('clicked'); + }); + + it('should work if multiple conditions are satisfied later', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button style="margin-top: 600px;" style="display: none;" disabled onclick="this.innerText = 'clicked';">test</button> + `); + using button = await page.$('button'); + const result = page.locator('button').click(); + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('test'); + await button?.evaluate(el => { + el.disabled = false; + el.style.display = 'block'; + }); + await result; + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('clicked'); + }); + + it('should time out', async () => { + const clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + shouldAdvanceTime: true, + }); + try { + const {page} = await getTestState(); + + page.setDefaultTimeout(5000); + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button style="display: none;" onclick="this.innerText = 'clicked';">test</button> + `); + const result = page.locator('button').click(); + clock.tick(5100); + await expect(result).rejects.toEqual( + new TimeoutError('Timed out after waiting 5000ms') + ); + } finally { + clock.restore(); + } + }); + + it('should retry clicks on errors', async () => { + const {page} = await getTestState(); + const clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + shouldAdvanceTime: true, + }); + try { + page.setDefaultTimeout(5000); + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button style="display: none;" onclick="this.innerText = 'clicked';">test</button> + `); + const result = page.locator('button').click(); + clock.tick(5100); + await expect(result).rejects.toEqual( + new TimeoutError('Timed out after waiting 5000ms') + ); + } finally { + clock.restore(); + } + }); + + it('can be aborted', async () => { + const {page} = await getTestState(); + const clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + shouldAdvanceTime: true, + }); + try { + page.setDefaultTimeout(5000); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button style="display: none;" onclick="this.innerText = 'clicked';">test</button> + `); + const abortController = new AbortController(); + const result = page.locator('button').click({ + signal: abortController.signal, + }); + clock.tick(2000); + abortController.abort(); + await expect(result).rejects.toThrow(/aborted/); + } finally { + clock.restore(); + } + }); + + it('should work with a OOPIF', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <iframe src="data:text/html,<button onclick="this.innerText = 'clicked';">test</button>"></iframe> + `); + const frame = await page.waitForFrame(frame => { + return frame.url().startsWith('data'); + }); + let willClick = false; + await frame + .locator('button') + .on(LocatorEvent.Action, () => { + willClick = true; + }) + .click(); + using button = await frame.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + expect(willClick).toBe(true); + }); + }); + + describe('Locator.hover', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button onmouseenter="this.innerText = 'hovered';">test</button> + `); + let hovered = false; + await page + .locator('button') + .on(LocatorEvent.Action, () => { + hovered = true; + }) + .hover(); + using button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('hovered'); + expect(hovered).toBe(true); + }); + }); + + describe('Locator.scroll', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <div style="height: 500px; width: 500px; overflow: scroll;"> + <div style="height: 1000px; width: 1000px;">test</div> + </div> + `); + let scrolled = false; + await page + .locator('div') + .on(LocatorEvent.Action, () => { + scrolled = true; + }) + .scroll({ + scrollTop: 500, + scrollLeft: 500, + }); + using scrollable = await page.$('div'); + const scroll = await scrollable?.evaluate(el => { + return el.scrollTop + ' ' + el.scrollLeft; + }); + expect(scroll).toBe('500 500'); + expect(scrolled).toBe(true); + }); + }); + + describe('Locator.fill', function () { + it('should work for textarea', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <textarea></textarea> + `); + let filled = false; + await page + .locator('textarea') + .on(LocatorEvent.Action, () => { + filled = true; + }) + .fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('textarea')?.value === 'test'; + }) + ).toBe(true); + expect(filled).toBe(true); + }); + + it('should work for selects', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <select> + <option value="value1">Option 1</option> + <option value="value2">Option 2</option> + <select> + `); + let filled = false; + await page + .locator('select') + .on(LocatorEvent.Action, () => { + filled = true; + }) + .fill('value2'); + expect( + await page.evaluate(() => { + return document.querySelector('select')?.value === 'value2'; + }) + ).toBe(true); + expect(filled).toBe(true); + }); + + it('should work for inputs', async () => { + const {page} = await getTestState(); + await page.setContent(` + <input> + `); + await page.locator('input').fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('input')?.value === 'test'; + }) + ).toBe(true); + }); + + it('should work if the input becomes enabled later', async () => { + const {page} = await getTestState(); + + await page.setContent(` + <input disabled> + `); + using input = await page.$('input'); + const result = page.locator('input').fill('test'); + expect( + await input?.evaluate(el => { + return el.value; + }) + ).toBe(''); + await input?.evaluate(el => { + el.disabled = false; + }); + await result; + expect( + await input?.evaluate(el => { + return el.value; + }) + ).toBe('test'); + }); + + it('should work for contenteditable', async () => { + const {page} = await getTestState(); + await page.setContent(` + <div contenteditable="true"> + `); + await page.locator('div').fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('div')?.innerText === 'test'; + }) + ).toBe(true); + }); + + it('should work for pre-filled inputs', async () => { + const {page} = await getTestState(); + await page.setContent(` + <input value="te"> + `); + await page.locator('input').fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('input')?.value === 'test'; + }) + ).toBe(true); + }); + + it('should override pre-filled inputs', async () => { + const {page} = await getTestState(); + await page.setContent(` + <input value="wrong prefix"> + `); + await page.locator('input').fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('input')?.value === 'test'; + }) + ).toBe(true); + }); + + it('should work for non-text inputs', async () => { + const {page} = await getTestState(); + await page.setContent(` + <input type="color"> + `); + await page.locator('input').fill('#333333'); + expect( + await page.evaluate(() => { + return document.querySelector('input')?.value === '#333333'; + }) + ).toBe(true); + }); + }); + + describe('Locator.race', () => { + it('races multiple locators', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button onclick="window.count++;">test</button> + `); + await page.evaluate(() => { + // @ts-expect-error different context. + window.count = 0; + }); + await Locator.race([ + page.locator('button'), + page.locator('button'), + ]).click(); + const count = await page.evaluate(() => { + // @ts-expect-error different context. + return globalThis.count; + }); + expect(count).toBe(1); + }); + + it('can be aborted', async () => { + const {page} = await getTestState(); + const clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + shouldAdvanceTime: true, + }); + try { + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + <button style="display: none;" onclick="this.innerText = 'clicked';">test</button> + `); + const abortController = new AbortController(); + const result = Locator.race([ + page.locator('button'), + page.locator('button'), + ]) + .setTimeout(5000) + .click({ + signal: abortController.signal, + }); + clock.tick(2000); + abortController.abort(); + await expect(result).rejects.toThrow(/aborted/); + } finally { + clock.restore(); + } + }); + + it('should time out when all locators do not match', async () => { + const clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + shouldAdvanceTime: true, + }); + try { + const {page} = await getTestState(); + await page.setContent(`<button>test</button>`); + const result = Locator.race([ + page.locator('not-found'), + page.locator('not-found'), + ]) + .setTimeout(5000) + .click(); + clock.tick(5100); + await expect(result).rejects.toEqual( + new TimeoutError('Timed out after waiting 5000ms') + ); + } finally { + clock.restore(); + } + }); + + it('should not time out when one of the locators matches', async () => { + const {page} = await getTestState(); + await page.setContent(`<button>test</button>`); + const result = Locator.race([ + page.locator('not-found'), + page.locator('button'), + ]).click(); + await expect(result).resolves.toEqual(undefined); + }); + }); + + describe('Locator.prototype.map', () => { + it('should work', async () => { + const {page} = await getTestState(); + await page.setContent(`<div>test</div>`); + await expect( + page + .locator('::-p-text(test)') + .map(element => { + return element.getAttribute('clickable'); + }) + .wait() + ).resolves.toEqual(null); + await page.evaluate(() => { + document.querySelector('div')?.setAttribute('clickable', 'true'); + }); + await expect( + page + .locator('::-p-text(test)') + .map(element => { + return element.getAttribute('clickable'); + }) + .wait() + ).resolves.toEqual('true'); + }); + it('should work with throws', async () => { + const {page} = await getTestState(); + await page.setContent(`<div>test</div>`); + const result = page + .locator('::-p-text(test)') + .map(element => { + const clickable = element.getAttribute('clickable'); + if (!clickable) { + throw new Error('Missing `clickable` as an attribute'); + } + return clickable; + }) + .wait(); + await page.evaluate(() => { + document.querySelector('div')?.setAttribute('clickable', 'true'); + }); + await expect(result).resolves.toEqual('true'); + }); + it('should work with expect', async () => { + const {page} = await getTestState(); + await page.setContent(`<div>test</div>`); + const result = page + .locator('::-p-text(test)') + .filter(element => { + return element.getAttribute('clickable') !== null; + }) + .map(element => { + return element.getAttribute('clickable'); + }) + .wait(); + await page.evaluate(() => { + document.querySelector('div')?.setAttribute('clickable', 'true'); + }); + await expect(result).resolves.toEqual('true'); + }); + }); + + describe('Locator.prototype.filter', () => { + it('should resolve as soon as the predicate matches', async () => { + const clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + shouldAdvanceTime: true, + }); + try { + const {page} = await getTestState(); + await page.setContent(`<div>test</div>`); + const result = page + .locator('::-p-text(test)') + .setTimeout(5000) + .filter(async element => { + return element.getAttribute('clickable') === 'true'; + }) + .filter(element => { + return element.getAttribute('clickable') === 'true'; + }) + .hover(); + clock.tick(2000); + await page.evaluate(() => { + document.querySelector('div')?.setAttribute('clickable', 'true'); + }); + clock.restore(); + await expect(result).resolves.toEqual(undefined); + } finally { + clock.restore(); + } + }); + }); + + describe('Locator.prototype.wait', () => { + it('should work', async () => { + const {page} = await getTestState(); + void page.setContent(` + <script> + setTimeout(() => { + const element = document.createElement("div"); + element.innerText = "test2" + document.body.append(element); + }, 50); + </script> + `); + // This shouldn't throw. + await page.locator('div').wait(); + }); + }); + + describe('Locator.prototype.waitHandle', () => { + it('should work', async () => { + const {page} = await getTestState(); + void page.setContent(` + <script> + setTimeout(() => { + const element = document.createElement("div"); + element.innerText = "test2" + document.body.append(element); + }, 50); + </script> + `); + await expect(page.locator('div').waitHandle()).resolves.toBeDefined(); + }); + }); + + describe('Locator.prototype.clone', () => { + it('should work', async () => { + const {page} = await getTestState(); + const locator = page.locator('div'); + const clone = locator.clone(); + expect(locator).not.toStrictEqual(clone); + }); + it('should work internally with delegated locators', async () => { + const {page} = await getTestState(); + const locator = page.locator('div'); + const delegatedLocators = [ + locator.map(div => { + return div.textContent; + }), + locator.filter(div => { + return div.textContent?.length === 0; + }), + ]; + for (let delegatedLocator of delegatedLocators) { + delegatedLocator = delegatedLocator.setTimeout(500); + expect(delegatedLocator.timeout).not.toStrictEqual(locator.timeout); + } + }); + }); + + describe('FunctionLocator', () => { + it('should work', async () => { + const {page} = await getTestState(); + const result = page + .locator(() => { + return new Promise<boolean>(resolve => { + return setTimeout(() => { + return resolve(true); + }, 100); + }); + }) + .wait(); + await expect(result).resolves.toEqual(true); + }); + it('should work with actions', async () => { + const {page} = await getTestState(); + await page.setContent(`<div onclick="window.clicked = true">test</div>`); + await page + .locator(() => { + return document.getElementsByTagName('div')[0] as HTMLDivElement; + }) + .click(); + await expect( + page.evaluate(() => { + return (window as unknown as {clicked: boolean}).clicked; + }) + ).resolves.toEqual(true); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/mocha-utils.ts b/remote/test/puppeteer/test/src/mocha-utils.ts new file mode 100644 index 0000000000..3fff9c9930 --- /dev/null +++ b/remote/test/puppeteer/test/src/mocha-utils.ts @@ -0,0 +1,507 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; + +import {TestServer} from '@pptr/testserver'; +import type {Protocol} from 'devtools-protocol'; +import expect from 'expect'; +import type * as MochaBase from 'mocha'; +import puppeteer from 'puppeteer/lib/cjs/puppeteer/puppeteer.js'; +import type {Browser} from 'puppeteer-core/internal/api/Browser.js'; +import type {BrowserContext} from 'puppeteer-core/internal/api/BrowserContext.js'; +import type {Page} from 'puppeteer-core/internal/api/Page.js'; +import type { + PuppeteerLaunchOptions, + PuppeteerNode, +} from 'puppeteer-core/internal/node/PuppeteerNode.js'; +import {rmSync} from 'puppeteer-core/internal/node/util/fs.js'; +import {Deferred} from 'puppeteer-core/internal/util/Deferred.js'; +import {isErrorLike} from 'puppeteer-core/internal/util/ErrorLike.js'; +import sinon from 'sinon'; + +import {extendExpectWithToBeGolden} from './utils.js'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Mocha { + export interface SuiteFunction { + /** + * Use it if you want to capture debug logs for a specitic test suite in CI. + * This describe function enables capturing of debug logs and would print them + * only if a test fails to reduce the amount of output. + */ + withDebugLogs: ( + description: string, + body: (this: MochaBase.Suite) => void + ) => void; + } + export interface TestFunction { + /* + * Use to rerun the test and capture logs for the failed attempts + * that way we don't push all the logs making it easier to read. + */ + deflake: ( + repeats: number, + title: string, + fn: MochaBase.AsyncFunc + ) => void; + /* + * Use to rerun a single test and capture logs for the failed attempts + */ + deflakeOnly: ( + repeats: number, + title: string, + fn: MochaBase.AsyncFunc + ) => void; + } + } +} + +const product = + process.env['PRODUCT'] || process.env['PUPPETEER_PRODUCT'] || 'chrome'; + +const headless = (process.env['HEADLESS'] || 'true').trim().toLowerCase() as + | 'true' + | 'false' + | 'new'; +export const isHeadless = headless === 'true' || headless === 'new'; +const isFirefox = product === 'firefox'; +const isChrome = product === 'chrome'; +const protocol = (process.env['PUPPETEER_PROTOCOL'] || 'cdp') as + | 'cdp' + | 'webDriverBiDi'; + +let extraLaunchOptions = {}; +try { + extraLaunchOptions = JSON.parse(process.env['EXTRA_LAUNCH_OPTIONS'] || '{}'); +} catch (error) { + if (isErrorLike(error)) { + console.warn( + `Error parsing EXTRA_LAUNCH_OPTIONS: ${error.message}. Skipping.` + ); + } else { + throw error; + } +} + +const defaultBrowserOptions = Object.assign( + { + handleSIGINT: true, + executablePath: process.env['BINARY'], + headless: headless === 'new' ? ('new' as const) : isHeadless, + dumpio: !!process.env['DUMPIO'], + protocol, + }, + extraLaunchOptions +); + +if (defaultBrowserOptions.executablePath) { + console.warn( + `WARN: running ${product} tests with ${defaultBrowserOptions.executablePath}` + ); +} else { + const executablePath = puppeteer.executablePath(); + if (!fs.existsSync(executablePath)) { + throw new Error( + `Browser is not downloaded at ${executablePath}. Run 'npm install' and try to re-run tests` + ); + } +} + +const processVariables: { + product: string; + headless: 'true' | 'false' | 'new'; + isHeadless: boolean; + isFirefox: boolean; + isChrome: boolean; + protocol: 'cdp' | 'webDriverBiDi'; + defaultBrowserOptions: PuppeteerLaunchOptions; +} = { + product, + headless, + isHeadless, + isFirefox, + isChrome, + protocol, + defaultBrowserOptions, +}; + +const setupServer = async () => { + const assetsPath = path.join(__dirname, '../assets'); + const cachedPath = path.join(__dirname, '../assets', 'cached'); + + const server = await TestServer.create(assetsPath); + const port = server.port; + server.enableHTTPCache(cachedPath); + server.PORT = port; + server.PREFIX = `http://localhost:${port}`; + server.CROSS_PROCESS_PREFIX = `http://127.0.0.1:${port}`; + server.EMPTY_PAGE = `http://localhost:${port}/empty.html`; + + const httpsServer = await TestServer.createHTTPS(assetsPath); + const httpsPort = httpsServer.port; + httpsServer.enableHTTPCache(cachedPath); + httpsServer.PORT = httpsPort; + httpsServer.PREFIX = `https://localhost:${httpsPort}`; + httpsServer.CROSS_PROCESS_PREFIX = `https://127.0.0.1:${httpsPort}`; + httpsServer.EMPTY_PAGE = `https://localhost:${httpsPort}/empty.html`; + + return {server, httpsServer}; +}; + +export const setupTestBrowserHooks = (): void => { + before(async function () { + try { + if (!state.browser) { + state.browser = await puppeteer.launch({ + ...processVariables.defaultBrowserOptions, + timeout: this.timeout() - 1_000, + }); + } + } catch (error) { + console.error(error); + // Intentionally empty as `getTestState` will throw + // if browser is not found + } + }); + + after(() => { + if (typeof gc !== 'undefined') { + gc(); + const memory = process.memoryUsage(); + console.log('Memory stats:'); + for (const key of Object.keys(memory)) { + console.log( + key, + // @ts-expect-error TS cannot the key type. + `${Math.round(((memory[key] / 1024 / 1024) * 100) / 100)} MB` + ); + } + } + }); +}; + +export const getTestState = async ( + options: { + skipLaunch?: boolean; + skipContextCreation?: boolean; + } = {} +): Promise<PuppeteerTestState> => { + const {skipLaunch = false, skipContextCreation = false} = options; + + state.defaultBrowserOptions = JSON.parse( + JSON.stringify(processVariables.defaultBrowserOptions) + ); + + state.server?.reset(); + state.httpsServer?.reset(); + + if (skipLaunch) { + return state as PuppeteerTestState; + } + + if (!state.browser) { + throw new Error('Browser was not set-up in time!'); + } + + if (state.context) { + await state.context.close(); + state.context = undefined; + state.page = undefined; + } + + if (!skipContextCreation) { + state.context = await state.browser!.createIncognitoBrowserContext(); + state.page = await state.context.newPage(); + } + return state as PuppeteerTestState; +}; + +const setupGoldenAssertions = (): void => { + const suffix = processVariables.product.toLowerCase(); + const GOLDEN_DIR = path.join(__dirname, `../golden-${suffix}`); + const OUTPUT_DIR = path.join(__dirname, `../output-${suffix}`); + if (fs.existsSync(OUTPUT_DIR)) { + rmSync(OUTPUT_DIR); + } + extendExpectWithToBeGolden(GOLDEN_DIR, OUTPUT_DIR); +}; + +setupGoldenAssertions(); + +export interface PuppeteerTestState { + browser: Browser; + context: BrowserContext; + page: Page; + puppeteer: PuppeteerNode; + defaultBrowserOptions: PuppeteerLaunchOptions; + server: TestServer; + httpsServer: TestServer; + isFirefox: boolean; + isChrome: boolean; + isHeadless: boolean; + headless: 'true' | 'false' | 'new'; + puppeteerPath: string; +} +const state: Partial<PuppeteerTestState> = {}; + +if ( + process.env['MOCHA_WORKER_ID'] === undefined || + process.env['MOCHA_WORKER_ID'] === '0' +) { + console.log( + `Running unit tests with: + -> product: ${processVariables.product} + -> binary: ${ + processVariables.defaultBrowserOptions.executablePath || + path.relative(process.cwd(), puppeteer.executablePath()) + } + -> mode: ${ + processVariables.isHeadless + ? processVariables.headless === 'new' + ? '--headless=new' + : '--headless' + : 'headful' + }` + ); +} + +const browserNotClosedError = new Error( + 'A manually launched browser was not closed!' +); + +export const mochaHooks = { + async beforeAll(): Promise<void> { + async function setUpDefaultState() { + const {server, httpsServer} = await setupServer(); + + state.puppeteer = puppeteer; + state.server = server; + state.httpsServer = httpsServer; + state.isFirefox = processVariables.isFirefox; + state.isChrome = processVariables.isChrome; + state.isHeadless = processVariables.isHeadless; + state.headless = processVariables.headless; + state.puppeteerPath = path.resolve( + path.join(__dirname, '..', '..', 'packages', 'puppeteer') + ); + } + + try { + await Deferred.race([ + setUpDefaultState(), + Deferred.create({ + message: `Failed in after Hook`, + timeout: (this as any).timeout() - 1000, + }), + ]); + } catch {} + }, + + async afterAll(): Promise<void> { + (this as any).timeout(0); + const lastTestFile = (this as any)?.test?.parent?.suites?.[0]?.file + ?.split('/') + ?.at(-1); + try { + await Promise.all([ + state.server?.stop(), + state.httpsServer?.stop(), + state.browser?.close(), + ]); + } catch (error) { + throw new Error( + `Closing defaults (HTTP TestServer, HTTPS TestServer, Browser ) failed in ${lastTestFile}}` + ); + } + if (browserCleanupsAfterAll.length > 0) { + await closeLaunched(browserCleanupsAfterAll)(); + throw new Error(`Browser was not closed in ${lastTestFile}`); + } + }, + + async afterEach(): Promise<void> { + if (browserCleanups.length > 0) { + (this as any).test.error(browserNotClosedError); + await Deferred.race([ + closeLaunched(browserCleanups)(), + Deferred.create({ + message: `Failed in after Hook`, + timeout: (this as any).timeout() - 1000, + }), + ]); + } + sinon.restore(); + }, +}; + +declare module 'expect' { + interface Matchers<R> { + atLeastOneToContain(expected: string[]): R; + } +} + +expect.extend({ + atLeastOneToContain: (actual: string, expected: string[]) => { + for (const test of expected) { + try { + expect(actual).toContain(test); + return { + pass: true, + message: () => { + return ''; + }, + }; + } catch (err) {} + } + + return { + pass: false, + message: () => { + return `"${actual}" didn't contain any of the strings ${JSON.stringify( + expected + )}`; + }, + }; + }, +}); + +export const expectCookieEquals = async ( + cookies: Protocol.Network.Cookie[], + expectedCookies: Array<Partial<Protocol.Network.Cookie>> +): Promise<void> => { + if (!processVariables.isChrome) { + // Only keep standard properties when testing on a browser other than Chrome. + expectedCookies = expectedCookies.map(cookie => { + return { + domain: cookie.domain, + expires: cookie.expires, + httpOnly: cookie.httpOnly, + name: cookie.name, + path: cookie.path, + secure: cookie.secure, + session: cookie.session, + size: cookie.size, + value: cookie.value, + }; + }); + } + + expect(cookies).toHaveLength(expectedCookies.length); + for (let i = 0; i < cookies.length; i++) { + expect(cookies[i]).toMatchObject(expectedCookies[i]!); + } +}; + +export const shortWaitForArrayToHaveAtLeastNElements = async ( + data: unknown[], + minLength: number, + attempts = 3, + timeout = 50 +): Promise<void> => { + for (let i = 0; i < attempts; i++) { + if (data.length >= minLength) { + break; + } + await new Promise(resolve => { + return setTimeout(resolve, timeout); + }); + } +}; + +export const createTimeout = <T>( + n: number, + value?: T +): Promise<T | undefined> => { + return new Promise(resolve => { + setTimeout(() => { + return resolve(value); + }, n); + }); +}; + +const browserCleanupsAfterAll: Array<() => Promise<void>> = []; +const browserCleanups: Array<() => Promise<void>> = []; + +const closeLaunched = (storage: Array<() => Promise<void>>) => { + return async () => { + let cleanup = storage.pop(); + try { + while (cleanup) { + await cleanup(); + cleanup = storage.pop(); + } + } catch (error) { + // If the browser was closed by other means, swallow the error + // and mark the browser as closed. + if ((error as Error)?.message.includes('Connection closed')) { + storage.splice(0, storage.length); + return; + } + + throw error; + } + }; +}; + +export const launch = async ( + launchOptions: Readonly<PuppeteerLaunchOptions>, + options: { + after?: 'each' | 'all'; + createContext?: boolean; + createPage?: boolean; + } = {} +): Promise< + PuppeteerTestState & { + close: () => Promise<void>; + } +> => { + const {after = 'each', createContext = true, createPage = true} = options; + const initState = await getTestState({ + skipLaunch: true, + }); + const cleanupStorage = + after === 'each' ? browserCleanups : browserCleanupsAfterAll; + try { + const browser = await puppeteer.launch({ + ...initState.defaultBrowserOptions, + ...launchOptions, + }); + cleanupStorage.push(() => { + return browser.close(); + }); + + let context: BrowserContext; + let page: Page; + if (createContext) { + context = await browser.createIncognitoBrowserContext(); + cleanupStorage.push(() => { + return context.close(); + }); + + if (createPage) { + page = await context.newPage(); + cleanupStorage.push(() => { + return page.close(); + }); + } + } + + return { + ...initState, + browser, + context: context!, + page: page!, + close: closeLaunched(cleanupStorage), + }; + } catch (error) { + await closeLaunched(cleanupStorage)(); + + throw error; + } +}; diff --git a/remote/test/puppeteer/test/src/mouse.spec.ts b/remote/test/puppeteer/test/src/mouse.spec.ts new file mode 100644 index 0000000000..69229eb147 --- /dev/null +++ b/remote/test/puppeteer/test/src/mouse.spec.ts @@ -0,0 +1,472 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import os from 'os'; + +import expect from 'expect'; +import {MouseButton} from 'puppeteer-core/internal/api/Input.js'; +import type {Page} from 'puppeteer-core/internal/api/Page.js'; +import type {KeyInput} from 'puppeteer-core/internal/common/USKeyboardLayout.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +interface ClickData { + type: string; + detail: number; + clientX: number; + clientY: number; + isTrusted: boolean; + button: number; + buttons: number; +} + +interface Dimensions { + x: number; + y: number; + width: number; + height: number; +} + +function dimensions(): Dimensions { + const rect = document.querySelector('textarea')!.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; +} + +describe('Mouse', function () { + setupTestBrowserHooks(); + + it('should click the document', async () => { + const {page} = await getTestState(); + + await page.evaluate(() => { + (globalThis as any).clickPromise = new Promise(resolve => { + document.addEventListener('click', event => { + resolve({ + type: event.type, + detail: event.detail, + clientX: event.clientX, + clientY: event.clientY, + isTrusted: event.isTrusted, + button: event.button, + }); + }); + }); + }); + await page.mouse.click(50, 60); + const event = await page.evaluate(() => { + return (globalThis as any).clickPromise; + }); + expect(event.type).toBe('click'); + expect(event.detail).toBe(1); + expect(event.clientX).toBe(50); + expect(event.clientY).toBe(60); + expect(event.isTrusted).toBe(true); + expect(event.button).toBe(0); + }); + it('should resize the textarea', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + const {x, y, width, height} = await page.evaluate(dimensions); + const mouse = page.mouse; + await mouse.move(x + width - 4, y + height - 4); + await mouse.down(); + await mouse.move(x + width + 100, y + height + 100); + await mouse.up(); + const newDimensions = await page.evaluate(dimensions); + expect(newDimensions.width).toBe(Math.round(width + 104)); + expect(newDimensions.height).toBe(Math.round(height + 104)); + }); + it('should select the text with mouse', async () => { + const {page, server} = await getTestState(); + + const text = + "This is the text that we are going to try to select. Let's see how it goes."; + + await page.goto(`${server.PREFIX}/input/textarea.html`); + await page.focus('textarea'); + await page.keyboard.type(text); + using handle = await page + .locator('textarea') + .filterHandle(async element => { + return await element.evaluate((element, text) => { + return element.value === text; + }, text); + }) + .waitHandle(); + const {x, y} = await page.evaluate(dimensions); + await page.mouse.move(x + 2, y + 2); + await page.mouse.down(); + await page.mouse.move(100, 100); + await page.mouse.up(); + expect( + await handle.evaluate(element => { + return element.value.substring( + element.selectionStart, + element.selectionEnd + ); + }) + ).toBe(text); + }); + it('should trigger hover state', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.hover('#button-6'); + expect( + await page.evaluate(() => { + return document.querySelector('button:hover')!.id; + }) + ).toBe('button-6'); + await page.hover('#button-2'); + expect( + await page.evaluate(() => { + return document.querySelector('button:hover')!.id; + }) + ).toBe('button-2'); + await page.hover('#button-91'); + expect( + await page.evaluate(() => { + return document.querySelector('button:hover')!.id; + }) + ).toBe('button-91'); + }); + it('should trigger hover state with removed window.Node', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.evaluate(() => { + // @ts-expect-error Expected. + return delete window.Node; + }); + await page.hover('#button-6'); + expect( + await page.evaluate(() => { + return document.querySelector('button:hover')!.id; + }) + ).toBe('button-6'); + }); + it('should set modifier keys on click', async () => { + const {page, server, isFirefox} = await getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.evaluate(() => { + return document.querySelector('#button-3')!.addEventListener( + 'mousedown', + e => { + return ((globalThis as any).lastEvent = e); + }, + true + ); + }); + const modifiers = new Map<KeyInput, string>([ + ['Shift', 'shiftKey'], + ['Control', 'ctrlKey'], + ['Alt', 'altKey'], + ['Meta', 'metaKey'], + ]); + // In Firefox, the Meta modifier only exists on Mac + if (isFirefox && os.platform() !== 'darwin') { + modifiers.delete('Meta'); + } + for (const [modifier, key] of modifiers) { + await page.keyboard.down(modifier); + await page.click('#button-3'); + if ( + !(await page.evaluate((mod: string) => { + return (globalThis as any).lastEvent[mod]; + }, key)) + ) { + throw new Error(key + ' should be true'); + } + await page.keyboard.up(modifier); + } + await page.click('#button-3'); + for (const [modifier, key] of modifiers) { + if ( + await page.evaluate((mod: string) => { + return (globalThis as any).lastEvent[mod]; + }, key) + ) { + throw new Error(modifiers.get(modifier) + ' should be false'); + } + } + }); + it('should send mouse wheel events', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/wheel.html'); + using elem = (await page.$('div'))!; + const boundingBoxBefore = (await elem.boundingBox())!; + expect(boundingBoxBefore).toMatchObject({ + width: 115, + height: 115, + }); + + await page.mouse.move( + boundingBoxBefore.x + boundingBoxBefore.width / 2, + boundingBoxBefore.y + boundingBoxBefore.height / 2 + ); + + await page.mouse.wheel({deltaY: -100}); + const boundingBoxAfter = await elem.boundingBox(); + expect(boundingBoxAfter).toMatchObject({ + width: 230, + height: 230, + }); + }); + it('should tween mouse movement', async () => { + const {page} = await getTestState(); + + await page.mouse.move(100, 100); + await page.evaluate(() => { + (globalThis as any).result = []; + document.addEventListener('mousemove', event => { + (globalThis as any).result.push([event.clientX, event.clientY]); + }); + }); + await page.mouse.move(200, 300, {steps: 5}); + expect(await page.evaluate('result')).toEqual([ + [120, 140], + [140, 180], + [160, 220], + [180, 260], + [200, 300], + ]); + }); + // @see https://crbug.com/929806 + it('should work with mobile viewports and cross process navigations', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setViewport({width: 360, height: 640, isMobile: true}); + await page.goto(server.CROSS_PROCESS_PREFIX + '/mobile.html'); + await page.evaluate(() => { + document.addEventListener('click', event => { + (globalThis as any).result = {x: event.clientX, y: event.clientY}; + }); + }); + + await page.mouse.click(30, 40); + + expect(await page.evaluate('result')).toEqual({x: 30, y: 40}); + }); + it('should not throw if buttons are pressed twice', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.mouse.down(); + await page.mouse.down(); + }); + + interface AddMouseDataListenersOptions { + includeMove?: boolean; + } + + const addMouseDataListeners = ( + page: Page, + options: AddMouseDataListenersOptions = {} + ) => { + return page.evaluate(({includeMove}) => { + const clicks: ClickData[] = []; + const mouseEventListener = (event: MouseEvent) => { + clicks.push({ + type: event.type, + detail: event.detail, + clientX: event.clientX, + clientY: event.clientY, + isTrusted: event.isTrusted, + button: event.button, + buttons: event.buttons, + }); + }; + document.addEventListener('mousedown', mouseEventListener); + if (includeMove) { + document.addEventListener('mousemove', mouseEventListener); + } + document.addEventListener('mouseup', mouseEventListener); + document.addEventListener('click', mouseEventListener); + document.addEventListener('auxclick', mouseEventListener); + (window as unknown as {clicks: ClickData[]}).clicks = clicks; + }, options); + }; + + it('should not throw if clicking in parallel', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await addMouseDataListeners(page); + + await Promise.all([page.mouse.click(0, 5), page.mouse.click(6, 10)]); + + const data = await page.evaluate(() => { + return (window as unknown as {clicks: ClickData[]}).clicks; + }); + const commonAttrs = { + isTrusted: true, + detail: 1, + clientY: 5, + clientX: 0, + button: 0, + }; + expect(data.splice(0, 3)).toMatchObject({ + 0: { + type: 'mousedown', + buttons: 1, + ...commonAttrs, + }, + 1: { + type: 'mouseup', + buttons: 0, + ...commonAttrs, + }, + 2: { + type: 'click', + buttons: 0, + ...commonAttrs, + }, + }); + Object.assign(commonAttrs, { + clientX: 6, + clientY: 10, + }); + expect(data).toMatchObject({ + 0: { + type: 'mousedown', + buttons: 1, + ...commonAttrs, + }, + 1: { + type: 'mouseup', + buttons: 0, + ...commonAttrs, + }, + 2: { + type: 'click', + buttons: 0, + ...commonAttrs, + }, + }); + }); + + it('should reset properly', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.mouse.move(5, 5); + await Promise.all([ + page.mouse.down({button: MouseButton.Left}), + page.mouse.down({button: MouseButton.Middle}), + page.mouse.down({button: MouseButton.Right}), + ]); + + await addMouseDataListeners(page, {includeMove: true}); + await page.mouse.reset(); + + const data = await page.evaluate(() => { + return (window as unknown as {clicks: ClickData[]}).clicks; + }); + const commonAttrs = { + isTrusted: true, + clientY: 5, + clientX: 5, + }; + + expect(data.slice(0, 2)).toMatchObject([ + { + ...commonAttrs, + button: 2, + buttons: 5, + detail: 1, + type: 'mouseup', + }, + { + ...commonAttrs, + button: 2, + buttons: 5, + detail: 1, + type: 'auxclick', + }, + ]); + // TODO(crbug/1485040): This should align with the firefox implementation. + if (isChrome) { + expect(data.slice(2)).toMatchObject([ + { + ...commonAttrs, + button: 1, + buttons: 1, + detail: 0, + type: 'mouseup', + }, + { + ...commonAttrs, + button: 0, + buttons: 0, + detail: 0, + type: 'mouseup', + }, + ]); + return; + } + expect(data.slice(2)).toMatchObject([ + { + ...commonAttrs, + button: 1, + buttons: 1, + detail: 1, + type: 'mouseup', + }, + { + ...commonAttrs, + button: 1, + buttons: 1, + detail: 1, + type: 'auxclick', + }, + { + ...commonAttrs, + button: 0, + buttons: 0, + detail: 1, + type: 'mouseup', + }, + { + ...commonAttrs, + button: 0, + buttons: 0, + detail: 1, + type: 'click', + }, + ]); + }); + + it('should evaluate before mouse event', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.goto(server.CROSS_PROCESS_PREFIX + '/input/button.html'); + + using button = await page.waitForSelector('button'); + + const point = await button!.clickablePoint(); + + const result = page.evaluate(() => { + return new Promise(resolve => { + document + .querySelector('button') + ?.addEventListener('click', resolve, {once: true}); + }); + }); + await page.mouse.click(point?.x, point?.y); + await result; + }); +}); diff --git a/remote/test/puppeteer/test/src/navigation.spec.ts b/remote/test/puppeteer/test/src/navigation.spec.ts new file mode 100644 index 0000000000..1f3a51f58a --- /dev/null +++ b/remote/test/puppeteer/test/src/navigation.spec.ts @@ -0,0 +1,918 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ServerResponse} from 'http'; + +import expect from 'expect'; +import {type Frame, TimeoutError} from 'puppeteer'; +import type {HTTPRequest} from 'puppeteer-core/internal/api/HTTPRequest.js'; +import type {HTTPResponse} from 'puppeteer-core/internal/api/HTTPResponse.js'; +import {Deferred} from 'puppeteer-core/internal/util/Deferred.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {attachFrame, isFavicon, waitEvent} from './utils.js'; + +describe('navigation', function () { + setupTestBrowserHooks(); + + describe('Page.goto', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + }); + it('should work with anchor navigation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + await page.goto(server.EMPTY_PAGE + '#foo'); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foo'); + await page.goto(server.EMPTY_PAGE + '#bar'); + expect(page.url()).toBe(server.EMPTY_PAGE + '#bar'); + }); + it('should work with redirects', async () => { + const {page, server} = await getTestState(); + + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/empty.html'); + await page.goto(server.PREFIX + '/redirect/1.html'); + expect(page.url()).toBe(server.EMPTY_PAGE); + }); + it('should navigate to about:blank', async () => { + const {page} = await getTestState(); + + const response = await page.goto('about:blank'); + expect(response).toBe(null); + }); + it('should return response when page changes its URL after load', async () => { + const {page, server} = await getTestState(); + + const response = await page.goto(server.PREFIX + '/historyapi.html'); + expect(response!.status()).toBe(200); + }); + it('should work with subframes return 204', async () => { + const {page, server} = await getTestState(); + + server.setRoute('/frames/frame.html', (_req, res) => { + res.statusCode = 204; + res.end(); + }); + let error!: Error; + await page + .goto(server.PREFIX + '/frames/one-frame.html') + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeUndefined(); + }); + it('should fail when server returns 204', async () => { + const {page, server, isChrome} = await getTestState(); + + server.setRoute('/empty.html', (_req, res) => { + res.statusCode = 204; + res.end(); + }); + let error!: Error; + await page.goto(server.EMPTY_PAGE).catch(error_ => { + return (error = error_); + }); + expect(error).not.toBe(null); + if (isChrome) { + expect(error.message).toContain('net::ERR_ABORTED'); + } else { + expect(error.message).toContain('NS_BINDING_ABORTED'); + } + }); + it('should navigate to empty page with domcontentloaded', async () => { + const {page, server} = await getTestState(); + + const response = await page.goto(server.EMPTY_PAGE, { + waitUntil: 'domcontentloaded', + }); + expect(response!.status()).toBe(200); + }); + it('should work when page calls history API in beforeunload', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + window.addEventListener( + 'beforeunload', + () => { + return history.replaceState(null, 'initial', window.location.href); + }, + false + ); + }); + const response = await page.goto(server.PREFIX + '/grid.html'); + expect(response!.status()).toBe(200); + }); + it('should navigate to empty page with networkidle0', async () => { + const {page, server} = await getTestState(); + + const response = await page.goto(server.EMPTY_PAGE, { + waitUntil: 'networkidle0', + }); + expect(response!.status()).toBe(200); + }); + it('should navigate to page with iframe and networkidle0', async () => { + const {page, server} = await getTestState(); + + const response = await page.goto( + server.PREFIX + '/frames/one-frame.html', + { + waitUntil: 'networkidle0', + } + ); + expect(response!.status()).toBe(200); + }); + it('should navigate to empty page with networkidle2', async () => { + const {page, server} = await getTestState(); + + const response = await page.goto(server.EMPTY_PAGE, { + waitUntil: 'networkidle2', + }); + expect(response!.status()).toBe(200); + }); + it('should fail when navigating to bad url', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.goto('asdfasdf').catch(error_ => { + return (error = error_); + }); + + expect(error.message).atLeastOneToContain([ + 'Cannot navigate to invalid URL', // Firefox WebDriver BiDi. + 'invalid argument', // Others. + ]); + }); + + const EXPECTED_SSL_CERT_MESSAGE_REGEX = + /net::ERR_CERT_INVALID|net::ERR_CERT_AUTHORITY_INVALID/; + + it('should fail when navigating to bad SSL', async () => { + const {page, httpsServer, isChrome} = await getTestState(); + + // Make sure that network events do not emit 'undefined'. + // @see https://crbug.com/750469 + const requests: string[] = []; + page.on('request', () => { + return requests.push('request'); + }); + page.on('requestfinished', () => { + return requests.push('requestfinished'); + }); + page.on('requestfailed', () => { + return requests.push('requestfailed'); + }); + + let error!: Error; + await page.goto(httpsServer.EMPTY_PAGE).catch(error_ => { + return (error = error_); + }); + if (isChrome) { + expect(error.message).toMatch(EXPECTED_SSL_CERT_MESSAGE_REGEX); + } else { + expect(error.message).toContain('SSL_ERROR_UNKNOWN'); + } + + expect(requests).toHaveLength(2); + expect(requests[0]).toBe('request'); + expect(requests[1]).toBe('requestfailed'); + }); + it('should fail when navigating to bad SSL after redirects', async () => { + const {page, server, httpsServer, isChrome} = await getTestState(); + + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/empty.html'); + let error!: Error; + await page.goto(httpsServer.PREFIX + '/redirect/1.html').catch(error_ => { + return (error = error_); + }); + if (isChrome) { + expect(error.message).toMatch(EXPECTED_SSL_CERT_MESSAGE_REGEX); + } else { + expect(error.message).atLeastOneToContain([ + 'MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT', // Firefox WebDriver BiDi. + 'SSL_ERROR_UNKNOWN ', // Others. + ]); + } + }); + it('should fail when main resources failed to load', async () => { + const {page, isChrome} = await getTestState(); + + let error!: Error; + await page + .goto('http://localhost:44123/non-existing-url') + .catch(error_ => { + return (error = error_); + }); + if (isChrome) { + expect(error.message).toContain('net::ERR_CONNECTION_REFUSED'); + } else { + expect(error.message).toContain('NS_ERROR_CONNECTION_REFUSED'); + } + }); + it('should fail when exceeding maximum navigation timeout', async () => { + const {page, server} = await getTestState(); + + // Hang for request to the empty.html + server.setRoute('/empty.html', () => {}); + let error!: Error; + await page + .goto(server.PREFIX + '/empty.html', {timeout: 1}) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('Navigation timeout of 1 ms exceeded'); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should fail when exceeding default maximum navigation timeout', async () => { + const {page, server} = await getTestState(); + + // Hang for request to the empty.html + server.setRoute('/empty.html', () => {}); + let error!: Error; + page.setDefaultNavigationTimeout(1); + await page.goto(server.PREFIX + '/empty.html').catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('Navigation timeout of 1 ms exceeded'); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should fail when exceeding default maximum timeout', async () => { + const {page, server} = await getTestState(); + + // Hang for request to the empty.html + server.setRoute('/empty.html', () => {}); + let error!: Error; + page.setDefaultTimeout(1); + await page.goto(server.PREFIX + '/empty.html').catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('Navigation timeout of 1 ms exceeded'); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should prioritize default navigation timeout over default timeout', async () => { + const {page, server} = await getTestState(); + + // Hang for request to the empty.html + server.setRoute('/empty.html', () => {}); + let error!: Error; + page.setDefaultTimeout(0); + page.setDefaultNavigationTimeout(1); + await page.goto(server.PREFIX + '/empty.html').catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('Navigation timeout of 1 ms exceeded'); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should disable timeout when its set to 0', async () => { + const {page, server} = await getTestState(); + + let error!: Error; + let loaded = false; + page.once('load', () => { + loaded = true; + }); + await page + .goto(server.PREFIX + '/grid.html', {timeout: 0, waitUntil: ['load']}) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeUndefined(); + expect(loaded).toBe(true); + }); + it('should work when navigating to valid url', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.ok()).toBe(true); + }); + it('should work when navigating to data url', async () => { + const {page} = await getTestState(); + + const response = (await page.goto('data:text/html,hello'))!; + expect(response.ok()).toBe(true); + }); + it('should work when navigating to 404', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.PREFIX + '/not-found'))!; + expect(response.ok()).toBe(false); + expect(response.status()).toBe(404); + }); + it('should not throw an error for a 404 response with an empty body', async () => { + const {page, server} = await getTestState(); + + server.setRoute('/404-error', (_, res) => { + res.statusCode = 404; + res.end(); + }); + + const response = (await page.goto(server.PREFIX + '/404-error'))!; + expect(response.ok()).toBe(false); + expect(response.status()).toBe(404); + }); + it('should not throw an error for a 500 response with an empty body', async () => { + const {page, server} = await getTestState(); + + server.setRoute('/500-error', (_, res) => { + res.statusCode = 500; + res.end(); + }); + + const response = (await page.goto(server.PREFIX + '/500-error'))!; + expect(response.ok()).toBe(false); + expect(response.status()).toBe(500); + }); + it('should return last response in redirect chain', async () => { + const {page, server} = await getTestState(); + + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/redirect/3.html'); + server.setRedirect('/redirect/3.html', server.EMPTY_PAGE); + const response = (await page.goto(server.PREFIX + '/redirect/1.html'))!; + expect(response.ok()).toBe(true); + expect(response.url()).toBe(server.EMPTY_PAGE); + }); + it('should wait for network idle to succeed navigation', async () => { + const {page, server} = await getTestState(); + + let responses: ServerResponse[] = []; + // Hold on to a bunch of requests without answering. + server.setRoute('/fetch-request-a.js', (_req, res) => { + return responses.push(res); + }); + server.setRoute('/fetch-request-b.js', (_req, res) => { + return responses.push(res); + }); + server.setRoute('/fetch-request-c.js', (_req, res) => { + return responses.push(res); + }); + server.setRoute('/fetch-request-d.js', (_req, res) => { + return responses.push(res); + }); + const initialFetchResourcesRequested = Promise.all([ + server.waitForRequest('/fetch-request-a.js'), + server.waitForRequest('/fetch-request-b.js'), + server.waitForRequest('/fetch-request-c.js'), + ]).catch(() => { + // Ignore Error that arise from test server during hooks + }); + const secondFetchResourceRequested = server + .waitForRequest('/fetch-request-d.js') + .catch(() => { + // Ignore Error that arise from test server during hooks + }); + + // Track when the navigation gets completed. + let navigationFinished = false; + let navigationError: Error | undefined; + // Navigate to a page which loads immediately and then does a bunch of + // requests via javascript's fetch method. + const navigationPromise = page + .goto(server.PREFIX + '/networkidle.html', { + waitUntil: 'networkidle0', + }) + .then(response => { + navigationFinished = true; + return response; + }) + .catch(error => { + navigationError = error; + return null; + }); + + let afterNavigationError: Error | undefined; + const afterNavigationPromise = (async () => { + // Wait for the page's 'load' event. + await waitEvent(page, 'load'); + expect(navigationFinished).toBe(false); + + // Wait for the initial three resources to be requested. + await initialFetchResourcesRequested; + + // Expect navigation still to be not finished. + expect(navigationFinished).toBe(false); + + // Respond to initial requests. + for (const response of responses) { + response.statusCode = 404; + response.end(`File not found`); + } + + // Reset responses array + responses = []; + + // Wait for the second round to be requested. + await secondFetchResourceRequested; + // Expect navigation still to be not finished. + expect(navigationFinished).toBe(false); + + // Respond to requests. + for (const response of responses) { + response.statusCode = 404; + response.end(`File not found`); + } + })().catch(error => { + afterNavigationError = error; + }); + + await Promise.race([navigationPromise, afterNavigationPromise]); + if (navigationError) { + throw navigationError; + } + await Promise.all([navigationPromise, afterNavigationPromise]); + if (afterNavigationError) { + throw afterNavigationError; + } + // Expect navigation to succeed. + expect(navigationFinished).toBeTruthy(); + expect((await navigationPromise)?.ok()).toBe(true); + }); + it('should not leak listeners during navigation', async function () { + this.timeout(25_000); + + const {page, server} = await getTestState(); + + let warning = null; + const warningHandler: NodeJS.WarningListener = w => { + return (warning = w); + }; + process.on('warning', warningHandler); + for (let i = 0; i < 20; ++i) { + await page.goto(server.EMPTY_PAGE); + } + process.removeListener('warning', warningHandler); + expect(warning).toBe(null); + }); + it('should not leak listeners during bad navigation', async function () { + this.timeout(25_000); + + const {page} = await getTestState(); + + let warning = null; + const warningHandler: NodeJS.WarningListener = w => { + return (warning = w); + }; + process.on('warning', warningHandler); + for (let i = 0; i < 20; ++i) { + await page.goto('asdf').catch(() => { + /* swallow navigation error */ + }); + } + process.removeListener('warning', warningHandler); + expect(warning).toBe(null); + }); + it('should not leak listeners during navigation of 11 pages', async function () { + this.timeout(25_000); + + const {context, server} = await getTestState(); + + let warning = null; + const warningHandler: NodeJS.WarningListener = w => { + return (warning = w); + }; + process.on('warning', warningHandler); + await Promise.all( + [...Array(20)].map(async () => { + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.close(); + }) + ); + process.removeListener('warning', warningHandler); + expect(warning).toBe(null); + }); + it('should navigate to dataURL and fire dataURL requests', async () => { + const {page} = await getTestState(); + + const requests: HTTPRequest[] = []; + page.on('request', request => { + return !isFavicon(request) && requests.push(request); + }); + const dataURL = 'data:text/html,<div>yo</div>'; + const response = (await page.goto(dataURL))!; + expect(response.status()).toBe(200); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(dataURL); + }); + it('should navigate to URL with hash and fire requests without hash', async () => { + const {page, server} = await getTestState(); + + const requests: HTTPRequest[] = []; + page.on('request', request => { + return !isFavicon(request) && requests.push(request); + }); + const response = (await page.goto(server.EMPTY_PAGE + '#hash'))!; + expect(response.status()).toBe(200); + expect(response.url()).toBe(server.EMPTY_PAGE); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(server.EMPTY_PAGE); + }); + it('should work with self requesting page', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.PREFIX + '/self-request.html'))!; + expect(response.status()).toBe(200); + expect(response.url()).toContain('self-request.html'); + }); + it('should fail when navigating and show the url at the error message', async () => { + const {page, httpsServer} = await getTestState(); + + const url = httpsServer.PREFIX + '/redirect/1.html'; + let error!: Error; + try { + await page.goto(url); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toContain(url); + }); + it('should send referer', async () => { + const {page, server} = await getTestState(); + + const requests = Promise.all([ + server.waitForRequest('/grid.html'), + server.waitForRequest('/digits/1.png'), + page.goto(server.PREFIX + '/grid.html', { + referer: 'http://google.com/', + }), + ]).catch(() => { + return []; + }); + + const [request1, request2] = await requests; + expect(request1.headers['referer']).toBe('http://google.com/'); + // Make sure subresources do not inherit referer. + expect(request2.headers['referer']).toBe(server.PREFIX + '/grid.html'); + }); + + it('should send referer policy', async () => { + const {page, server} = await getTestState(); + + const [request1, request2] = await Promise.all([ + server.waitForRequest('/grid.html'), + server.waitForRequest('/digits/1.png'), + page.goto(server.PREFIX + '/grid.html', { + referrerPolicy: 'no-referer', + }), + ]).catch(() => { + return []; + }); + expect(request1.headers['referer']).toBeUndefined(); + expect(request2.headers['referer']).toBe(server.PREFIX + '/grid.html'); + }); + }); + + describe('Page.waitForNavigation', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.evaluate((url: string) => { + return (window.location.href = url); + }, server.PREFIX + '/grid.html'), + ]); + expect(response!.ok()).toBe(true); + expect(response!.url()).toContain('grid.html'); + }); + it('should work with both domcontentloaded and load', async () => { + const {page, server} = await getTestState(); + + let response!: ServerResponse; + server.setRoute('/one-style.css', (_req, res) => { + return (response = res); + }); + let error: Error | undefined; + let bothFired = false; + const navigationPromise = page + .goto(server.PREFIX + '/one-style.html') + .catch(_error => { + return (error = _error); + }); + const domContentLoadedPromise = page + .waitForNavigation({ + waitUntil: 'domcontentloaded', + }) + .catch(_error => { + return (error = _error); + }); + + const loadFiredPromise = page + .waitForNavigation({ + waitUntil: 'load', + }) + .then(() => { + return (bothFired = true); + }) + .catch(_error => { + return (error = _error); + }); + + await server.waitForRequest('/one-style.css').catch(() => {}); + await domContentLoadedPromise; + expect(bothFired).toBe(false); + response.end(); + await loadFiredPromise; + await navigationPromise; + expect(error).toBeUndefined(); + }); + it('should work with clicking on anchor links', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent(`<a href='#foobar'>foobar</a>`); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(response).toBe(null); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foobar'); + }); + it('should work with history.pushState()', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + <a onclick='javascript:pushState()'>SPA</a> + <script> + function pushState() { history.pushState({}, '', 'wow.html') } + </script> + `); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(response).toBe(null); + expect(page.url()).toBe(server.PREFIX + '/wow.html'); + }); + it('should work with history.replaceState()', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + <a onclick='javascript:replaceState()'>SPA</a> + <script> + function replaceState() { history.replaceState({}, '', '/replaced.html') } + </script> + `); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(response).toBe(null); + expect(page.url()).toBe(server.PREFIX + '/replaced.html'); + }); + it('should work with DOM history.back()/history.forward()', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + <a id=back onclick='javascript:goBack()'>back</a> + <a id=forward onclick='javascript:goForward()'>forward</a> + <script> + function goBack() { history.back(); } + function goForward() { history.forward(); } + history.pushState({}, '', '/first.html'); + history.pushState({}, '', '/second.html'); + </script> + `); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + const [backResponse] = await Promise.all([ + page.waitForNavigation(), + page.click('a#back'), + ]); + expect(backResponse).toBe(null); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + const [forwardResponse] = await Promise.all([ + page.waitForNavigation(), + page.click('a#forward'), + ]); + expect(forwardResponse).toBe(null); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + }); + it('should work when subframe issues window.stop()', async function () { + const {page, server} = await getTestState(); + + server.setRoute('/frames/style.css', () => {}); + let frame: Frame | undefined; + const eventPromises = Deferred.race([ + Promise.all([ + waitEvent(page, 'frameattached').then(_frame => { + return (frame = _frame); + }), + waitEvent(page, 'framenavigated', f => { + return f === frame; + }), + ]), + Deferred.create({ + message: `should work when subframe issues window.stop()`, + timeout: this.timeout() - 1000, + }), + ]); + const navigationPromise = page.goto( + server.PREFIX + '/frames/one-frame.html' + ); + try { + await eventPromises; + } catch (error) { + navigationPromise.catch(() => {}); + throw error; + } + await Promise.all([ + frame!.evaluate(() => { + return window.stop(); + }), + navigationPromise, + ]); + }); + }); + + describe('Page.goBack', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.goto(server.PREFIX + '/grid.html'); + + let response = (await page.goBack())!; + expect(response.ok()).toBe(true); + expect(response.url()).toContain(server.EMPTY_PAGE); + + response = (await page.goForward())!; + expect(response.ok()).toBe(true); + expect(response.url()).toContain('/grid.html'); + + response = (await page.goForward())!; + expect(response).toBe(null); + }); + it('should work with HistoryAPI', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + history.pushState({}, '', '/first.html'); + history.pushState({}, '', '/second.html'); + }); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + + await page.goBack(); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + await page.goBack(); + expect(page.url()).toBe(server.EMPTY_PAGE); + await page.goForward(); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + }); + }); + + describe('Frame.goto', function () { + it('should navigate subframes', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + expect(page.frames()[0]!.url()).toContain('/frames/one-frame.html'); + expect(page.frames()[1]!.url()).toContain('/frames/frame.html'); + + const response = (await page.frames()[1]!.goto(server.EMPTY_PAGE))!; + expect(response.ok()).toBe(true); + expect(response.frame()).toBe(page.frames()[1]); + }); + it('should reject when frame detaches', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + + server.setRoute('/empty.html', () => {}); + const navigationPromise = page + .frames()[1]! + .goto(server.EMPTY_PAGE) + .catch(error_ => { + return error_; + }); + await server.waitForRequest('/empty.html').catch(() => {}); + + await page.$eval('iframe', frame => { + return frame.remove(); + }); + const error = await navigationPromise; + expect(error.message).atLeastOneToContain([ + 'Navigating frame was detached', + 'Frame detached', + 'Error: NS_BINDING_ABORTED', + 'net::ERR_ABORTED', + ]); + }); + it('should return matching responses', async () => { + const {page, server} = await getTestState(); + + // Disable cache: otherwise, the browser will cache similar requests. + await page.setCacheEnabled(false); + await page.goto(server.EMPTY_PAGE); + // Attach three frames. + const frames = await Promise.all([ + attachFrame(page, 'frame1', server.EMPTY_PAGE), + attachFrame(page, 'frame2', server.EMPTY_PAGE), + attachFrame(page, 'frame3', server.EMPTY_PAGE), + ]); + // Navigate all frames to the same URL. + const serverResponses: ServerResponse[] = []; + server.setRoute('/one-style.html', (_req, res) => { + return serverResponses.push(res); + }); + const navigations: Array<Promise<HTTPResponse | null>> = []; + for (let i = 0; i < 3; ++i) { + navigations.push(frames[i]!.goto(server.PREFIX + '/one-style.html')); + await server.waitForRequest('/one-style.html'); + } + // Respond from server out-of-order. + const serverResponseTexts = ['AAA', 'BBB', 'CCC']; + try { + for (const i of [1, 2, 0]) { + const response = await getResponse(i); + expect(response.frame()).toBe(frames[i]); + expect(await response.text()).toBe(serverResponseTexts[i]); + } + } catch (error) { + await Promise.all([getResponse(0), getResponse(1), getResponse(2)]); + throw error; + } + + async function getResponse(index: number) { + serverResponses[index]!.end(serverResponseTexts[index]); + return (await navigations[index])!; + } + }); + }); + + describe('Frame.waitForNavigation', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + const frame = page.frames()[1]!; + const [response] = await Promise.all([ + frame.waitForNavigation(), + frame.evaluate((url: string) => { + return (window.location.href = url); + }, server.PREFIX + '/grid.html'), + ]); + expect(response!.ok()).toBe(true); + expect(response!.url()).toContain('grid.html'); + expect(response!.frame()).toBe(frame); + expect(page.url()).toContain('/frames/one-frame.html'); + }); + it('should fail when frame detaches', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + const frame = page.frames()[1]!; + + server.setRoute('/empty.html', () => {}); + let error!: Error; + const navigationPromise = frame.waitForNavigation().catch(error_ => { + return (error = error_); + }); + await Promise.all([ + server.waitForRequest('/empty.html'), + frame.evaluate(() => { + return ((window as any).location = '/empty.html'); + }), + ]); + await page.$eval('iframe', frame => { + return frame.remove(); + }); + await navigationPromise; + expect(error.message).atLeastOneToContain([ + 'Navigating frame was detached', + 'Frame detached', + ]); + }); + }); + + describe('Page.reload', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + return ((globalThis as any)._foo = 10); + }); + await page.reload(); + expect( + await page.evaluate(() => { + return (globalThis as any)._foo; + }) + ).toBe(undefined); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/network.spec.ts b/remote/test/puppeteer/test/src/network.spec.ts new file mode 100644 index 0000000000..c6f51a3412 --- /dev/null +++ b/remote/test/puppeteer/test/src/network.spec.ts @@ -0,0 +1,917 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import type {ServerResponse} from 'http'; +import path from 'path'; + +import expect from 'expect'; +import type {HTTPRequest} from 'puppeteer-core/internal/api/HTTPRequest.js'; +import type {HTTPResponse} from 'puppeteer-core/internal/api/HTTPResponse.js'; + +import {getTestState, launch, setupTestBrowserHooks} from './mocha-utils.js'; +import {attachFrame, isFavicon, waitEvent} from './utils.js'; + +describe('network', function () { + setupTestBrowserHooks(); + + describe('Page.Events.Request', function () { + it('should fire for navigation requests', async () => { + const {page, server} = await getTestState(); + + const requests: HTTPRequest[] = []; + page.on('request', request => { + return !isFavicon(request) && requests.push(request); + }); + await page.goto(server.EMPTY_PAGE); + expect(requests).toHaveLength(1); + }); + it('should fire for iframes', async () => { + const {page, server} = await getTestState(); + + const requests: HTTPRequest[] = []; + page.on('request', request => { + return !isFavicon(request) && requests.push(request); + }); + await page.goto(server.EMPTY_PAGE); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + expect(requests).toHaveLength(2); + }); + it('should fire for fetches', async () => { + const {page, server} = await getTestState(); + + const requests: HTTPRequest[] = []; + page.on('request', request => { + return !isFavicon(request) && requests.push(request); + }); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + return fetch('/empty.html'); + }); + expect(requests).toHaveLength(2); + }); + }); + describe('Request.frame', function () { + it('should work for main frame navigation request', async () => { + const {page, server} = await getTestState(); + + const requests: HTTPRequest[] = []; + page.on('request', request => { + return !isFavicon(request) && requests.push(request); + }); + await page.goto(server.EMPTY_PAGE); + expect(requests).toHaveLength(1); + expect(requests[0]!.frame()).toBe(page.mainFrame()); + }); + it('should work for subframe navigation request', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const requests: HTTPRequest[] = []; + page.on('request', request => { + return !isFavicon(request) && requests.push(request); + }); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + expect(requests).toHaveLength(1); + expect(requests[0]!.frame()).toBe(page.frames()[1]); + }); + it('should work for fetch requests', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + let requests: HTTPRequest[] = []; + page.on('request', request => { + return !isFavicon(request) && requests.push(request); + }); + await page.evaluate(() => { + return fetch('/digits/1.png'); + }); + requests = requests.filter(request => { + return !request.url().includes('favicon'); + }); + expect(requests).toHaveLength(1); + expect(requests[0]!.frame()).toBe(page.mainFrame()); + }); + }); + + describe('Request.headers', function () { + it('should define Browser in user agent header', async () => { + const {page, server, isChrome} = await getTestState(); + const response = (await page.goto(server.EMPTY_PAGE))!; + const userAgent = response.request().headers()['user-agent']; + + if (isChrome) { + expect(userAgent).toContain('Chrome'); + } else { + expect(userAgent).toContain('Firefox'); + } + }); + }); + + describe('Response.headers', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + server.setRoute('/empty.html', (_req, res) => { + res.setHeader('foo', 'bar'); + res.end(); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.headers()['foo']).toBe('bar'); + }); + }); + + describe('Request.initiator', () => { + it('should return the initiator', async () => { + const {page, server} = await getTestState(); + + const initiators = new Map(); + page.on('request', request => { + return initiators.set( + request.url().split('/').pop(), + request.initiator() + ); + }); + await page.goto(server.PREFIX + '/initiator.html'); + + expect(initiators.get('initiator.html').type).toBe('other'); + expect(initiators.get('initiator.js').type).toBe('parser'); + expect(initiators.get('initiator.js').url).toBe( + server.PREFIX + '/initiator.html' + ); + expect(initiators.get('frame.html').type).toBe('parser'); + expect(initiators.get('frame.html').url).toBe( + server.PREFIX + '/initiator.html' + ); + expect(initiators.get('script.js').type).toBe('parser'); + expect(initiators.get('script.js').url).toBe( + server.PREFIX + '/frames/frame.html' + ); + expect(initiators.get('style.css').type).toBe('parser'); + expect(initiators.get('style.css').url).toBe( + server.PREFIX + '/frames/frame.html' + ); + expect(initiators.get('initiator.js').type).toBe('parser'); + expect(initiators.get('injectedfile.js').type).toBe('script'); + expect(initiators.get('injectedfile.js').stack.callFrames[0]!.url).toBe( + server.PREFIX + '/initiator.js' + ); + expect(initiators.get('injectedstyle.css').type).toBe('script'); + expect(initiators.get('injectedstyle.css').stack.callFrames[0]!.url).toBe( + server.PREFIX + '/initiator.js' + ); + expect(initiators.get('initiator.js').url).toBe( + server.PREFIX + '/initiator.html' + ); + }); + }); + + describe('Response.fromCache', function () { + it('should return |false| for non-cached content', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.fromCache()).toBe(false); + }); + + it('should work', async () => { + const {page, server} = await getTestState(); + + const responses = new Map(); + page.on('response', r => { + return ( + !isFavicon(r.request()) && responses.set(r.url().split('/').pop(), r) + ); + }); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + await page.reload(); + + expect(responses.size).toBe(2); + expect(responses.get('one-style.css').status()).toBe(200); + expect(responses.get('one-style.css').fromCache()).toBe(true); + expect(responses.get('one-style.html').status()).toBe(304); + expect(responses.get('one-style.html').fromCache()).toBe(false); + }); + }); + + describe('Response.fromServiceWorker', function () { + it('should return |false| for non-service-worker content', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.fromServiceWorker()).toBe(false); + }); + + it('Response.fromServiceWorker', async () => { + const {page, server} = await getTestState(); + + const responses = new Map(); + page.on('response', r => { + return !isFavicon(r) && responses.set(r.url().split('/').pop(), r); + }); + + // Load and re-load to make sure serviceworker is installed and running. + await page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html', { + waitUntil: 'networkidle2', + }); + await page.evaluate(async () => { + return (globalThis as any).activationPromise; + }); + await page.reload(); + + expect(responses.size).toBe(2); + expect(responses.get('sw.html').status()).toBe(200); + expect(responses.get('sw.html').fromServiceWorker()).toBe(true); + expect(responses.get('style.css').status()).toBe(200); + expect(responses.get('style.css').fromServiceWorker()).toBe(true); + }); + }); + + describe('Request.postData', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (_req, res) => { + return res.end(); + }); + + const [request] = await Promise.all([ + waitEvent<HTTPRequest>(page, 'request', r => { + return !isFavicon(r); + }), + page.evaluate(() => { + return fetch('./post', { + method: 'POST', + body: JSON.stringify({foo: 'bar'}), + }); + }), + ]); + + expect(request).toBeTruthy(); + expect(request.postData()).toBe('{"foo":"bar"}'); + }); + + it('should be |undefined| when there is no post data', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.request().postData()).toBe(undefined); + }); + + it('should work with blobs', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (_req, res) => { + return res.end(); + }); + + const [request] = await Promise.all([ + waitEvent<HTTPRequest>(page, 'request', r => { + return !isFavicon(r); + }), + page.evaluate(() => { + return fetch('./post', { + method: 'POST', + body: new Blob([JSON.stringify({foo: 'bar'})], { + type: 'application/json', + }), + }); + }), + ]); + + expect(request).toBeTruthy(); + expect(request.postData()).toBe(undefined); + expect(request.hasPostData()).toBe(true); + expect(await request.fetchPostData()).toBe('{"foo":"bar"}'); + }); + }); + + describe('Response.text', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.PREFIX + '/simple.json'))!; + const responseText = (await response.text()).trimEnd(); + expect(responseText).toBe('{"foo": "bar"}'); + }); + it('should return uncompressed text', async () => { + const {page, server} = await getTestState(); + + server.enableGzip('/simple.json'); + const response = (await page.goto(server.PREFIX + '/simple.json'))!; + expect(response.headers()['content-encoding']).toBe('gzip'); + const responseText = (await response.text()).trimEnd(); + expect(responseText).toBe('{"foo": "bar"}'); + }); + it('should throw when requesting body of redirected response', async () => { + const {page, server} = await getTestState(); + + server.setRedirect('/foo.html', '/empty.html'); + const response = (await page.goto(server.PREFIX + '/foo.html'))!; + const redirectChain = response.request().redirectChain(); + expect(redirectChain).toHaveLength(1); + const redirected = redirectChain[0]!.response()!; + expect(redirected.status()).toBe(302); + let error!: Error; + await redirected.text().catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain( + 'Response body is unavailable for redirect responses' + ); + }); + it('should wait until response completes', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + // Setup server to trap request. + let serverResponse!: ServerResponse; + server.setRoute('/get', (_req, res) => { + serverResponse = res; + // In Firefox, |fetch| will be hanging until it receives |Content-Type| header + // from server. + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.write('hello '); + }); + // Setup page to trap response. + let requestFinished = false; + page.on('requestfinished', r => { + return (requestFinished = requestFinished || r.url().includes('/get')); + }); + // send request and wait for server response + const [pageResponse] = await Promise.all([ + page.waitForResponse(r => { + return !isFavicon(r.request()); + }), + page.evaluate(() => { + return fetch('./get', {method: 'GET'}); + }), + server.waitForRequest('/get'), + ]); + + expect(serverResponse).toBeTruthy(); + expect(pageResponse).toBeTruthy(); + expect(pageResponse.status()).toBe(200); + expect(requestFinished).toBe(false); + + const responseText = pageResponse.text(); + // Write part of the response and wait for it to be flushed. + await new Promise(x => { + return serverResponse.write('wor', x); + }); + // Finish response. + await new Promise<void>(x => { + serverResponse.end('ld!', () => { + return x(); + }); + }); + expect(await responseText).toBe('hello world!'); + }); + }); + + describe('Response.json', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.PREFIX + '/simple.json'))!; + expect(await response.json()).toEqual({foo: 'bar'}); + }); + }); + + describe('Response.buffer', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + const response = (await page.goto(server.PREFIX + '/pptr.png'))!; + const imageBuffer = fs.readFileSync( + path.join(__dirname, '../assets', 'pptr.png') + ); + const responseBuffer = await response.buffer(); + expect(responseBuffer.equals(imageBuffer)).toBe(true); + }); + it('should work with compression', async () => { + const {page, server} = await getTestState(); + + server.enableGzip('/pptr.png'); + const response = (await page.goto(server.PREFIX + '/pptr.png'))!; + const imageBuffer = fs.readFileSync( + path.join(__dirname, '../assets', 'pptr.png') + ); + const responseBuffer = await response.buffer(); + expect(responseBuffer.equals(imageBuffer)).toBe(true); + }); + it('should throw if the response does not have a body', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/empty.html'); + + server.setRoute('/test.html', (_req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'x-ping'); + res.end('Hello World'); + }); + const url = server.CROSS_PROCESS_PREFIX + '/test.html'; + const responsePromise = waitEvent<HTTPResponse>( + page, + 'response', + response => { + // Get the preflight response. + return ( + response.request().method() === 'OPTIONS' && response.url() === url + ); + } + ); + + // Trigger a request with a preflight. + await page.evaluate(async src => { + const response = await fetch(src, { + method: 'POST', + headers: {'x-ping': 'pong'}, + }); + return response; + }, url); + + const response = await responsePromise; + await expect(response.buffer()).rejects.toThrowError( + 'Could not load body for this request. This might happen if the request is a preflight request.' + ); + }); + }); + + describe('Response.statusText', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + server.setRoute('/cool', (_req, res) => { + res.writeHead(200, 'cool!'); + res.end(); + }); + const response = (await page.goto(server.PREFIX + '/cool'))!; + expect(response.statusText()).toBe('cool!'); + }); + + it('handles missing status text', async () => { + const {page, server} = await getTestState(); + + server.setRoute('/nostatus', (_req, res) => { + res.writeHead(200, ''); + res.end(); + }); + const response = (await page.goto(server.PREFIX + '/nostatus'))!; + expect(response.statusText()).toBe(''); + }); + }); + + describe('Response.timing', function () { + it('returns timing information', async () => { + const {page, server} = await getTestState(); + const responses: HTTPResponse[] = []; + page.on('response', response => { + return responses.push(response); + }); + await page.goto(server.EMPTY_PAGE); + expect(responses).toHaveLength(1); + expect(responses[0]!.timing()!.receiveHeadersEnd).toBeGreaterThan(0); + }); + }); + + describe('Network Events', function () { + it('Page.Events.Request', async () => { + const {page, server} = await getTestState(); + + const requests: HTTPRequest[] = []; + page.on('request', request => { + return requests.push(request); + }); + await page.goto(server.EMPTY_PAGE); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(server.EMPTY_PAGE); + expect(requests[0]!.resourceType()).toBe('document'); + expect(requests[0]!.method()).toBe('GET'); + expect(requests[0]!.response()).toBeTruthy(); + expect(requests[0]!.frame() === page.mainFrame()).toBe(true); + expect(requests[0]!.frame()!.url()).toBe(server.EMPTY_PAGE); + }); + it('Page.Events.RequestServedFromCache', async () => { + const {page, server} = await getTestState(); + + const cached: string[] = []; + page.on('requestservedfromcache', r => { + return cached.push(r.url().split('/').pop()!); + }); + + await page.goto(server.PREFIX + '/cached/one-style.html'); + expect(cached).toEqual([]); + + await page.reload(); + expect(cached).toEqual(['one-style.css']); + }); + it('Page.Events.Response', async () => { + const {page, server} = await getTestState(); + + const responses: HTTPResponse[] = []; + page.on('response', response => { + return responses.push(response); + }); + await page.goto(server.EMPTY_PAGE); + expect(responses).toHaveLength(1); + expect(responses[0]!.url()).toBe(server.EMPTY_PAGE); + expect(responses[0]!.status()).toBe(200); + expect(responses[0]!.ok()).toBe(true); + expect(responses[0]!.request()).toBeTruthy(); + const remoteAddress = responses[0]!.remoteAddress(); + // Either IPv6 or IPv4, depending on environment. + expect( + remoteAddress.ip!.includes('::1') || remoteAddress.ip === '127.0.0.1' + ).toBe(true); + expect(remoteAddress.port).toBe(server.PORT); + }); + + it('Page.Events.RequestFailed', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + if (request.url().endsWith('css')) { + void request.abort(); + } else { + void request.continue(); + } + }); + const failedRequests: HTTPRequest[] = []; + page.on('requestfailed', request => { + return failedRequests.push(request); + }); + await page.goto(server.PREFIX + '/one-style.html'); + expect(failedRequests).toHaveLength(1); + expect(failedRequests[0]!.url()).toContain('one-style.css'); + expect(failedRequests[0]!.response()).toBe(null); + expect(failedRequests[0]!.resourceType()).toBe('stylesheet'); + if (isChrome) { + expect(failedRequests[0]!.failure()!.errorText).toBe('net::ERR_FAILED'); + } else { + expect(failedRequests[0]!.failure()!.errorText).toBe( + 'NS_ERROR_FAILURE' + ); + } + expect(failedRequests[0]!.frame()).toBeTruthy(); + }); + it('Page.Events.RequestFinished', async () => { + const {page, server} = await getTestState(); + + const requests: HTTPRequest[] = []; + page.on('requestfinished', request => { + return !isFavicon(request) && requests.push(request); + }); + await page.goto(server.EMPTY_PAGE); + expect(requests).toHaveLength(1); + const request = requests[0]!; + expect(request.url()).toBe(server.EMPTY_PAGE); + expect(request.response()).toBeTruthy(); + expect(request.frame() === page.mainFrame()).toBe(true); + expect(request.frame()!.url()).toBe(server.EMPTY_PAGE); + }); + it('should fire events in proper order', async () => { + const {page, server} = await getTestState(); + + const events: string[] = []; + page.on('request', () => { + return events.push('request'); + }); + page.on('response', () => { + return events.push('response'); + }); + page.on('requestfinished', () => { + return events.push('requestfinished'); + }); + await page.goto(server.EMPTY_PAGE); + // Events can sneak in after the page has navigate + expect(events.slice(0, 3)).toEqual([ + 'request', + 'response', + 'requestfinished', + ]); + }); + it('should support redirects', async () => { + const {page, server} = await getTestState(); + + const events: string[] = []; + page.on('request', request => { + return events.push(`${request.method()} ${request.url()}`); + }); + page.on('response', response => { + return events.push(`${response.status()} ${response.url()}`); + }); + page.on('requestfinished', request => { + return events.push(`DONE ${request.url()}`); + }); + page.on('requestfailed', request => { + return events.push(`FAIL ${request.url()}`); + }); + server.setRedirect('/foo.html', '/empty.html'); + const FOO_URL = server.PREFIX + '/foo.html'; + const response = (await page.goto(FOO_URL))!; + expect(events).toEqual([ + `GET ${FOO_URL}`, + `302 ${FOO_URL}`, + `DONE ${FOO_URL}`, + `GET ${server.EMPTY_PAGE}`, + `200 ${server.EMPTY_PAGE}`, + `DONE ${server.EMPTY_PAGE}`, + ]); + + // Check redirect chain + const redirectChain = response.request().redirectChain(); + expect(redirectChain).toHaveLength(1); + expect(redirectChain[0]!.url()).toContain('/foo.html'); + expect(redirectChain[0]!.response()!.remoteAddress().port).toBe( + server.PORT + ); + }); + }); + + describe('Request.isNavigationRequest', () => { + it('should work', async () => { + const {page, server} = await getTestState(); + + const requests = new Map(); + page.on('request', request => { + return requests.set(request.url().split('/').pop(), request); + }); + server.setRedirect('/rrredirect', '/frames/one-frame.html'); + await page.goto(server.PREFIX + '/rrredirect'); + expect(requests.get('rrredirect').isNavigationRequest()).toBe(true); + expect(requests.get('one-frame.html').isNavigationRequest()).toBe(true); + expect(requests.get('frame.html').isNavigationRequest()).toBe(true); + expect(requests.get('script.js').isNavigationRequest()).toBe(false); + expect(requests.get('style.css').isNavigationRequest()).toBe(false); + }); + it('should work with request interception', async () => { + const {page, server} = await getTestState(); + + const requests = new Map(); + page.on('request', request => { + requests.set(request.url().split('/').pop(), request); + void request.continue(); + }); + await page.setRequestInterception(true); + server.setRedirect('/rrredirect', '/frames/one-frame.html'); + await page.goto(server.PREFIX + '/rrredirect'); + expect(requests.get('rrredirect').isNavigationRequest()).toBe(true); + expect(requests.get('one-frame.html').isNavigationRequest()).toBe(true); + expect(requests.get('frame.html').isNavigationRequest()).toBe(true); + expect(requests.get('script.js').isNavigationRequest()).toBe(false); + expect(requests.get('style.css').isNavigationRequest()).toBe(false); + }); + it('should work when navigating to image', async () => { + const {page, server} = await getTestState(); + + const [request] = await Promise.all([ + waitEvent<HTTPRequest>(page, 'request'), + page.goto(server.PREFIX + '/pptr.png'), + ]); + expect(request.isNavigationRequest()).toBe(true); + }); + }); + + describe('Page.setExtraHTTPHeaders', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setExtraHTTPHeaders({ + foo: 'bar', + }); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + expect(request.headers['foo']).toBe('bar'); + }); + it('should throw for non-string header values', async () => { + const {page} = await getTestState(); + + let error!: Error; + try { + // @ts-expect-error purposeful bad input + await page.setExtraHTTPHeaders({foo: 1}); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toBe( + 'Expected value of header "foo" to be String, but "number" is found.' + ); + }); + }); + + describe('Page.authenticate', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + server.setAuth('/empty.html', 'user', 'pass'); + let response; + try { + response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.status()).toBe(401); + } catch (error) { + // In headful, an error is thrown instead of 401. + if ( + !(error as Error).message.startsWith( + 'net::ERR_INVALID_AUTH_CREDENTIALS' + ) + ) { + throw error; + } + } + await page.authenticate({ + username: 'user', + password: 'pass', + }); + response = (await page.reload())!; + expect(response.status()).toBe(200); + }); + it('should fail if wrong credentials', async () => { + const {page, server} = await getTestState(); + + // Use unique user/password since Chrome caches credentials per origin. + server.setAuth('/empty.html', 'user2', 'pass2'); + await page.authenticate({ + username: 'foo', + password: 'bar', + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.status()).toBe(401); + }); + it('should allow disable authentication', async () => { + const {page, server} = await getTestState(); + + // Use unique user/password since Chrome caches credentials per origin. + server.setAuth('/empty.html', 'user3', 'pass3'); + await page.authenticate({ + username: 'user3', + password: 'pass3', + }); + let response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.status()).toBe(200); + await page.authenticate({ + username: '', + password: '', + }); + // Navigate to a different origin to bust Chrome's credential caching. + try { + response = (await page.goto( + server.CROSS_PROCESS_PREFIX + '/empty.html' + ))!; + expect(response.status()).toBe(401); + } catch (error) { + // In headful, an error is thrown instead of 401. + if ( + !(error as Error).message.startsWith( + 'net::ERR_INVALID_AUTH_CREDENTIALS' + ) + ) { + throw error; + } + } + }); + it('should not disable caching', async () => { + const {page, server} = await getTestState(); + + // Use unique user/password since Chrome caches credentials per origin. + server.setAuth('/cached/one-style.css', 'user4', 'pass4'); + server.setAuth('/cached/one-style.html', 'user4', 'pass4'); + await page.authenticate({ + username: 'user4', + password: 'pass4', + }); + + const responses = new Map(); + page.on('response', r => { + return responses.set(r.url().split('/').pop(), r); + }); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + await page.reload(); + + expect(responses.get('one-style.css').status()).toBe(200); + expect(responses.get('one-style.css').fromCache()).toBe(true); + expect(responses.get('one-style.html').status()).toBe(304); + expect(responses.get('one-style.html').fromCache()).toBe(false); + }); + }); + + describe('raw network headers', () => { + it('Same-origin set-cookie navigation', async () => { + const {page, server} = await getTestState(); + + const setCookieString = 'foo=bar'; + server.setRoute('/empty.html', (_req, res) => { + res.setHeader('set-cookie', setCookieString); + res.end('hello world'); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.headers()['set-cookie']).toBe(setCookieString); + }); + + it('Same-origin set-cookie subresource', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + + const setCookieString = 'foo=bar'; + server.setRoute('/foo', (_req, res) => { + res.setHeader('set-cookie', setCookieString); + res.end('hello world'); + }); + + const [response] = await Promise.all([ + waitEvent<HTTPResponse>(page, 'response', res => { + return !isFavicon(res); + }), + page.evaluate(() => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/foo'); + xhr.send(); + }), + ]); + expect(response.headers()['set-cookie']).toBe(setCookieString); + }); + + it('Cross-origin set-cookie', async () => { + const {page, httpsServer, close} = await launch({ + ignoreHTTPSErrors: true, + }); + try { + await page.goto(httpsServer.PREFIX + '/empty.html'); + + const setCookieString = 'hello=world'; + httpsServer.setRoute('/setcookie.html', (_req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('set-cookie', setCookieString); + res.end(); + }); + await page.goto(httpsServer.PREFIX + '/setcookie.html'); + const url = httpsServer.CROSS_PROCESS_PREFIX + '/setcookie.html'; + const [response] = await Promise.all([ + waitEvent<HTTPResponse>(page, 'response', response => { + return response.url() === url; + }), + page.evaluate(src => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', src); + xhr.send(); + }, url), + ]); + expect(response.headers()['set-cookie']).toBe(setCookieString); + } finally { + await close(); + } + }); + }); + + describe('Page.setBypassServiceWorker', () => { + it('bypass for network', async () => { + const {page, server} = await getTestState(); + + const responses = new Map(); + page.on('response', r => { + return !isFavicon(r) && responses.set(r.url().split('/').pop(), r); + }); + + // Load and re-load to make sure serviceworker is installed and running. + await page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html', { + waitUntil: 'networkidle2', + }); + await page.evaluate(async () => { + return (globalThis as any).activationPromise; + }); + await page.reload({ + waitUntil: 'networkidle2', + }); + + expect(page.isServiceWorkerBypassed()).toBe(false); + expect(responses.size).toBe(2); + expect(responses.get('sw.html').status()).toBe(200); + expect(responses.get('sw.html').fromServiceWorker()).toBe(true); + expect(responses.get('style.css').status()).toBe(200); + expect(responses.get('style.css').fromServiceWorker()).toBe(true); + + await page.setBypassServiceWorker(true); + await page.reload({ + waitUntil: 'networkidle2', + }); + + expect(page.isServiceWorkerBypassed()).toBe(true); + expect(responses.get('sw.html').status()).toBe(200); + expect(responses.get('sw.html').fromServiceWorker()).toBe(false); + expect(responses.get('style.css').status()).toBe(200); + expect(responses.get('style.css').fromServiceWorker()).toBe(false); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/oopif.spec.ts b/remote/test/puppeteer/test/src/oopif.spec.ts new file mode 100644 index 0000000000..c024b76aba --- /dev/null +++ b/remote/test/puppeteer/test/src/oopif.spec.ts @@ -0,0 +1,527 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import type {BrowserContext} from 'puppeteer-core/internal/api/BrowserContext.js'; +import type {CDPSession} from 'puppeteer-core/internal/api/CDPSession.js'; +import {CDPSessionEvent} from 'puppeteer-core/internal/api/CDPSession.js'; +import type {CdpTarget} from 'puppeteer-core/internal/cdp/Target.js'; + +import {getTestState, launch} from './mocha-utils.js'; +import {attachFrame, detachFrame, navigateFrame} from './utils.js'; + +describe('OOPIF', function () { + /* We use a special browser for this test as we need the --site-per-process flag */ + let state: Awaited<ReturnType<typeof launch>>; + + before(async () => { + const {defaultBrowserOptions} = await getTestState({skipLaunch: true}); + + state = await launch( + Object.assign({}, defaultBrowserOptions, { + args: (defaultBrowserOptions.args || []).concat([ + '--site-per-process', + '--remote-debugging-port=21222', + '--host-rules=MAP * 127.0.0.1', + ]), + }), + {after: 'all'} + ); + }); + + beforeEach(async () => { + state.context = await state.browser.createIncognitoBrowserContext(); + state.page = await state.context.newPage(); + }); + + afterEach(async () => { + await state.context.close(); + }); + + after(async () => { + await state.close(); + }); + + it('should treat OOP iframes and normal iframes the same', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return frame.url().endsWith('/empty.html'); + }); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await attachFrame( + page, + 'frame2', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + await framePromise; + expect(page.mainFrame().childFrames()).toHaveLength(2); + }); + it('should track navigations within OOP iframes', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + await attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + const frame = await framePromise; + expect(frame.url()).toContain('/empty.html'); + await navigateFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/assets/frame.html' + ); + expect(frame.url()).toContain('/assets/frame.html'); + }); + it('should support OOP iframes becoming normal iframes again', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + + const frame = await framePromise; + expect(frame.isOOPFrame()).toBe(false); + await navigateFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + expect(frame.isOOPFrame()).toBe(true); + await navigateFrame(page, 'frame1', server.EMPTY_PAGE); + expect(frame.isOOPFrame()).toBe(false); + expect(page.frames()).toHaveLength(2); + }); + it('should support frames within OOP frames', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + const frame1Promise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + const frame2Promise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 2; + }); + await attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/frames/one-frame.html' + ); + + const [frame1, frame2] = await Promise.all([frame1Promise, frame2Promise]); + + expect( + await frame1.evaluate(() => { + return document.location.href; + }) + ).toMatch(/one-frame\.html$/); + expect( + await frame2.evaluate(() => { + return document.location.href; + }) + ).toMatch(/frames\/frame\.html$/); + }); + it('should support OOP iframes getting detached', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + + const frame = await framePromise; + expect(frame.isOOPFrame()).toBe(false); + await navigateFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + expect(frame.isOOPFrame()).toBe(true); + await detachFrame(page, 'frame1'); + expect(page.frames()).toHaveLength(1); + }); + + it('should support wait for navigation for transitions from local to OOPIF', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + + const frame = await framePromise; + expect(frame.isOOPFrame()).toBe(false); + const nav = frame.waitForNavigation(); + await navigateFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + await nav; + expect(frame.isOOPFrame()).toBe(true); + await detachFrame(page, 'frame1'); + expect(page.frames()).toHaveLength(1); + }); + + it('should keep track of a frames OOP state', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + await attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + const frame = await framePromise; + expect(frame.url()).toContain('/empty.html'); + await navigateFrame(page, 'frame1', server.EMPTY_PAGE); + expect(frame.url()).toBe(server.EMPTY_PAGE); + }); + + it('should support evaluating in oop iframes', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + await attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + const frame = await framePromise; + await frame.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + _test = 'Test 123!'; + }); + const result = await frame.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + return window._test; + }); + expect(result).toBe('Test 123!'); + }); + it('should provide access to elements', async () => { + const {server, isHeadless, headless, page} = state; + + if (!isHeadless || headless === 'new') { + // TODO: this test is partially blocked on crbug.com/1334119. Enable test once + // the upstream is fixed. + // TLDR: when we dispatch events to the frame the compositor might + // not be up-to-date yet resulting in a misclick (the iframe element + // becomes the event target instead of the content inside the iframe). + // The solution is to use InsertVisualCallback on the backend but that causes + // another issue that events cannot be dispatched to inactive tabs as the + // visual callback is never invoked. + // The old headless mode does not have this issue since it operates with + // special scheduling settings that keep even inactive tabs updating. + return; + } + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + await attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + + const frame = await framePromise; + await frame.evaluate(() => { + const button = document.createElement('button'); + button.id = 'test-button'; + button.innerText = 'click'; + button.onclick = () => { + button.id = 'clicked'; + }; + document.body.appendChild(button); + }); + await page.evaluate(() => { + document.body.style.border = '150px solid black'; + document.body.style.margin = '250px'; + document.body.style.padding = '50px'; + }); + await frame.waitForSelector('#test-button', {visible: true}); + await frame.click('#test-button'); + await frame.waitForSelector('#clicked'); + }); + it('should report oopif frames', async () => { + const {server, page, context} = state; + + const frame = page.waitForFrame(frame => { + return frame.url().endsWith('/oopif.html'); + }); + await page.goto(server.PREFIX + '/dynamic-oopif.html'); + await frame; + expect(oopifs(context)).toHaveLength(1); + expect(page.frames()).toHaveLength(2); + }); + + it('should wait for inner OOPIFs', async () => { + const {server, page, context} = state; + await page.goto(`http://mainframe:${server.PORT}/main-frame.html`); + const frame2 = await page.waitForFrame(frame => { + return frame.url().endsWith('inner-frame2.html'); + }); + expect(oopifs(context)).toHaveLength(2); + expect( + page.frames().filter(frame => { + return frame.isOOPFrame(); + }) + ).toHaveLength(2); + expect( + await frame2.evaluate(() => { + return document.querySelectorAll('button').length; + }) + ).toStrictEqual(1); + }); + + it('should load oopif iframes with subresources and request interception', async () => { + const {server, page, context} = state; + + const framePromise = page.waitForFrame(frame => { + return frame.url().endsWith('/oopif.html'); + }); + page.on('request', request => { + void request.continue(); + }); + await page.setRequestInterception(true); + const requestPromise = page.waitForRequest(request => { + return request.url().includes('requestFromOOPIF'); + }); + await page.goto(server.PREFIX + '/dynamic-oopif.html'); + const frame = await framePromise; + const request = await requestPromise; + expect(oopifs(context)).toHaveLength(1); + expect(request.frame()).toBe(frame); + }); + + it('should support frames within OOP iframes', async () => { + const {server, page} = state; + + const oopIframePromise = page.waitForFrame(frame => { + return frame.url().endsWith('/oopif.html'); + }); + await page.goto(server.PREFIX + '/dynamic-oopif.html'); + const oopIframe = await oopIframePromise; + await attachFrame( + oopIframe, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + + const frame1 = oopIframe.childFrames()[0]!; + expect(frame1.url()).toMatch(/empty.html$/); + await navigateFrame( + oopIframe, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/oopif.html' + ); + expect(frame1.url()).toMatch(/oopif.html$/); + await frame1.goto( + server.CROSS_PROCESS_PREFIX + '/oopif.html#navigate-within-document', + {waitUntil: 'load'} + ); + expect(frame1.url()).toMatch(/oopif.html#navigate-within-document$/); + await detachFrame(oopIframe, 'frame1'); + expect(oopIframe.childFrames()).toHaveLength(0); + }); + + it('clickablePoint, boundingBox, boxModel should work for elements inside OOPIFs', async () => { + const {server, page} = state; + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame(frame => { + return page.frames().indexOf(frame) === 1; + }); + await attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + const frame = await framePromise; + await page.evaluate(() => { + document.body.style.border = '50px solid black'; + document.body.style.margin = '50px'; + document.body.style.padding = '50px'; + }); + await frame.evaluate(() => { + const button = document.createElement('button'); + button.id = 'test-button'; + button.innerText = 'click'; + document.body.appendChild(button); + }); + using button = (await frame.waitForSelector('#test-button', { + visible: true, + }))!; + const result = await button.clickablePoint(); + expect(result.x).toBeGreaterThan(150); // padding + margin + border left + expect(result.y).toBeGreaterThan(150); // padding + margin + border top + const resultBoxModel = (await button.boxModel())!; + for (const quad of [ + resultBoxModel.content, + resultBoxModel.border, + resultBoxModel.margin, + resultBoxModel.padding, + ]) { + for (const part of quad) { + expect(part.x).toBeGreaterThan(150); // padding + margin + border left + expect(part.y).toBeGreaterThan(150); // padding + margin + border top + } + } + const resultBoundingBox = (await button.boundingBox())!; + expect(resultBoundingBox.x).toBeGreaterThan(150); // padding + margin + border left + expect(resultBoundingBox.y).toBeGreaterThan(150); // padding + margin + border top + }); + + it('should detect existing OOPIFs when Puppeteer connects to an existing page', async () => { + const {server, puppeteer, page, context} = state; + + const frame = page.waitForFrame(frame => { + return frame.url().endsWith('/oopif.html'); + }); + await page.goto(server.PREFIX + '/dynamic-oopif.html'); + await frame; + expect(oopifs(context)).toHaveLength(1); + expect(page.frames()).toHaveLength(2); + + const browserURL = 'http://127.0.0.1:21222'; + const browser1 = await puppeteer.connect({browserURL}); + const target = await browser1.waitForTarget(target => { + return target.url().endsWith('dynamic-oopif.html'); + }); + await target.page(); + await browser1.disconnect(); + }); + + it('should support lazy OOP frames', async () => { + const {server, page} = state; + + await page.goto(server.PREFIX + '/lazy-oopif-frame.html'); + await page.setViewport({width: 1000, height: 1000}); + + expect( + page.frames().map(frame => { + return frame._hasStartedLoading; + }) + ).toEqual([true, true, false]); + }); + + describe('waitForFrame', () => { + it('should resolve immediately if the frame already exists', async () => { + const {server, page} = state; + + await page.goto(server.EMPTY_PAGE); + await attachFrame( + page, + 'frame2', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + + await page.waitForFrame(frame => { + return frame.url().endsWith('/empty.html'); + }); + }); + }); + + it('should report google.com frame', async () => { + const {server, page} = state; + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + page.on('request', r => { + return r.respond({body: 'YO, GOOGLE.COM'}); + }); + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.setAttribute('src', 'https://google.com/'); + document.body.appendChild(frame); + return new Promise(x => { + return (frame.onload = x); + }); + }); + await page.waitForSelector('iframe[src="https://google.com/"]'); + const urls = page + .frames() + .map(frame => { + return frame.url(); + }) + .sort(); + expect(urls).toEqual([server.EMPTY_PAGE, 'https://google.com/']); + }); + + it('should expose events within OOPIFs', async () => { + const {server, page} = state; + + // Setup our session listeners to observe OOPIF activity. + const session = await page.target().createCDPSession(); + const networkEvents: string[] = []; + const otherSessions: CDPSession[] = []; + await session.send('Target.setAutoAttach', { + autoAttach: true, + flatten: true, + waitForDebuggerOnStart: true, + }); + session.on(CDPSessionEvent.SessionAttached, async session => { + otherSessions.push(session); + + session.on('Network.requestWillBeSent', params => { + return networkEvents.push(params.request.url); + }); + await session.send('Network.enable'); + await session.send('Runtime.runIfWaitingForDebugger'); + }); + + // Navigate to the empty page and add an OOPIF iframe with at least one request. + await page.goto(server.EMPTY_PAGE); + await page.evaluate( + (frameUrl: string) => { + const frame = document.createElement('iframe'); + frame.setAttribute('src', frameUrl); + document.body.appendChild(frame); + return new Promise((x, y) => { + frame.onload = x; + frame.onerror = y; + }); + }, + server.PREFIX.replace('localhost', 'oopifdomain') + '/one-style.html' + ); + await page.waitForSelector('iframe'); + + // Ensure we found the iframe session. + expect(otherSessions).toHaveLength(1); + + // Resume the iframe and trigger another request. + const iframeSession = otherSessions[0]!; + await iframeSession.send('Runtime.evaluate', { + expression: `fetch('/fetch')`, + awaitPromise: true, + }); + + expect(networkEvents).toContain(`http://oopifdomain:${server.PORT}/fetch`); + }); +}); + +function oopifs(context: BrowserContext) { + return context.targets().filter(target => { + return (target as CdpTarget)._getTargetInfo().type === 'iframe'; + }); +} diff --git a/remote/test/puppeteer/test/src/page.spec.ts b/remote/test/puppeteer/test/src/page.spec.ts new file mode 100644 index 0000000000..79fc69ebbc --- /dev/null +++ b/remote/test/puppeteer/test/src/page.spec.ts @@ -0,0 +1,2287 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert'; +import fs from 'fs'; +import type {ServerResponse} from 'http'; +import path from 'path'; + +import expect from 'expect'; +import {KnownDevices, TimeoutError} from 'puppeteer'; +import {CDPSession} from 'puppeteer-core/internal/api/CDPSession.js'; +import type {HTTPRequest} from 'puppeteer-core/internal/api/HTTPRequest.js'; +import type {Metrics, Page} from 'puppeteer-core/internal/api/Page.js'; +import type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js'; +import type {ConsoleMessage} from 'puppeteer-core/internal/common/ConsoleMessage.js'; +import sinon from 'sinon'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {attachFrame, detachFrame, isFavicon, waitEvent} from './utils.js'; + +describe('Page', function () { + setupTestBrowserHooks(); + + describe('Page.close', function () { + it('should reject all promises when page is closed', async () => { + const {context} = await getTestState(); + + const newPage = await context.newPage(); + let error!: Error; + await Promise.all([ + newPage + .evaluate(() => { + return new Promise(() => {}); + }) + .catch(error_ => { + return (error = error_); + }), + newPage.close(), + ]); + expect(error.message).toContain('Protocol error'); + }); + it('should not be visible in browser.pages', async () => { + const {browser} = await getTestState(); + + const newPage = await browser.newPage(); + expect(await browser.pages()).toContain(newPage); + await newPage.close(); + expect(await browser.pages()).not.toContain(newPage); + }); + it('should run beforeunload if asked for', async () => { + const {context, server, isChrome} = await getTestState(); + + const newPage = await context.newPage(); + await newPage.goto(server.PREFIX + '/beforeunload.html'); + // We have to interact with a page so that 'beforeunload' handlers + // fire. + await newPage.click('body'); + const pageClosingPromise = newPage.close({runBeforeUnload: true}); + const dialog = await waitEvent(newPage, 'dialog'); + expect(dialog.type()).toBe('beforeunload'); + expect(dialog.defaultValue()).toBe(''); + if (isChrome) { + expect(dialog.message()).toBe(''); + } else { + expect(dialog.message()).toBeTruthy(); + } + await dialog.accept(); + await pageClosingPromise; + }); + it('should *not* run beforeunload by default', async () => { + const {context, server} = await getTestState(); + + const newPage = await context.newPage(); + await newPage.goto(server.PREFIX + '/beforeunload.html'); + // We have to interact with a page so that 'beforeunload' handlers + // fire. + await newPage.click('body'); + await newPage.close(); + }); + it('should set the page close state', async () => { + const {context} = await getTestState(); + + const newPage = await context.newPage(); + expect(newPage.isClosed()).toBe(false); + await newPage.close(); + expect(newPage.isClosed()).toBe(true); + }); + it('should terminate network waiters', async () => { + const {context, server} = await getTestState(); + + const newPage = await context.newPage(); + const results = await Promise.all([ + newPage.waitForRequest(server.EMPTY_PAGE).catch(error => { + return error; + }), + newPage.waitForResponse(server.EMPTY_PAGE).catch(error => { + return error; + }), + newPage.close(), + ]); + for (let i = 0; i < 2; i++) { + const message = results[i].message; + expect(message).atLeastOneToContain(['Target closed', 'Page closed!']); + expect(message).not.toContain('Timeout'); + } + }); + }); + + describe('Page.Events.Load', function () { + it('should fire when expected', async () => { + const {page} = await getTestState(); + + await Promise.all([waitEvent(page, 'load'), page.goto('about:blank')]); + }); + }); + + describe('removing and adding event handlers', () => { + it('should correctly fire event handlers as they are added and then removed', async () => { + const {page, server} = await getTestState(); + + const handler = sinon.spy(); + const onResponse = (response: {url: () => string}) => { + // Ignore default favicon requests. + if (!isFavicon(response)) { + handler(); + } + }; + page.on('response', onResponse); + await page.goto(server.EMPTY_PAGE); + expect(handler.callCount).toBe(1); + page.off('response', onResponse); + await page.goto(server.EMPTY_PAGE); + // Still one because we removed the handler. + expect(handler.callCount).toBe(1); + page.on('response', onResponse); + await page.goto(server.EMPTY_PAGE); + // Two now because we added the handler back. + expect(handler.callCount).toBe(2); + }); + + it('should correctly added and removed request events', async () => { + const {page, server} = await getTestState(); + + const handler = sinon.spy(); + const onResponse = (response: {url: () => string}) => { + // Ignore default favicon requests. + if (!isFavicon(response)) { + handler(); + } + }; + + page.on('request', onResponse); + page.on('request', onResponse); + await page.goto(server.EMPTY_PAGE); + expect(handler.callCount).toBe(2); + page.off('request', onResponse); + await page.goto(server.EMPTY_PAGE); + // Still one because we removed the handler. + expect(handler.callCount).toBe(3); + page.off('request', onResponse); + await page.goto(server.EMPTY_PAGE); + expect(handler.callCount).toBe(3); + page.on('request', onResponse); + await page.goto(server.EMPTY_PAGE); + // Two now because we added the handler back. + expect(handler.callCount).toBe(4); + }); + }); + + describe('Page.Events.error', function () { + it('should throw when page crashes', async () => { + const {page, isChrome} = await getTestState(); + + let navigate: Promise<unknown>; + if (isChrome) { + navigate = page.goto('chrome://crash').catch(() => {}); + } else { + navigate = page.goto('about:crashcontent').catch(() => {}); + } + const [error] = await Promise.all([ + waitEvent<Error>(page, 'error'), + navigate, + ]); + expect(error.message).toBe('Page crashed!'); + }); + }); + + describe('Page.Events.Popup', function () { + it('should work', async () => { + const {page} = await getTestState(); + + const [popup] = await Promise.all([ + waitEvent<Page>(page, 'popup'), + page.evaluate(() => { + return window.open('about:blank'); + }), + ]); + expect( + await page.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + expect( + await popup.evaluate(() => { + return !!window.opener; + }) + ).toBe(true); + }); + it('should work with noopener', async () => { + const {page} = await getTestState(); + + const [popup] = await Promise.all([ + waitEvent<Page>(page, 'popup'), + page.evaluate(() => { + return window.open('about:blank', undefined, 'noopener'); + }), + ]); + expect( + await page.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + expect( + await popup.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + }); + it('should work with clicking target=_blank and without rel=opener', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent('<a target=_blank href="/one-style.html">yo</a>'); + const [popup] = await Promise.all([ + waitEvent<Page>(page, 'popup'), + page.click('a'), + ]); + expect( + await page.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + expect( + await popup.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + }); + it('should work with clicking target=_blank and with rel=opener', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent( + '<a target=_blank rel=opener href="/one-style.html">yo</a>' + ); + const [popup] = await Promise.all([ + waitEvent<Page>(page, 'popup'), + page.click('a'), + ]); + expect( + await page.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + expect( + await popup.evaluate(() => { + return !!window.opener; + }) + ).toBe(true); + }); + it('should work with fake-clicking target=_blank and rel=noopener', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent( + '<a target=_blank rel=noopener href="/one-style.html">yo</a>' + ); + const [popup] = await Promise.all([ + waitEvent<Page>(page, 'popup'), + page.$eval('a', a => { + return (a as HTMLAnchorElement).click(); + }), + ]); + expect( + await page.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + expect( + await popup.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + }); + it('should work with clicking target=_blank and rel=noopener', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent( + '<a target=_blank rel=noopener href="/one-style.html">yo</a>' + ); + const [popup] = await Promise.all([ + waitEvent<Page>(page, 'popup'), + page.click('a'), + ]); + expect( + await page.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + expect( + await popup.evaluate(() => { + return !!window.opener; + }) + ).toBe(false); + }); + }); + + describe('Page.setGeolocation', function () { + it('should work', async () => { + const {page, server, context} = await getTestState(); + + await context.overridePermissions(server.PREFIX, ['geolocation']); + await page.goto(server.EMPTY_PAGE); + await page.setGeolocation({longitude: 10, latitude: 10}); + const geolocation = await page.evaluate(() => { + return new Promise(resolve => { + return navigator.geolocation.getCurrentPosition(position => { + resolve({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + }); + }); + }); + expect(geolocation).toEqual({ + latitude: 10, + longitude: 10, + }); + }); + it('should throw when invalid longitude', async () => { + const {page} = await getTestState(); + + let error!: Error; + try { + await page.setGeolocation({longitude: 200, latitude: 10}); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toContain('Invalid longitude "200"'); + }); + }); + + describe('Page.setOfflineMode', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setOfflineMode(true); + let error!: Error; + await page.goto(server.EMPTY_PAGE).catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + await page.setOfflineMode(false); + const response = (await page.reload())!; + expect(response.status()).toBe(200); + }); + it('should emulate navigator.onLine', async () => { + const {page} = await getTestState(); + + expect( + await page.evaluate(() => { + return window.navigator.onLine; + }) + ).toBe(true); + await page.setOfflineMode(true); + expect( + await page.evaluate(() => { + return window.navigator.onLine; + }) + ).toBe(false); + await page.setOfflineMode(false); + expect( + await page.evaluate(() => { + return window.navigator.onLine; + }) + ).toBe(true); + }); + }); + + describe('Page.Events.Console', function () { + it('should work', async () => { + const {page} = await getTestState(); + + const [message] = await Promise.all([ + waitEvent<ConsoleMessage>(page, 'console'), + page.evaluate(() => { + return console.log('hello', 5, {foo: 'bar'}); + }), + ]); + expect(message.text()).toEqual('hello 5 JSHandle@object'); + expect(message.type()).toEqual('log'); + expect(message.args()).toHaveLength(3); + expect(message.location()).toEqual({ + url: expect.any(String), + lineNumber: expect.any(Number), + columnNumber: expect.any(Number), + }); + + expect(await message.args()[0]!.jsonValue()).toEqual('hello'); + expect(await message.args()[1]!.jsonValue()).toEqual(5); + expect(await message.args()[2]!.jsonValue()).toEqual({foo: 'bar'}); + }); + it('should work on script call right after navigation', async () => { + const {page} = await getTestState(); + + const [message] = await Promise.all([ + waitEvent<ConsoleMessage>(page, 'console'), + page.goto( + // Firefox prints warn if <!DOCTYPE html> is not present + `data:text/html,<!DOCTYPE html><script>console.log('SOME_LOG_MESSAGE');</script>` + ), + ]); + + expect(message.text()).toEqual('SOME_LOG_MESSAGE'); + }); + it('should work for different console API calls with logging functions', async () => { + const {page} = await getTestState(); + + const messages: ConsoleMessage[] = []; + page.on('console', msg => { + return messages.push(msg); + }); + // All console events will be reported before `page.evaluate` is finished. + await page.evaluate(() => { + console.trace('calling console.trace'); + console.dir('calling console.dir'); + console.warn('calling console.warn'); + console.error('calling console.error'); + console.log(Promise.resolve('should not wait until resolved!')); + }); + expect( + messages.map(msg => { + return msg.type(); + }) + ).toEqual(['trace', 'dir', 'warning', 'error', 'log']); + expect( + messages.map(msg => { + return msg.text(); + }) + ).toEqual([ + 'calling console.trace', + 'calling console.dir', + 'calling console.warn', + 'calling console.error', + 'JSHandle@promise', + ]); + }); + it('should work for different console API calls with timing functions', async () => { + const {page} = await getTestState(); + + const messages: any[] = []; + page.on('console', msg => { + return messages.push(msg); + }); + // All console events will be reported before `page.evaluate` is finished. + await page.evaluate(() => { + // A pair of time/timeEnd generates only one Console API call. + console.time('calling console.time'); + console.timeEnd('calling console.time'); + }); + expect( + messages.map(msg => { + return msg.type(); + }) + ).toEqual(['timeEnd']); + expect(messages[0]!.text()).toContain('calling console.time'); + }); + it('should not fail for window object', async () => { + const {page} = await getTestState(); + + const [message] = await Promise.all([ + waitEvent<ConsoleMessage>(page, 'console'), + page.evaluate(() => { + return console.error(window); + }), + ]); + expect(message.text()).atLeastOneToContain([ + 'JSHandle@object', + 'JSHandle@window', + ]); + }); + it('should trigger correct Log', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.goto('about:blank'); + const [message] = await Promise.all([ + waitEvent(page, 'console'), + page.evaluate(async (url: string) => { + return await fetch(url).catch(() => {}); + }, server.EMPTY_PAGE), + ]); + expect(message.text()).toContain('Access-Control-Allow-Origin'); + if (isChrome) { + expect(message.type()).toEqual('error'); + } else { + expect(message.type()).toEqual('warn'); + } + }); + it('should have location when fetch fails', async () => { + const {page, server} = await getTestState(); + + // The point of this test is to make sure that we report console messages from + // Log domain: https://vanilla.aslushnikov.com/?Log.entryAdded + await page.goto(server.EMPTY_PAGE); + const [message] = await Promise.all([ + waitEvent(page, 'console'), + page.setContent(`<script>fetch('http://wat');</script>`), + ]); + expect(message.text()).toContain(`ERR_NAME_NOT_RESOLVED`); + expect(message.type()).toEqual('error'); + expect(message.location()).toEqual({ + url: 'http://wat/', + lineNumber: undefined, + }); + }); + it('should have location and stack trace for console API calls', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [message] = await Promise.all([ + waitEvent(page, 'console'), + page.goto(server.PREFIX + '/consolelog.html'), + ]); + expect(message.text()).toBe('yellow'); + expect(message.type()).toBe('log'); + expect(message.location()).toEqual({ + url: server.PREFIX + '/consolelog.html', + lineNumber: 8, + columnNumber: isChrome ? 16 : 8, // console.|log vs |console.log + }); + expect(message.stackTrace()).toEqual([ + { + url: server.PREFIX + '/consolelog.html', + lineNumber: 8, + columnNumber: isChrome ? 16 : 8, // console.|log vs |console.log + }, + { + url: server.PREFIX + '/consolelog.html', + lineNumber: 11, + columnNumber: 8, + }, + { + url: server.PREFIX + '/consolelog.html', + lineNumber: 13, + columnNumber: 6, + }, + ]); + }); + // @see https://github.com/puppeteer/puppeteer/issues/3865 + it('should not throw when there are console messages in detached iframes', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(async () => { + // 1. Create a popup that Puppeteer is not connected to. + const win = window.open( + window.location.href, + 'Title', + 'toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=780,height=200,top=0,left=0' + )!; + await new Promise(x => { + return (win.onload = x); + }); + // 2. In this popup, create an iframe that console.logs a message. + win.document.body.innerHTML = `<iframe src='/consolelog.html'></iframe>`; + const frame = win.document.querySelector('iframe')!; + await new Promise(x => { + return (frame.onload = x); + }); + // 3. After that, remove the iframe. + frame.remove(); + }); + const popupTarget = page + .browserContext() + .targets() + .find(target => { + return target !== page.target(); + })!; + // 4. Connect to the popup and make sure it doesn't throw. + await popupTarget.page(); + }); + }); + + describe('Page.Events.DOMContentLoaded', function () { + it('should fire when expected', async () => { + const {page} = await getTestState(); + + const navigate = page.goto('about:blank'); + await Promise.all([waitEvent(page, 'domcontentloaded'), navigate]); + }); + }); + + describe('Page.metrics', function () { + it('should get metrics from a page', async () => { + const {page} = await getTestState(); + + await page.goto('about:blank'); + const metrics = await page.metrics(); + checkMetrics(metrics); + }); + it('metrics event fired on console.timeStamp', async () => { + const {page} = await getTestState(); + + const metricsPromise = waitEvent<{metrics: Metrics; title: string}>( + page, + 'metrics' + ); + + await page.evaluate(() => { + return console.timeStamp('test42'); + }); + const metrics = await metricsPromise; + expect(metrics.title).toBe('test42'); + checkMetrics(metrics.metrics); + }); + function checkMetrics(metrics: Metrics) { + const metricsToCheck = new Set([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', + ]); + for (const name in metrics) { + expect(metricsToCheck.has(name)).toBeTruthy(); + expect(metrics[name as keyof Metrics]).toBeGreaterThanOrEqual(0); + metricsToCheck.delete(name); + } + expect(metricsToCheck.size).toBe(0); + } + }); + + describe('Page.waitForRequest', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest(server.PREFIX + '/digits/2.png'), + page.evaluate(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }), + ]); + expect(request.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should work with predicate', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest(request => { + return request.url() === server.PREFIX + '/digits/2.png'; + }), + page.evaluate(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }), + ]); + expect(request.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should work with async predicate', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest(async request => { + return request.url() === server.PREFIX + '/digits/2.png'; + }), + page.evaluate(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }), + ]); + expect(request.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should respect timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .waitForRequest( + () => { + return false; + }, + {timeout: 1} + ) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should respect default timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + page.setDefaultTimeout(1); + await page + .waitForRequest(() => { + return false; + }) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should work with no timeout', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest(server.PREFIX + '/digits/2.png', {timeout: 0}), + page.evaluate(() => { + return setTimeout(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }, 50); + }), + ]); + expect(request.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + }); + + describe('Page.waitForResponse', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse(server.PREFIX + '/digits/2.png'), + page.evaluate(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }), + ]); + expect(response.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should respect timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .waitForResponse( + () => { + return false; + }, + {timeout: 1} + ) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should respect default timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + page.setDefaultTimeout(1); + await page + .waitForResponse(() => { + return false; + }) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should work with predicate', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse(response => { + return response.url() === server.PREFIX + '/digits/2.png'; + }), + page.evaluate(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }), + ]); + expect(response.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should work with async predicate', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse(async response => { + return response.url() === server.PREFIX + '/digits/2.png'; + }), + page.evaluate(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }), + ]); + expect(response.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should work with no timeout', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse(server.PREFIX + '/digits/2.png', {timeout: 0}), + page.evaluate(() => { + return setTimeout(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }, 50); + }), + ]); + expect(response.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + }); + + describe('Page.waitForNetworkIdle', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + let res; + const [t1, t2] = await Promise.all([ + page.waitForNetworkIdle().then(r => { + res = r; + return Date.now(); + }), + page + .evaluate(async () => { + await Promise.all([fetch('/digits/1.png'), fetch('/digits/2.png')]); + await new Promise(resolve => { + return setTimeout(resolve, 200); + }); + await fetch('/digits/3.png'); + await new Promise(resolve => { + return setTimeout(resolve, 200); + }); + await fetch('/digits/4.png'); + }) + .then(() => { + return Date.now(); + }), + ]); + expect(res).toBe(undefined); + expect(t1).toBeGreaterThan(t2); + expect(t1 - t2).toBeGreaterThanOrEqual(400); + }); + it('should respect timeout', async () => { + const {page} = await getTestState(); + let error!: Error; + await page.waitForNetworkIdle({timeout: 1}).catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should respect idleTime', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + const [t1, t2] = await Promise.all([ + page.waitForNetworkIdle({idleTime: 10}).then(() => { + return Date.now(); + }), + page + .evaluate(() => { + return (async () => { + await Promise.all([ + fetch('/digits/1.png'), + fetch('/digits/2.png'), + ]); + await new Promise(resolve => { + return setTimeout(resolve, 250); + }); + })(); + }) + .then(() => { + return Date.now(); + }), + ]); + expect(t2).toBeGreaterThan(t1); + }); + it('should work with no timeout', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + const [result] = await Promise.all([ + page.waitForNetworkIdle({timeout: 0}), + page.evaluate(() => { + return setTimeout(() => { + void fetch('/digits/1.png'); + void fetch('/digits/2.png'); + void fetch('/digits/3.png'); + }, 50); + }), + ]); + expect(result).toBe(undefined); + }); + it('should work with aborted requests', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/abort-request.html'); + + using element = await page.$(`#abort`); + await element!.click(); + + let error = false; + await page.waitForNetworkIdle().catch(() => { + return (error = true); + }); + + expect(error).toBe(false); + }); + it('should work with delayed response', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + let response!: ServerResponse; + server.setRoute('/fetch-request-b.js', (_req, res) => { + response = res; + }); + const t0 = Date.now(); + const [t1, t2] = await Promise.all([ + page.waitForNetworkIdle({idleTime: 100}).then(() => { + return Date.now(); + }), + new Promise<number>(res => { + setTimeout(() => { + response.end(); + res(Date.now()); + }, 300); + }), + page.evaluate(async () => { + await fetch('/fetch-request-b.js'); + }), + ]); + expect(t1).toBeGreaterThan(t2); + // request finished + idle time. + expect(t1 - t0).toBeGreaterThan(400); + // request finished + idle time - request finished. + expect(t1 - t2).toBeGreaterThanOrEqual(100); + }); + }); + + describe('Page.exposeFunction', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.exposeFunction('compute', function (a: number, b: number) { + return a * b; + }); + const result = await page.evaluate(async function () { + return (globalThis as any).compute(9, 4); + }); + expect(result).toBe(36); + }); + it('should throw exception in page context', async () => { + const {page} = await getTestState(); + + await page.exposeFunction('woof', () => { + throw new Error('WOOF WOOF'); + }); + const {message, stack} = await page.evaluate(async () => { + try { + return await ( + globalThis as unknown as {woof(): Promise<never>} + ).woof(); + } catch (error) { + return { + message: (error as Error).message, + stack: (error as Error).stack, + }; + } + }); + expect(message).toBe('WOOF WOOF'); + expect(stack).toContain('page.spec.ts'); + }); + it('should support throwing "null"', async () => { + const {page} = await getTestState(); + + await page.exposeFunction('woof', function () { + throw null; + }); + const thrown = await page.evaluate(async () => { + try { + await (globalThis as any).woof(); + return; + } catch (error) { + return error; + } + }); + expect(thrown).toBe(null); + }); + it('should be callable from-inside evaluateOnNewDocument', async () => { + const {page} = await getTestState(); + + let called = false; + await page.exposeFunction('woof', function () { + called = true; + }); + await page.evaluateOnNewDocument(() => { + return (globalThis as any).woof(); + }); + await page.reload(); + expect(called).toBe(true); + }); + it('should survive navigation', async () => { + const {page, server} = await getTestState(); + + await page.exposeFunction('compute', function (a: number, b: number) { + return a * b; + }); + + await page.goto(server.EMPTY_PAGE); + const result = await page.evaluate(async function () { + return (globalThis as any).compute(9, 4); + }); + expect(result).toBe(36); + }); + it('should await returned promise', async () => { + const {page} = await getTestState(); + + await page.exposeFunction('compute', function (a: number, b: number) { + return Promise.resolve(a * b); + }); + + const result = await page.evaluate(async function () { + return (globalThis as any).compute(3, 5); + }); + expect(result).toBe(15); + }); + it('should await returned if called from function', async () => { + const {page} = await getTestState(); + + await page.exposeFunction('compute', function (a: number, b: number) { + return Promise.resolve(a * b); + }); + + const result = await page.evaluate(async function () { + const result = await (globalThis as any).compute(3, 5); + return result; + }); + expect(result).toBe(15); + }); + it('should work on frames', async () => { + const {page, server} = await getTestState(); + + await page.exposeFunction('compute', function (a: number, b: number) { + return Promise.resolve(a * b); + }); + + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + const frame = page.frames()[1]!; + const result = await frame.evaluate(async function () { + return (globalThis as any).compute(3, 5); + }); + expect(result).toBe(15); + }); + it('should work with loading frames', async () => { + // Tries to reproduce the scenario from + // https://github.com/puppeteer/puppeteer/issues/8106 + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + let saveRequest: (value: HTTPRequest | PromiseLike<HTTPRequest>) => void; + const iframeRequest = new Promise<HTTPRequest>(resolve => { + saveRequest = resolve; + }); + page.on('request', async req => { + if (req.url().endsWith('/frames/frame.html')) { + saveRequest(req); + } else { + await req.continue(); + } + }); + + let error: Error | undefined; + const navPromise = page + .goto(server.PREFIX + '/frames/one-frame.html', { + waitUntil: 'networkidle0', + }) + .catch(err => { + error = err; + }); + const req = await iframeRequest; + // Expose function while the frame is being loaded. Loading process is + // controlled by interception. + const exposePromise = page.exposeFunction( + 'compute', + function (a: number, b: number) { + return Promise.resolve(a * b); + } + ); + await Promise.all([req.continue(), exposePromise]); + await navPromise; + expect(error).toBeUndefined(); + const frame = page.frames()[1]!; + const result = await frame.evaluate(async function () { + return (globalThis as any).compute(3, 5); + }); + expect(result).toBe(15); + }); + it('should work on frames before navigation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + await page.exposeFunction('compute', function (a: number, b: number) { + return Promise.resolve(a * b); + }); + + const frame = page.frames()[1]!; + const result = await frame.evaluate(async function () { + return (globalThis as any).compute(3, 5); + }); + expect(result).toBe(15); + }); + it('should not throw when frames detach', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await page.exposeFunction('compute', function (a: number, b: number) { + return Promise.resolve(a * b); + }); + await detachFrame(page, 'frame1'); + + await expect( + page.evaluate(async function () { + return (globalThis as any).compute(3, 5); + }) + ).resolves.toEqual(15); + }); + it('should work with complex objects', async () => { + const {page} = await getTestState(); + + await page.exposeFunction( + 'complexObject', + function (a: {x: number}, b: {x: number}) { + return {x: a.x + b.x}; + } + ); + const result = await page.evaluate(async () => { + return (globalThis as any).complexObject({x: 5}, {x: 2}); + }); + expect(result.x).toBe(7); + }); + it('should fallback to default export when passed a module object', async () => { + const {page, server} = await getTestState(); + const moduleObject = { + default: function (a: number, b: number) { + return a * b; + }, + }; + await page.goto(server.EMPTY_PAGE); + await page.exposeFunction('compute', moduleObject); + const result = await page.evaluate(async function () { + return (globalThis as any).compute(9, 4); + }); + expect(result).toBe(36); + }); + }); + + describe('Page.removeExposedFunction', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.exposeFunction('compute', function (a: number, b: number) { + return a * b; + }); + const result = await page.evaluate(async function () { + return (globalThis as any).compute(9, 4); + }); + expect(result).toBe(36); + await page.removeExposedFunction('compute'); + + let error: Error | null = null; + await page + .evaluate(async function () { + return (globalThis as any).compute(9, 4); + }) + .catch(_error => { + return (error = _error); + }); + expect(error).toBeTruthy(); + }); + }); + + describe('Page.Events.PageError', function () { + it('should fire', async () => { + const {page, server} = await getTestState(); + + const [error] = await Promise.all([ + waitEvent<Error>(page, 'pageerror', err => { + return err.message.includes('Fancy'); + }), + page.goto(server.PREFIX + '/error.html'), + ]); + expect(error.message).toContain('Fancy'); + expect(error.stack?.split('\n')[1]).toContain('error.html:13'); + }); + }); + + describe('Page.setUserAgent', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + expect( + await page.evaluate(() => { + return navigator.userAgent; + }) + ).toContain('Mozilla'); + await page.setUserAgent('foobar'); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + expect(request.headers['user-agent']).toBe('foobar'); + }); + it('should work for subframes', async () => { + const {page, server} = await getTestState(); + + expect( + await page.evaluate(() => { + return navigator.userAgent; + }) + ).toContain('Mozilla'); + await page.setUserAgent('foobar'); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + attachFrame(page, 'frame1', server.EMPTY_PAGE), + ]); + expect(request.headers['user-agent']).toBe('foobar'); + }); + it('should emulate device user-agent', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + expect( + await page.evaluate(() => { + return navigator.userAgent; + }) + ).not.toContain('iPhone'); + await page.setUserAgent(KnownDevices['iPhone 6'].userAgent); + expect( + await page.evaluate(() => { + return navigator.userAgent; + }) + ).toContain('iPhone'); + }); + it('should work with additional userAgentMetdata', async () => { + const {page, server} = await getTestState(); + + await page.setUserAgent('MockBrowser', { + architecture: 'Mock1', + mobile: false, + model: 'Mockbook', + platform: 'MockOS', + platformVersion: '3.1', + }); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + expect( + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error: userAgentData not yet in TypeScript DOM API + return navigator.userAgentData.mobile; + }) + ).toBe(false); + + const uaData = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error: userAgentData not yet in TypeScript DOM API + return navigator.userAgentData.getHighEntropyValues([ + 'architecture', + 'model', + 'platform', + 'platformVersion', + ]); + }); + expect(uaData['architecture']).toBe('Mock1'); + expect(uaData['model']).toBe('Mockbook'); + expect(uaData['platform']).toBe('MockOS'); + expect(uaData['platformVersion']).toBe('3.1'); + expect(request.headers['user-agent']).toBe('MockBrowser'); + }); + }); + + describe('Page.setContent', function () { + const expectedOutput = + '<html><head></head><body><div>hello</div></body></html>'; + it('should work', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>hello</div>'); + const result = await page.content(); + expect(result).toBe(expectedOutput); + }); + it('should work with doctype', async () => { + const {page} = await getTestState(); + + const doctype = '<!DOCTYPE html>'; + await page.setContent(`${doctype}<div>hello</div>`); + const result = await page.content(); + expect(result).toBe(`${doctype}${expectedOutput}`); + }); + it('should work with HTML 4 doctype', async () => { + const {page} = await getTestState(); + + const doctype = + '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" ' + + '"http://www.w3.org/TR/html4/strict.dtd">'; + await page.setContent(`${doctype}<div>hello</div>`); + const result = await page.content(); + expect(result).toBe(`${doctype}${expectedOutput}`); + }); + it('should respect timeout', async () => { + const {page, server} = await getTestState(); + + const imgPath = '/img.png'; + // stall for image + server.setRoute(imgPath, () => {}); + let error!: Error; + await page + .setContent(`<img src="${server.PREFIX + imgPath}"></img>`, { + timeout: 1, + }) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should respect default navigation timeout', async () => { + const {page, server} = await getTestState(); + + page.setDefaultNavigationTimeout(1); + const imgPath = '/img.png'; + // stall for image + server.setRoute(imgPath, () => {}); + let error!: Error; + await page + .setContent(`<img src="${server.PREFIX + imgPath}"></img>`) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should await resources to load', async () => { + const {page, server} = await getTestState(); + + const imgPath = '/img.png'; + let imgResponse!: ServerResponse; + server.setRoute(imgPath, (_req, res) => { + return (imgResponse = res); + }); + let loaded = false; + const contentPromise = page + .setContent(`<img src="${server.PREFIX + imgPath}"></img>`) + .then(() => { + return (loaded = true); + }); + await server.waitForRequest(imgPath); + expect(loaded).toBe(false); + imgResponse.end(); + await contentPromise; + }); + it('should work fast enough', async () => { + const {page} = await getTestState(); + + for (let i = 0; i < 20; ++i) { + await page.setContent('<div>yo</div>'); + } + }); + it('should work with tricky content', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>hello world</div>' + '\x7F'); + expect( + await page.$eval('div', div => { + return div.textContent; + }) + ).toBe('hello world'); + }); + it('should work with accents', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>aberración</div>'); + expect( + await page.$eval('div', div => { + return div.textContent; + }) + ).toBe('aberración'); + }); + it('should work with emojis', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>🐥</div>'); + expect( + await page.$eval('div', div => { + return div.textContent; + }) + ).toBe('🐥'); + }); + it('should work with newline', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>\n</div>'); + expect( + await page.$eval('div', div => { + return div.textContent; + }) + ).toBe('\n'); + }); + it('should work with comments outside HTML tag', async () => { + const {page} = await getTestState(); + + const comment = '<!-- Comment -->'; + await page.setContent(`${comment}<div>hello</div>`); + const result = await page.content(); + expect(result).toBe(`${comment}${expectedOutput}`); + }); + }); + + describe('Page.setBypassCSP', function () { + it('should bypass CSP meta tag', async () => { + const {page, server} = await getTestState(); + + // Make sure CSP prohibits addScriptTag. + await page.goto(server.PREFIX + '/csp.html'); + await page + .addScriptTag({content: 'window.__injected = 42;'}) + .catch(error => { + return void error; + }); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(undefined); + + // By-pass CSP and try one more time. + await page.setBypassCSP(true); + await page.reload(); + await page.addScriptTag({content: 'window.__injected = 42;'}); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(42); + }); + + it('should bypass CSP header', async () => { + const {page, server} = await getTestState(); + + // Make sure CSP prohibits addScriptTag. + server.setCSP('/empty.html', 'default-src "self"'); + await page.goto(server.EMPTY_PAGE); + await page + .addScriptTag({content: 'window.__injected = 42;'}) + .catch(error => { + return void error; + }); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(undefined); + + // By-pass CSP and try one more time. + await page.setBypassCSP(true); + await page.reload(); + await page.addScriptTag({content: 'window.__injected = 42;'}); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(42); + }); + + it('should bypass after cross-process navigation', async () => { + const {page, server} = await getTestState(); + + await page.setBypassCSP(true); + await page.goto(server.PREFIX + '/csp.html'); + await page.addScriptTag({content: 'window.__injected = 42;'}); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(42); + + await page.goto(server.CROSS_PROCESS_PREFIX + '/csp.html'); + await page.addScriptTag({content: 'window.__injected = 42;'}); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(42); + }); + it('should bypass CSP in iframes as well', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + { + // Make sure CSP prohibits addScriptTag in an iframe. + const frame = (await attachFrame( + page, + 'frame1', + server.PREFIX + '/csp.html' + ))!; + await frame + .addScriptTag({content: 'window.__injected = 42;'}) + .catch(error => { + return void error; + }); + expect( + await frame.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(undefined); + } + + // By-pass CSP and try one more time. + await page.setBypassCSP(true); + await page.reload(); + + { + const frame = (await attachFrame( + page, + 'frame1', + server.PREFIX + '/csp.html' + ))!; + await frame + .addScriptTag({content: 'window.__injected = 42;'}) + .catch(error => { + return void error; + }); + expect( + await frame.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(42); + } + }); + }); + + describe('Page.addScriptTag', function () { + it('should throw an error if no options are provided', async () => { + const {page} = await getTestState(); + + let error!: Error; + try { + // @ts-expect-error purposefully passing bad options + await page.addScriptTag('/injectedfile.js'); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toBe( + 'Exactly one of `url`, `path`, or `content` must be specified.' + ); + }); + + it('should work with a url', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + using scriptHandle = await page.addScriptTag({url: '/injectedfile.js'}); + expect(scriptHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(42); + }); + + it('should work with a url and type=module', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({url: '/es6/es6import.js', type: 'module'}); + expect( + await page.evaluate(() => { + return (window as unknown as {__es6injected: number}).__es6injected; + }) + ).toBe(42); + }); + + it('should work with a path and type=module', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ + path: path.join(__dirname, '../assets/es6/es6pathimport.js'), + type: 'module', + }); + await page.waitForFunction(() => { + return (window as unknown as {__es6injected: number}).__es6injected; + }); + expect( + await page.evaluate(() => { + return (window as unknown as {__es6injected: number}).__es6injected; + }) + ).toBe(42); + }); + + it('should work with a content and type=module', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ + content: `import num from '/es6/es6module.js';window.__es6injected = num;`, + type: 'module', + }); + await page.waitForFunction(() => { + return (window as unknown as {__es6injected: number}).__es6injected; + }); + expect( + await page.evaluate(() => { + return (window as unknown as {__es6injected: number}).__es6injected; + }) + ).toBe(42); + }); + + it('should throw an error if loading from url fail', async () => { + const {page, server, isFirefox} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + let error!: Error; + try { + await page.addScriptTag({url: '/nonexistfile.js'}); + } catch (error_) { + error = error_ as Error; + } + if (isFirefox) { + expect(error.message).toBeTruthy(); + } else { + expect(error.message).toContain('Could not load script'); + } + }); + + it('should work with a path', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + using scriptHandle = await page.addScriptTag({ + path: path.join(__dirname, '../assets/injectedfile.js'), + }); + expect(scriptHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(42); + }); + + it('should include sourcemap when path is provided', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ + path: path.join(__dirname, '../assets/injectedfile.js'), + }); + const result = await page.evaluate(() => { + return (globalThis as any).__injectedError.stack; + }); + expect(result).toContain(path.join('assets', 'injectedfile.js')); + }); + + it('should work with content', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + using scriptHandle = await page.addScriptTag({ + content: 'window.__injected = 35;', + }); + expect(scriptHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate(() => { + return (globalThis as any).__injected; + }) + ).toBe(35); + }); + + it('should add id when provided', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({content: 'window.__injected = 1;', id: 'one'}); + await page.addScriptTag({url: '/injectedfile.js', id: 'two'}); + expect(await page.$('#one')).not.toBeNull(); + expect(await page.$('#two')).not.toBeNull(); + }); + + // @see https://github.com/puppeteer/puppeteer/issues/4840 + it('should throw when added with content to the CSP page', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/csp.html'); + let error!: Error; + await page + .addScriptTag({content: 'window.__injected = 35;'}) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + }); + + it('should throw when added with URL to the CSP page', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/csp.html'); + let error!: Error; + await page + .addScriptTag({url: server.CROSS_PROCESS_PREFIX + '/injectedfile.js'}) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + }); + }); + + describe('Page.addStyleTag', function () { + it('should throw an error if no options are provided', async () => { + const {page} = await getTestState(); + + let error!: Error; + try { + // @ts-expect-error purposefully passing bad input + await page.addStyleTag('/injectedstyle.css'); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toBe( + 'Exactly one of `url`, `path`, or `content` must be specified.' + ); + }); + + it('should work with a url', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + using styleHandle = await page.addStyleTag({url: '/injectedstyle.css'}); + expect(styleHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate( + `window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')` + ) + ).toBe('rgb(255, 0, 0)'); + }); + + it('should throw an error if loading from url fail', async () => { + const {page, server, isFirefox} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + let error!: Error; + try { + await page.addStyleTag({url: '/nonexistfile.js'}); + } catch (error_) { + error = error_ as Error; + } + if (isFirefox) { + expect(error.message).toBeTruthy(); + } else { + expect(error.message).toContain('Could not load style'); + } + }); + + it('should work with a path', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + using styleHandle = await page.addStyleTag({ + path: path.join(__dirname, '../assets/injectedstyle.css'), + }); + expect(styleHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate( + `window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')` + ) + ).toBe('rgb(255, 0, 0)'); + }); + + it('should include sourcemap when path is provided', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addStyleTag({ + path: path.join(__dirname, '../assets/injectedstyle.css'), + }); + using styleHandle = (await page.$('style'))!; + const styleContent = await page.evaluate((style: HTMLStyleElement) => { + return style.innerHTML; + }, styleHandle); + expect(styleContent).toContain(path.join('assets', 'injectedstyle.css')); + }); + + it('should work with content', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + using styleHandle = await page.addStyleTag({ + content: 'body { background-color: green; }', + }); + expect(styleHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate( + `window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')` + ) + ).toBe('rgb(0, 128, 0)'); + }); + + it('should throw when added with content to the CSP page', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/csp.html'); + let error!: Error; + await page + .addStyleTag({content: 'body { background-color: green; }'}) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + }); + + it('should throw when added with URL to the CSP page', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/csp.html'); + let error!: Error; + await page + .addStyleTag({ + url: server.CROSS_PROCESS_PREFIX + '/injectedstyle.css', + }) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + }); + }); + + describe('Page.url', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + expect(page.url()).toBe('about:blank'); + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + }); + }); + + describe('Page.setJavaScriptEnabled', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.setJavaScriptEnabled(false); + await page.goto( + 'data:text/html, <script>var something = "forbidden"</script>' + ); + let error!: Error; + await page.evaluate('something').catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('something is not defined'); + + await page.setJavaScriptEnabled(true); + await page.goto( + 'data:text/html, <script>var something = "forbidden"</script>' + ); + expect(await page.evaluate('something')).toBe('forbidden'); + }); + }); + + describe('Page.setCacheEnabled', function () { + it('should enable or disable the cache based on the state passed', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/cached/one-style.html'); + const [cachedRequest] = await Promise.all([ + server.waitForRequest('/cached/one-style.html'), + page.reload(), + ]); + // Rely on "if-modified-since" caching in our test server. + expect(cachedRequest.headers['if-modified-since']).not.toBe(undefined); + + await page.setCacheEnabled(false); + const [nonCachedRequest] = await Promise.all([ + server.waitForRequest('/cached/one-style.html'), + page.reload(), + ]); + expect(nonCachedRequest.headers['if-modified-since']).toBe(undefined); + }); + it('should stay disabled when toggling request interception on/off', async () => { + const {page, server} = await getTestState(); + + await page.setCacheEnabled(false); + await page.setRequestInterception(true); + await page.setRequestInterception(false); + + await page.goto(server.PREFIX + '/cached/one-style.html'); + const [nonCachedRequest] = await Promise.all([ + server.waitForRequest('/cached/one-style.html'), + page.reload(), + ]); + expect(nonCachedRequest.headers['if-modified-since']).toBe(undefined); + }); + }); + + describe('Page.pdf', function () { + it('can print to PDF and save to file', async () => { + const {page, server} = await getTestState(); + + const outputFile = __dirname + '/../assets/output.pdf'; + await page.goto(server.PREFIX + '/pdf.html'); + await page.pdf({path: outputFile}); + try { + expect(fs.readFileSync(outputFile).byteLength).toBeGreaterThan(0); + } finally { + fs.unlinkSync(outputFile); + } + }); + + it('can print to PDF with accessible', async () => { + const {page, server} = await getTestState(); + + const outputFile = __dirname + '/../assets/output.pdf'; + const outputFileAccessible = + __dirname + '/../assets/output-accessible.pdf'; + await page.goto(server.PREFIX + '/pdf.html'); + await page.pdf({path: outputFile}); + await page.pdf({path: outputFileAccessible, tagged: true}); + try { + expect( + fs.readFileSync(outputFileAccessible).byteLength + ).toBeGreaterThan(fs.readFileSync(outputFile).byteLength); + } finally { + fs.unlinkSync(outputFileAccessible); + fs.unlinkSync(outputFile); + } + }); + + it('can print to PDF and stream the result', async () => { + const {page} = await getTestState(); + + const stream = await page.createPDFStream(); + let size = 0; + for await (const chunk of stream) { + size += chunk.length; + } + expect(size).toBeGreaterThan(0); + }); + + it('should respect timeout', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/pdf.html'); + + const error = await page.pdf({timeout: 1}).catch(err => { + return err; + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + }); + + describe('Page.title', function () { + it('should return the page title', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/title.html'); + expect(await page.title()).toBe('Woof-Woof'); + }); + }); + + describe('Page.select', function () { + it('should select single option', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onInput; + }) + ).toEqual(['blue']); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onChange; + }) + ).toEqual(['blue']); + }); + it('should select only first option', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue', 'green', 'red'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onInput; + }) + ).toEqual(['blue']); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onChange; + }) + ).toEqual(['blue']); + }); + it('should not throw when select causes navigation', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.$eval('select', select => { + return select.addEventListener('input', () => { + return ((window as any).location = '/empty.html'); + }); + }); + await Promise.all([ + page.select('select', 'blue'), + page.waitForNavigation(), + ]); + expect(page.url()).toContain('empty.html'); + }); + it('should select multiple options', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => { + return (globalThis as any).makeMultiple(); + }); + await page.select('select', 'blue', 'green', 'red'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onInput; + }) + ).toEqual(['blue', 'green', 'red']); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onChange; + }) + ).toEqual(['blue', 'green', 'red']); + }); + it('should respect event bubbling', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onBubblingInput; + }) + ).toEqual(['blue']); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onBubblingChange; + }) + ).toEqual(['blue']); + }); + it('should throw when element is not a <select>', async () => { + const {page, server} = await getTestState(); + + let error!: Error; + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('body', '').catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain('Element is not a <select> element.'); + }); + it('should return [] on no matched values', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select('select', '42', 'abc'); + expect(result).toEqual([]); + }); + it('should return an array of matched values', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => { + return (globalThis as any).makeMultiple(); + }); + const result = await page.select('select', 'blue', 'black', 'magenta'); + expect( + result.reduce((accumulator, current) => { + return ['blue', 'black', 'magenta'].includes(current) && accumulator; + }, true) + ).toEqual(true); + }); + it('should return an array of one element when multiple is not set', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select( + 'select', + '42', + 'blue', + 'black', + 'magenta' + ); + expect(result).toHaveLength(1); + }); + it('should return [] on no values', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select('select'); + expect(result).toEqual([]); + }); + it('should deselect all options when passed no values for a multiple select', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => { + return (globalThis as any).makeMultiple(); + }); + await page.select('select', 'blue', 'black', 'magenta'); + await page.select('select'); + expect( + await page.$eval('select', select => { + return Array.from((select as HTMLSelectElement).options).every( + option => { + return !option.selected; + } + ); + }) + ).toEqual(true); + }); + it('should deselect all options when passed no values for a select without multiple', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue', 'black', 'magenta'); + await page.select('select'); + expect( + await page.$eval('select', select => { + return Array.from((select as HTMLSelectElement).options).filter( + option => { + return option.selected; + } + )[0]!.value; + }) + ).toEqual(''); + }); + it('should throw if passed in non-strings', async () => { + const {page} = await getTestState(); + + await page.setContent('<select><option value="12"/></select>'); + let error!: Error; + try { + // @ts-expect-error purposefully passing bad input + await page.select('select', 12); + } catch (error_) { + error = error_ as Error; + } + expect(error.message).toContain('Values must be strings'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/3327 + it('should work when re-defining top-level Event class', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => { + // @ts-expect-error Expected. + return (window.Event = undefined); + }); + await page.select('select', 'blue'); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onInput; + }) + ).toEqual(['blue']); + expect( + await page.evaluate(() => { + return (globalThis as any).result.onChange; + }) + ).toEqual(['blue']); + }); + }); + + describe('Page.Events.Close', function () { + it('should work with window.close', async () => { + const {page, context} = await getTestState(); + + const newPagePromise = new Promise<Page | null>(fulfill => { + return context.once('targetcreated', target => { + return fulfill(target.page()); + }); + }); + assert(page); + await page.evaluate(() => { + return ((window as any)['newPage'] = window.open('about:blank')); + }); + const newPage = await newPagePromise; + assert(newPage); + const closedPromise = waitEvent(newPage, 'close'); + await page.evaluate(() => { + return (window as any)['newPage'].close(); + }); + await closedPromise; + }); + it('should work with page.close', async () => { + const {context} = await getTestState(); + + const newPage = await context.newPage(); + const closedPromise = waitEvent(newPage, 'close'); + await newPage.close(); + await closedPromise; + }); + }); + + describe('Page.browser', function () { + it('should return the correct browser instance', async () => { + const {page, browser} = await getTestState(); + + expect(page.browser()).toBe(browser); + }); + }); + + describe('Page.browserContext', function () { + it('should return the correct browser context instance', async () => { + const {page, context} = await getTestState(); + + expect(page.browserContext()).toBe(context); + }); + }); + + describe('Page.client', function () { + it('should return the client instance', async () => { + const {page} = await getTestState(); + expect((page as CdpPage)._client()).toBeInstanceOf(CDPSession); + }); + }); + + describe('Page.bringToFront', function () { + it('should work', async () => { + const {browser} = await getTestState(); + const page1 = await browser.newPage(); + const page2 = await browser.newPage(); + + await page1.bringToFront(); + expect( + await page1.evaluate(() => { + return document.visibilityState; + }) + ).toBe('visible'); + expect( + await page2.evaluate(() => { + return document.visibilityState; + }) + ).toBe('hidden'); + + await page2.bringToFront(); + expect( + await page1.evaluate(() => { + return document.visibilityState; + }) + ).toBe('hidden'); + expect( + await page2.evaluate(() => { + return document.visibilityState; + }) + ).toBe('visible'); + + await page1.close(); + await page2.close(); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/proxy.spec.ts b/remote/test/puppeteer/test/src/proxy.spec.ts new file mode 100644 index 0000000000..07b73cdd0d --- /dev/null +++ b/remote/test/puppeteer/test/src/proxy.spec.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2021 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IncomingMessage, Server, ServerResponse} from 'http'; +import http from 'http'; +import type {AddressInfo} from 'net'; +import os from 'os'; + +import type {TestServer} from '@pptr/testserver'; +import expect from 'expect'; + +import {getTestState, launch} from './mocha-utils.js'; + +let HOSTNAME = os.hostname(); + +// Hostname might not be always accessible in environments other than GitHub +// Actions. Therefore, we try to find an external IPv4 address to be used as a +// hostname in these tests. +const networkInterfaces = os.networkInterfaces(); +for (const key of Object.keys(networkInterfaces)) { + const interfaces = networkInterfaces[key]; + for (const net of interfaces || []) { + if (net.family === 'IPv4' && !net.internal) { + HOSTNAME = net.address; + break; + } + } +} + +/** + * Requests to localhost do not get proxied by default. Create a URL using the hostname + * instead. + */ +function getEmptyPageUrl(server: TestServer): string { + const emptyPagePath = new URL(server.EMPTY_PAGE).pathname; + + return `http://${HOSTNAME}:${server.PORT}${emptyPagePath}`; +} + +describe('request proxy', () => { + let proxiedRequestUrls: string[]; + let proxyServer: Server; + let proxyServerUrl: string; + const defaultArgs = [ + // We disable this in tests so that proxy-related tests + // don't intercept queries from this service in headful. + '--disable-features=NetworkTimeServiceQuerying', + ]; + + beforeEach(() => { + proxiedRequestUrls = []; + + proxyServer = http + .createServer( + ( + originalRequest: IncomingMessage, + originalResponse: ServerResponse + ) => { + proxiedRequestUrls.push(originalRequest.url as string); + + const proxyRequest = http.request( + originalRequest.url as string, + { + method: originalRequest.method, + headers: originalRequest.headers, + }, + proxyResponse => { + originalResponse.writeHead( + proxyResponse.statusCode as number, + proxyResponse.headers + ); + proxyResponse.pipe(originalResponse, {end: true}); + } + ); + + originalRequest.pipe(proxyRequest, {end: true}); + } + ) + .listen(); + + proxyServerUrl = `http://${HOSTNAME}:${ + (proxyServer.address() as AddressInfo).port + }`; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + proxyServer.close(error => { + if (error) { + reject(error); + } else { + resolve(undefined); + } + }); + }); + }); + + it('should proxy requests when configured', async () => { + const {server} = await getTestState({ + skipLaunch: true, + }); + const emptyPageUrl = getEmptyPageUrl(server); + const {browser, close} = await launch({ + args: [...defaultArgs, `--proxy-server=${proxyServerUrl}`], + }); + try { + const page = await browser.newPage(); + const response = (await page.goto(emptyPageUrl))!; + + expect(response.ok()).toBe(true); + expect(proxiedRequestUrls).toEqual([emptyPageUrl]); + } finally { + await close(); + } + }); + + it('should respect proxy bypass list', async () => { + const {server} = await getTestState({ + skipLaunch: true, + }); + const emptyPageUrl = getEmptyPageUrl(server); + const {browser, close} = await launch({ + args: [ + ...defaultArgs, + `--proxy-server=${proxyServerUrl}`, + `--proxy-bypass-list=${new URL(emptyPageUrl).host}`, + ], + }); + try { + const page = await browser.newPage(); + const response = (await page.goto(emptyPageUrl))!; + + expect(response.ok()).toBe(true); + expect(proxiedRequestUrls).toEqual([]); + } finally { + await close(); + } + }); + + describe('in incognito browser context', () => { + it('should proxy requests when configured at browser level', async () => { + const {server} = await getTestState({ + skipLaunch: true, + }); + const emptyPageUrl = getEmptyPageUrl(server); + const {browser, close} = await launch({ + args: [...defaultArgs, `--proxy-server=${proxyServerUrl}`], + }); + try { + const context = await browser.createIncognitoBrowserContext(); + const page = await context.newPage(); + const response = (await page.goto(emptyPageUrl))!; + + expect(response.ok()).toBe(true); + expect(proxiedRequestUrls).toEqual([emptyPageUrl]); + } finally { + await close(); + } + }); + + it('should respect proxy bypass list when configured at browser level', async () => { + const {server} = await getTestState({ + skipLaunch: true, + }); + const emptyPageUrl = getEmptyPageUrl(server); + const {browser, close} = await launch({ + args: [ + ...defaultArgs, + `--proxy-server=${proxyServerUrl}`, + `--proxy-bypass-list=${new URL(emptyPageUrl).host}`, + ], + }); + try { + const context = await browser.createIncognitoBrowserContext(); + const page = await context.newPage(); + const response = (await page.goto(emptyPageUrl))!; + + expect(response.ok()).toBe(true); + expect(proxiedRequestUrls).toEqual([]); + } finally { + await close(); + } + }); + + /** + * See issues #7873, #7719, and #7698. + */ + it('should proxy requests when configured at context level', async () => { + const {server} = await getTestState({ + skipLaunch: true, + }); + const emptyPageUrl = getEmptyPageUrl(server); + const {browser, close} = await launch({ + args: defaultArgs, + }); + try { + const context = await browser.createIncognitoBrowserContext({ + proxyServer: proxyServerUrl, + }); + const page = await context.newPage(); + const response = (await page.goto(emptyPageUrl))!; + + expect(response.ok()).toBe(true); + expect(proxiedRequestUrls).toEqual([emptyPageUrl]); + } finally { + await close(); + } + }); + + it('should respect proxy bypass list when configured at context level', async () => { + const {server} = await getTestState({ + skipLaunch: true, + }); + const emptyPageUrl = getEmptyPageUrl(server); + const {browser, close} = await launch({ + args: defaultArgs, + }); + try { + const context = await browser.createIncognitoBrowserContext({ + proxyServer: proxyServerUrl, + proxyBypassList: [new URL(emptyPageUrl).host], + }); + const page = await context.newPage(); + const response = (await page.goto(emptyPageUrl))!; + + expect(response.ok()).toBe(true); + expect(proxiedRequestUrls).toEqual([]); + } finally { + await close(); + } + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/queryhandler.spec.ts b/remote/test/puppeteer/test/src/queryhandler.spec.ts new file mode 100644 index 0000000000..05f201a9be --- /dev/null +++ b/remote/test/puppeteer/test/src/queryhandler.spec.ts @@ -0,0 +1,653 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert'; + +import expect from 'expect'; +import {Puppeteer} from 'puppeteer-core'; +import type {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('Query handler tests', function () { + setupTestBrowserHooks(); + + describe('Pierce selectors', function () { + async function setUpPage(): ReturnType<typeof getTestState> { + const state = await getTestState(); + await state.page.setContent( + `<script> + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({mode: 'open'}); + const div1 = document.createElement('div'); + div1.textContent = 'Hello'; + div1.className = 'foo'; + const div2 = document.createElement('div'); + div2.textContent = 'World'; + div2.className = 'foo'; + shadowRoot.appendChild(div1); + shadowRoot.appendChild(div2); + document.documentElement.appendChild(div); + </script>` + ); + return state; + } + it('should find first element in shadow', async () => { + const {page} = await setUpPage(); + using div = (await page.$('pierce/.foo')) as ElementHandle<HTMLElement>; + const text = await div.evaluate(element => { + return element.textContent; + }); + expect(text).toBe('Hello'); + }); + it('should find all elements in shadow', async () => { + const {page} = await setUpPage(); + const divs = (await page.$$('pierce/.foo')) as Array< + ElementHandle<HTMLElement> + >; + const text = await Promise.all( + divs.map(div => { + return div.evaluate(element => { + return element.textContent; + }); + }) + ); + expect(text.join(' ')).toBe('Hello World'); + }); + it('should find first child element', async () => { + const {page} = await setUpPage(); + using parentElement = (await page.$('html > div'))!; + using childElement = (await parentElement.$( + 'pierce/div' + )) as ElementHandle<HTMLElement>; + const text = await childElement.evaluate(element => { + return element.textContent; + }); + expect(text).toBe('Hello'); + }); + it('should find all child elements', async () => { + const {page} = await setUpPage(); + using parentElement = (await page.$('html > div'))!; + const childElements = (await parentElement.$$('pierce/div')) as Array< + ElementHandle<HTMLElement> + >; + const text = await Promise.all( + childElements.map(div => { + return div.evaluate(element => { + return element.textContent; + }); + }) + ); + expect(text.join(' ')).toBe('Hello World'); + }); + }); + + describe('Text selectors', function () { + describe('in Page', function () { + it('should query existing element', async () => { + const {page} = await getTestState(); + + await page.setContent('<section>test</section>'); + + expect(await page.$('text/test')).toBeTruthy(); + expect(await page.$$('text/test')).toHaveLength(1); + }); + it('should return empty array for non-existing element', async () => { + const {page} = await getTestState(); + + expect(await page.$('text/test')).toBeFalsy(); + expect(await page.$$('text/test')).toHaveLength(0); + }); + it('should return first element', async () => { + const {page} = await getTestState(); + + await page.setContent('<div id="1">a</div><div>a</div>'); + + using element = await page.$('text/a'); + expect( + await element?.evaluate(e => { + return e.id; + }) + ).toBe('1'); + }); + it('should return multiple elements', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>a</div><div>a</div>'); + + const elements = await page.$$('text/a'); + expect(elements).toHaveLength(2); + }); + it('should pierce shadow DOM', async () => { + const {page} = await getTestState(); + + await page.evaluate(() => { + const div = document.createElement('div'); + const shadow = div.attachShadow({mode: 'open'}); + const diva = document.createElement('div'); + shadow.append(diva); + const divb = document.createElement('div'); + shadow.append(divb); + diva.innerHTML = 'a'; + divb.innerHTML = 'b'; + document.body.append(div); + }); + + using element = await page.$('text/a'); + expect( + await element?.evaluate(e => { + return e.textContent; + }) + ).toBe('a'); + }); + it('should query deeply nested text', async () => { + const {page} = await getTestState(); + + await page.setContent('<div><div>a</div><div>b</div></div>'); + + using element = await page.$('text/a'); + expect( + await element?.evaluate(e => { + return e.textContent; + }) + ).toBe('a'); + }); + it('should query inputs', async () => { + const {page} = await getTestState(); + + await page.setContent('<input value="a">'); + + using element = (await page.$( + 'text/a' + )) as ElementHandle<HTMLInputElement>; + expect( + await element?.evaluate(e => { + return e.value; + }) + ).toBe('a'); + }); + it('should not query radio', async () => { + const {page} = await getTestState(); + + await page.setContent('<radio value="a">'); + + expect(await page.$('text/a')).toBeNull(); + }); + it('should query text spanning multiple elements', async () => { + const {page} = await getTestState(); + + await page.setContent('<div><span>a</span> <span>b</span><div>'); + + using element = await page.$('text/a b'); + expect( + await element?.evaluate(e => { + return e.textContent; + }) + ).toBe('a b'); + }); + it('should clear caches', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<div id=target1>text</div><input id=target2 value=text><div id=target3>text</div>' + ); + using div = (await page.$('#target1')) as ElementHandle<HTMLDivElement>; + using input = (await page.$( + '#target2' + )) as ElementHandle<HTMLInputElement>; + + await div.evaluate(div => { + div.textContent = 'text'; + }); + expect( + await page.$eval(`text/text`, e => { + return e.id; + }) + ).toBe('target1'); + await div.evaluate(div => { + div.textContent = 'foo'; + }); + expect( + await page.$eval(`text/text`, e => { + return e.id; + }) + ).toBe('target2'); + await input.evaluate(input => { + input.value = ''; + }); + await input.type('foo'); + expect( + await page.$eval(`text/text`, e => { + return e.id; + }) + ).toBe('target3'); + + await div.evaluate(div => { + div.textContent = 'text'; + }); + await input.evaluate(input => { + input.value = ''; + }); + await input.type('text'); + expect( + await page.$$eval(`text/text`, es => { + return es.length; + }) + ).toBe(3); + await div.evaluate(div => { + div.textContent = 'foo'; + }); + expect( + await page.$$eval(`text/text`, es => { + return es.length; + }) + ).toBe(2); + await input.evaluate(input => { + input.value = ''; + }); + await input.type('foo'); + expect( + await page.$$eval(`text/text`, es => { + return es.length; + }) + ).toBe(1); + }); + }); + describe('in ElementHandles', function () { + it('should query existing element', async () => { + const {page} = await getTestState(); + + await page.setContent('<div class="a"><span>a</span></div>'); + + using elementHandle = (await page.$('div'))!; + expect(await elementHandle.$(`text/a`)).toBeTruthy(); + expect(await elementHandle.$$(`text/a`)).toHaveLength(1); + }); + + it('should return null for non-existing element', async () => { + const {page} = await getTestState(); + + await page.setContent('<div class="a"></div>'); + + using elementHandle = (await page.$('div'))!; + expect(await elementHandle.$(`text/a`)).toBeFalsy(); + expect(await elementHandle.$$(`text/a`)).toHaveLength(0); + }); + }); + }); + + describe('XPath selectors', function () { + describe('in Page', function () { + it('should query existing element', async () => { + const {page} = await getTestState(); + + await page.setContent('<section>test</section>'); + + expect(await page.$('xpath/html/body/section')).toBeTruthy(); + expect(await page.$$('xpath/html/body/section')).toHaveLength(1); + }); + it('should return empty array for non-existing element', async () => { + const {page} = await getTestState(); + + expect( + await page.$('xpath/html/body/non-existing-element') + ).toBeFalsy(); + expect( + await page.$$('xpath/html/body/non-existing-element') + ).toHaveLength(0); + }); + it('should return first element', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>a</div><div></div>'); + + using element = await page.$('xpath/html/body/div'); + expect( + await element?.evaluate(e => { + return e.textContent === 'a'; + }) + ).toBeTruthy(); + }); + it('should return multiple elements', async () => { + const {page} = await getTestState(); + + await page.setContent('<div></div><div></div>'); + + const elements = await page.$$('xpath/html/body/div'); + expect(elements).toHaveLength(2); + }); + }); + describe('in ElementHandles', function () { + it('should query existing element', async () => { + const {page} = await getTestState(); + + await page.setContent('<div class="a">a<span></span></div>'); + + using elementHandle = (await page.$('div'))!; + expect(await elementHandle.$(`xpath/span`)).toBeTruthy(); + expect(await elementHandle.$$(`xpath/span`)).toHaveLength(1); + }); + + it('should return null for non-existing element', async () => { + const {page} = await getTestState(); + + await page.setContent('<div class="a">a</div>'); + + using elementHandle = (await page.$('div'))!; + expect(await elementHandle.$(`xpath/span`)).toBeFalsy(); + expect(await elementHandle.$$(`xpath/span`)).toHaveLength(0); + }); + }); + }); + + describe('P selectors', () => { + beforeEach(async () => { + Puppeteer.clearCustomQueryHandlers(); + }); + + it('should work with CSS selectors', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.$('div > button'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + + // Should parse more complex CSS selectors. Listing a few problematic + // cases from bug reports. + for (const selector of [ + '.user_row[data-user-id="\\38 "]:not(.deactivated_user)', + `input[value='Search']:not([class='hidden'])`, + `[data-test-id^="test-"]:not([data-test-id^="test-foo"])`, + ]) { + await page.$$(selector); + } + }); + + it('should work with deep combinators', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + { + using element = await page.$('div >>>> div'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'c'; + }) + ).toBeTruthy(); + } + { + const elements = await page.$$('div >>> div'); + assert(elements[1], 'Could not find element'); + expect( + await elements[1]?.evaluate(element => { + return element.id === 'd'; + }) + ).toBeTruthy(); + } + { + const elements = await page.$$('#c >>>> div'); + assert(elements[0], 'Could not find element'); + expect( + await elements[0]?.evaluate(element => { + return element.id === 'd'; + }) + ).toBeTruthy(); + } + { + const elements = await page.$$('#c >>> div'); + assert(elements[0], 'Could not find element'); + expect( + await elements[0]?.evaluate(element => { + return element.id === 'd'; + }) + ).toBeTruthy(); + } + }); + + it('should work with text selectors', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.$('div ::-p-text(world)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + }); + + it('should work ARIA selectors', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.$('div ::-p-aria(world)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + }); + + it('should work for ARIA selectors in multiple isolated worlds', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.waitForSelector('::-p-aria(world)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + // $ would add ARIA query handler to the main world. + await element.$('::-p-aria(world)'); + using element2 = await page.waitForSelector('::-p-aria(world)'); + assert(element2, 'Could not find element'); + expect( + await element2.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + }); + + it('should work ARIA selectors with role', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.$('::-p-aria(world[role="button"])'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + }); + + it('should work ARIA selectors with name and role', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.$('::-p-aria([name="world"][role="button"])'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + }); + + it('should work XPath selectors', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.$('div ::-p-xpath(//button)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + }); + + it('should work with custom selectors', async () => { + Puppeteer.registerCustomQueryHandler('div', { + queryOne() { + return document.querySelector('div'); + }, + }); + + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.$('::-p-div'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'a'; + }) + ).toBeTruthy(); + }); + + it('should work with custom selectors with args', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + Puppeteer.registerCustomQueryHandler('div', { + queryOne(_, selector) { + if (selector === 'true') { + return document.querySelector('div'); + } else { + return document.querySelector('button'); + } + }, + }); + + { + using element = await page.$('::-p-div(true)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'a'; + }) + ).toBeTruthy(); + } + { + using element = await page.$('::-p-div("true")'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'a'; + }) + ).toBeTruthy(); + } + { + using element = await page.$("::-p-div('true')"); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'a'; + }) + ).toBeTruthy(); + } + { + using element = await page.$('::-p-div'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.id === 'b'; + }) + ).toBeTruthy(); + } + }); + + it('should work with :hover', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using button = await page.$('div ::-p-text(world)'); + assert(button, 'Could not find element'); + await button.hover(); + + using button2 = await page.$('div ::-p-text(world):hover'); + assert(button2, 'Could not find element'); + const value = await button2.evaluate(span => { + return {textContent: span.textContent, tagName: span.tagName}; + }); + expect(value).toMatchObject({textContent: 'world', tagName: 'BUTTON'}); + }); + + it('should work with selector lists', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + const elements = await page.$$('div, ::-p-text(world)'); + expect(elements).toHaveLength(3); + }); + + const permute = <T>(inputs: T[]): T[][] => { + const results: T[][] = []; + for (let i = 0; i < inputs.length; ++i) { + const permutation = permute( + inputs.slice(0, i).concat(inputs.slice(i + 1)) + ); + const value = inputs[i] as T; + if (permutation.length === 0) { + results.push([value]); + continue; + } + for (const part of permutation) { + results.push([value].concat(part)); + } + } + return results; + }; + + it('should match querySelector* ordering', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + for (const list of permute(['div', 'button', 'span'])) { + const elements = await page.$$( + list + .map(selector => { + return selector === 'button' ? '::-p-text(world)' : selector; + }) + .join(',') + ); + const actual = await Promise.all( + elements.map(element => { + return element.evaluate(element => { + return element.id; + }); + }) + ); + expect(actual.join()).toStrictEqual('a,b,f,c'); + } + }); + + it('should not have duplicate elements from selector lists', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + const elements = await page.$$('::-p-text(world), button'); + expect(elements).toHaveLength(1); + }); + + it('should handle escapes', async () => { + const {server, page} = await getTestState(); + await page.goto(`${server.PREFIX}/p-selectors.html`); + using element = await page.$( + ':scope >>> ::-p-text(My name is Jun \\(pronounced like "June"\\))' + ); + expect(element).toBeTruthy(); + using element2 = await page.$( + ':scope >>> ::-p-text("My name is Jun (pronounced like \\"June\\")")' + ); + expect(element2).toBeTruthy(); + using element3 = await page.$( + ':scope >>> ::-p-text(My name is Jun \\(pronounced like "June"\\)")' + ); + expect(element3).toBeFalsy(); + using element4 = await page.$( + ':scope >>> ::-p-text("My name is Jun \\(pronounced like "June"\\))' + ); + expect(element4).toBeFalsy(); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/queryselector.spec.ts b/remote/test/puppeteer/test/src/queryselector.spec.ts new file mode 100644 index 0000000000..7fd27f914f --- /dev/null +++ b/remote/test/puppeteer/test/src/queryselector.spec.ts @@ -0,0 +1,491 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import expect from 'expect'; +import {Puppeteer} from 'puppeteer'; +import type {CustomQueryHandler} from 'puppeteer-core/internal/common/CustomQueryHandler.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +describe('querySelector', function () { + setupTestBrowserHooks(); + + describe('Page.$eval', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.setContent('<section id="testAttribute">43543</section>'); + const idAttribute = await page.$eval('section', e => { + return e.id; + }); + expect(idAttribute).toBe('testAttribute'); + }); + it('should accept arguments', async () => { + const {page} = await getTestState(); + + await page.setContent('<section>hello</section>'); + const text = await page.$eval( + 'section', + (e, suffix) => { + return e.textContent! + suffix; + }, + ' world!' + ); + expect(text).toBe('hello world!'); + }); + it('should accept ElementHandles as arguments', async () => { + const {page} = await getTestState(); + + await page.setContent('<section>hello</section><div> world</div>'); + using divHandle = (await page.$('div'))!; + const text = await page.$eval( + 'section', + (e, div) => { + return e.textContent! + (div as HTMLElement).textContent!; + }, + divHandle + ); + expect(text).toBe('hello world'); + }); + it('should throw error if no element is found', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .$eval('section', e => { + return e.id; + }) + .catch(error_ => { + return (error = error_); + }); + expect(error.message).toContain( + 'failed to find element matching selector "section"' + ); + }); + }); + + // The tests for $$eval are repeated later in this file in the test group 'QueryAll'. + // This is done to also test a query handler where QueryAll returns an Element[] + // as opposed to NodeListOf<Element>. + describe('Page.$$eval', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<div>hello</div><div>beautiful</div><div>world!</div>' + ); + const divsCount = await page.$$eval('div', divs => { + return divs.length; + }); + expect(divsCount).toBe(3); + }); + it('should accept extra arguments', async () => { + const {page} = await getTestState(); + await page.setContent( + '<div>hello</div><div>beautiful</div><div>world!</div>' + ); + const divsCountPlus5 = await page.$$eval( + 'div', + (divs, two, three) => { + return divs.length + (two as number) + (three as number); + }, + 2, + 3 + ); + expect(divsCountPlus5).toBe(8); + }); + it('should accept ElementHandles as arguments', async () => { + const {page} = await getTestState(); + await page.setContent( + '<section>2</section><section>2</section><section>1</section><div>3</div>' + ); + using divHandle = (await page.$('div'))!; + const sum = await page.$$eval( + 'section', + (sections, div) => { + return ( + sections.reduce((acc, section) => { + return acc + Number(section.textContent); + }, 0) + Number((div as HTMLElement).textContent) + ); + }, + divHandle + ); + expect(sum).toBe(8); + }); + it('should handle many elements', async function () { + this.timeout(25_000); + + const {page} = await getTestState(); + await page.evaluate( + ` + for (var i = 0; i <= 1000; i++) { + const section = document.createElement('section'); + section.textContent = i; + document.body.appendChild(section); + } + ` + ); + const sum = await page.$$eval('section', sections => { + return sections.reduce((acc, section) => { + return acc + Number(section.textContent); + }, 0); + }); + expect(sum).toBe(500500); + }); + }); + + describe('Page.$', function () { + it('should query existing element', async () => { + const {page} = await getTestState(); + + await page.setContent('<section>test</section>'); + using element = (await page.$('section'))!; + expect(element).toBeTruthy(); + }); + it('should return null for non-existing element', async () => { + const {page} = await getTestState(); + + using element = (await page.$('non-existing-element'))!; + expect(element).toBe(null); + }); + }); + + describe('Page.$$', function () { + it('should query existing elements', async () => { + const {page} = await getTestState(); + + await page.setContent('<div>A</div><br/><div>B</div>'); + const elements = await page.$$('div'); + expect(elements).toHaveLength(2); + const promises = elements.map(element => { + return page.evaluate((e: HTMLElement) => { + return e.textContent; + }, element); + }); + expect(await Promise.all(promises)).toEqual(['A', 'B']); + }); + it('should return empty array if nothing is found', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const elements = await page.$$('div'); + expect(elements).toHaveLength(0); + }); + }); + + describe('Page.$x', function () { + it('should query existing element', async () => { + const {page} = await getTestState(); + + await page.setContent('<section>test</section>'); + const elements = await page.$x('/html/body/section'); + expect(elements[0]).toBeTruthy(); + expect(elements).toHaveLength(1); + }); + it('should return empty array for non-existing element', async () => { + const {page} = await getTestState(); + + const element = await page.$x('/html/body/non-existing-element'); + expect(element).toEqual([]); + }); + it('should return multiple elements', async () => { + const {page} = await getTestState(); + + await page.setContent('<div></div><div></div>'); + const elements = await page.$x('/html/body/div'); + expect(elements).toHaveLength(2); + }); + }); + + describe('ElementHandle.$', function () { + it('should query existing element', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/playground.html'); + await page.setContent( + '<html><body><div class="second"><div class="inner">A</div></div></body></html>' + ); + using html = (await page.$('html'))!; + using second = (await html.$('.second'))!; + using inner = await second.$('.inner'); + const content = await page.evaluate(e => { + return e?.textContent; + }, inner); + expect(content).toBe('A'); + }); + + it('should return null for non-existing element', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<html><body><div class="second"><div class="inner">B</div></div></body></html>' + ); + using html = (await page.$('html'))!; + using second = await html.$('.third'); + expect(second).toBe(null); + }); + }); + describe('ElementHandle.$eval', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<html><body><div class="tweet"><div class="like">100</div><div class="retweets">10</div></div></body></html>' + ); + using tweet = (await page.$('.tweet'))!; + const content = await tweet.$eval('.like', node => { + return (node as HTMLElement).innerText; + }); + expect(content).toBe('100'); + }); + + it('should retrieve content from subtree', async () => { + const {page} = await getTestState(); + + const htmlContent = + '<div class="a">not-a-child-div</div><div id="myId"><div class="a">a-child-div</div></div>'; + await page.setContent(htmlContent); + using elementHandle = (await page.$('#myId'))!; + const content = await elementHandle.$eval('.a', node => { + return (node as HTMLElement).innerText; + }); + expect(content).toBe('a-child-div'); + }); + + it('should throw in case of missing selector', async () => { + const {page} = await getTestState(); + + const htmlContent = + '<div class="a">not-a-child-div</div><div id="myId"></div>'; + await page.setContent(htmlContent); + using elementHandle = (await page.$('#myId'))!; + const errorMessage = await elementHandle + .$eval('.a', node => { + return (node as HTMLElement).innerText; + }) + .catch(error => { + return error.message; + }); + expect(errorMessage).toBe( + `Error: failed to find element matching selector ".a"` + ); + }); + }); + describe('ElementHandle.$$eval', function () { + it('should work', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<html><body><div class="tweet"><div class="like">100</div><div class="like">10</div></div></body></html>' + ); + using tweet = (await page.$('.tweet'))!; + const content = await tweet.$$eval('.like', nodes => { + return (nodes as HTMLElement[]).map(n => { + return n.innerText; + }); + }); + expect(content).toEqual(['100', '10']); + }); + + it('should retrieve content from subtree', async () => { + const {page} = await getTestState(); + + const htmlContent = + '<div class="a">not-a-child-div</div><div id="myId"><div class="a">a1-child-div</div><div class="a">a2-child-div</div></div>'; + await page.setContent(htmlContent); + using elementHandle = (await page.$('#myId'))!; + const content = await elementHandle.$$eval('.a', nodes => { + return (nodes as HTMLElement[]).map(n => { + return n.innerText; + }); + }); + expect(content).toEqual(['a1-child-div', 'a2-child-div']); + }); + + it('should not throw in case of missing selector', async () => { + const {page} = await getTestState(); + + const htmlContent = + '<div class="a">not-a-child-div</div><div id="myId"></div>'; + await page.setContent(htmlContent); + using elementHandle = (await page.$('#myId'))!; + const nodesLength = await elementHandle.$$eval('.a', nodes => { + return nodes.length; + }); + expect(nodesLength).toBe(0); + }); + }); + + describe('ElementHandle.$$', function () { + it('should query existing elements', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<html><body><div>A</div><br/><div>B</div></body></html>' + ); + using html = (await page.$('html'))!; + const elements = await html.$$('div'); + expect(elements).toHaveLength(2); + const promises = elements.map(element => { + return page.evaluate((e: HTMLElement) => { + return e.textContent; + }, element); + }); + expect(await Promise.all(promises)).toEqual(['A', 'B']); + }); + + it('should return empty array for non-existing elements', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<html><body><span>A</span><br/><span>B</span></body></html>' + ); + using html = (await page.$('html'))!; + const elements = await html.$$('div'); + expect(elements).toHaveLength(0); + }); + }); + + describe('ElementHandle.$x', function () { + it('should query existing element', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.PREFIX + '/playground.html'); + await page.setContent( + '<html><body><div class="second"><div class="inner">A</div></div></body></html>' + ); + using html = (await page.$('html'))!; + const second = await html.$x(`./body/div[contains(@class, 'second')]`); + const inner = await second[0]!.$x(`./div[contains(@class, 'inner')]`); + const content = await page.evaluate(e => { + return e.textContent; + }, inner[0]!); + expect(content).toBe('A'); + }); + + it('should return null for non-existing element', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<html><body><div class="second"><div class="inner">B</div></div></body></html>' + ); + using html = (await page.$('html'))!; + const second = await html.$x(`/div[contains(@class, 'third')]`); + expect(second).toEqual([]); + }); + }); + + // This is the same tests for `$$eval` and `$$` as above, but with a queryAll + // handler that returns an array instead of a list of nodes. + describe('QueryAll', function () { + const handler: CustomQueryHandler = { + queryAll: (element, selector) => { + return [...(element as Element).querySelectorAll(selector)]; + }, + }; + before(() => { + Puppeteer.registerCustomQueryHandler('allArray', handler); + }); + + it('should have registered handler', async () => { + expect( + Puppeteer.customQueryHandlerNames().includes('allArray') + ).toBeTruthy(); + }); + it('$$ should query existing elements', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<html><body><div>A</div><br/><div>B</div></body></html>' + ); + using html = (await page.$('html'))!; + const elements = await html.$$('allArray/div'); + expect(elements).toHaveLength(2); + const promises = elements.map(element => { + return page.evaluate(e => { + return e.textContent; + }, element); + }); + expect(await Promise.all(promises)).toEqual(['A', 'B']); + }); + + it('$$ should return empty array for non-existing elements', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<html><body><span>A</span><br/><span>B</span></body></html>' + ); + using html = (await page.$('html'))!; + const elements = await html.$$('allArray/div'); + expect(elements).toHaveLength(0); + }); + it('$$eval should work', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<div>hello</div><div>beautiful</div><div>world!</div>' + ); + const divsCount = await page.$$eval('allArray/div', divs => { + return divs.length; + }); + expect(divsCount).toBe(3); + }); + it('$$eval should accept extra arguments', async () => { + const {page} = await getTestState(); + await page.setContent( + '<div>hello</div><div>beautiful</div><div>world!</div>' + ); + const divsCountPlus5 = await page.$$eval( + 'allArray/div', + (divs, two, three) => { + return divs.length + (two as number) + (three as number); + }, + 2, + 3 + ); + expect(divsCountPlus5).toBe(8); + }); + it('$$eval should accept ElementHandles as arguments', async () => { + const {page} = await getTestState(); + await page.setContent( + '<section>2</section><section>2</section><section>1</section><div>3</div>' + ); + using divHandle = (await page.$('div'))!; + const sum = await page.$$eval( + 'allArray/section', + (sections, div) => { + return ( + sections.reduce((acc, section) => { + return acc + Number(section.textContent); + }, 0) + Number((div as HTMLElement).textContent) + ); + }, + divHandle + ); + expect(sum).toBe(8); + }); + it('$$eval should handle many elements', async function () { + this.timeout(25_000); + + const {page} = await getTestState(); + await page.evaluate( + ` + for (var i = 0; i <= 1000; i++) { + const section = document.createElement('section'); + section.textContent = i; + document.body.appendChild(section); + } + ` + ); + const sum = await page.$$eval('allArray/section', sections => { + return sections.reduce((acc, section) => { + return acc + Number(section.textContent); + }, 0); + }); + expect(sum).toBe(500500); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/requestinterception-experimental.spec.ts b/remote/test/puppeteer/test/src/requestinterception-experimental.spec.ts new file mode 100644 index 0000000000..966554fd5d --- /dev/null +++ b/remote/test/puppeteer/test/src/requestinterception-experimental.spec.ts @@ -0,0 +1,969 @@ +/** + * @license + * Copyright 2021 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; + +import expect from 'expect'; +import { + type ActionResult, + type HTTPRequest, + InterceptResolutionAction, +} from 'puppeteer-core/internal/api/HTTPRequest.js'; +import type {ConsoleMessage} from 'puppeteer-core/internal/common/ConsoleMessage.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {isFavicon, waitEvent} from './utils.js'; + +describe('cooperative request interception', function () { + setupTestBrowserHooks(); + + describe('Page.setRequestInterception', function () { + const expectedActions: ActionResult[] = ['abort', 'continue', 'respond']; + while (expectedActions.length > 0) { + const expectedAction = expectedActions.pop(); + it(`should cooperatively ${expectedAction} by priority`, async () => { + const {page, server} = await getTestState(); + + const actionResults: ActionResult[] = []; + await page.setRequestInterception(true); + page.on('request', request => { + if (request.url().endsWith('.css')) { + void request.continue( + {headers: {...request.headers(), xaction: 'continue'}}, + expectedAction === 'continue' ? 1 : 0 + ); + } else { + void request.continue({}, 0); + } + }); + page.on('request', request => { + if (request.url().endsWith('.css')) { + void request.respond( + {headers: {xaction: 'respond'}}, + expectedAction === 'respond' ? 1 : 0 + ); + } else { + void request.continue({}, 0); + } + }); + page.on('request', request => { + if (request.url().endsWith('.css')) { + void request.abort('aborted', expectedAction === 'abort' ? 1 : 0); + } else { + void request.continue({}, 0); + } + }); + page.on('response', response => { + const {xaction} = response!.headers(); + if (response!.url().endsWith('.css') && !!xaction) { + actionResults.push(xaction as ActionResult); + } + }); + page.on('requestfailed', request => { + if (request.url().endsWith('.css')) { + actionResults.push('abort'); + } + }); + + const response = (await (async () => { + if (expectedAction === 'continue') { + const [serverRequest, response] = await Promise.all([ + server.waitForRequest('/one-style.css'), + page.goto(server.PREFIX + '/one-style.html'), + ]); + actionResults.push( + serverRequest.headers['xaction'] as ActionResult + ); + return response; + } else { + return await page.goto(server.PREFIX + '/one-style.html'); + } + })())!; + + expect(actionResults).toHaveLength(1); + expect(actionResults[0]).toBe(expectedAction); + expect(response!.ok()).toBe(true); + }); + } + + it('should intercept', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + if (isFavicon(request)) { + void request.continue({}, 0); + return; + } + expect(request.url()).toContain('empty.html'); + expect(request.headers()['user-agent']).toBeTruthy(); + expect(request.method()).toBe('GET'); + expect(request.postData()).toBe(undefined); + expect(request.isNavigationRequest()).toBe(true); + expect(request.resourceType()).toBe('document'); + expect(request.frame() === page.mainFrame()).toBe(true); + expect(request.frame()!.url()).toBe('about:blank'); + void request.continue({}, 0); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response!.ok()).toBe(true); + expect(response!.remoteAddress().port).toBe(server.PORT); + }); + // @see https://github.com/puppeteer/puppeteer/pull/3105 + it('should work when POST is redirected with 302', async () => { + const {page, server} = await getTestState(); + + server.setRedirect('/rredirect', '/empty.html'); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue({}, 0); + }); + await page.setContent(` + <form action='/rredirect' method='post'> + <input type="hidden" id="foo" name="foo" value="FOOBAR"> + </form> + `); + await Promise.all([ + page.$eval('form', form => { + return (form as HTMLFormElement).submit(); + }), + page.waitForNavigation(), + ]); + }); + // @see https://github.com/puppeteer/puppeteer/issues/3973 + it('should work when header manipulation headers with redirect', async () => { + const {page, server} = await getTestState(); + + server.setRedirect('/rrredirect', '/empty.html'); + await page.setRequestInterception(true); + page.on('request', request => { + const headers = Object.assign({}, request.headers(), { + foo: 'bar', + }); + void request.continue({headers}, 0); + + expect(request.continueRequestOverrides()).toEqual({headers}); + }); + // Make sure that the goto does not time out. + await page.goto(server.PREFIX + '/rrredirect'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4743 + it('should be able to remove headers', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + const headers = Object.assign({}, request.headers(), { + foo: 'bar', + origin: undefined, // remove "origin" header + }); + void request.continue({headers}, 0); + }); + + const [serverRequest] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.PREFIX + '/empty.html'), + ]); + + expect(serverRequest.headers.origin).toBe(undefined); + }); + it('should contain referer header', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + if (!isFavicon(request)) { + requests.push(request); + } + void request.continue({}, 0); + }); + await page.goto(server.PREFIX + '/one-style.html'); + expect(requests[1]!.url()).toContain('/one-style.css'); + expect(requests[1]!.headers()['referer']).toContain('/one-style.html'); + }); + it('should properly return navigation response when URL has cookies', async () => { + const {page, server} = await getTestState(); + + // Setup cookie. + await page.goto(server.EMPTY_PAGE); + await page.setCookie({name: 'foo', value: 'bar'}); + + // Setup request interception. + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue({}, 0); + }); + const response = await page.reload(); + expect(response!.status()).toBe(200); + }); + it('should stop intercepting', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.once('request', request => { + return request.continue({}, 0); + }); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(false); + await page.goto(server.EMPTY_PAGE); + }); + it('should show custom HTTP headers', async () => { + const {page, server} = await getTestState(); + + await page.setExtraHTTPHeaders({ + foo: 'bar', + }); + await page.setRequestInterception(true); + page.on('request', request => { + expect(request.headers()['foo']).toBe('bar'); + void request.continue({}, 0); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response!.ok()).toBe(true); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4337 + it('should work with redirect inside sync XHR', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + server.setRedirect('/logo.png', '/pptr.png'); + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue({}, 0); + }); + const status = await page.evaluate(async () => { + const request = new XMLHttpRequest(); + request.open('GET', '/logo.png', false); // `false` makes the request synchronous + request.send(null); + return request.status; + }); + expect(status).toBe(200); + }); + it('should work with custom referer headers', async () => { + const {page, server} = await getTestState(); + + await page.setExtraHTTPHeaders({referer: server.EMPTY_PAGE}); + await page.setRequestInterception(true); + page.on('request', request => { + expect(request.headers()['referer']).toBe(server.EMPTY_PAGE); + void request.continue({}, 0); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response!.ok()).toBe(true); + }); + it('should be abortable', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + if (request.url().endsWith('.css')) { + void request.abort('failed', 0); + } else { + void request.continue({}, 0); + } + }); + let failedRequests = 0; + page.on('requestfailed', () => { + return ++failedRequests; + }); + const response = await page.goto(server.PREFIX + '/one-style.html'); + expect(response!.ok()).toBe(true); + expect(response!.request().failure()).toBe(null); + expect(failedRequests).toBe(1); + }); + it('should be able to access the error reason', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.abort('failed', 0); + }); + let abortReason = null; + page.on('request', request => { + abortReason = request.abortErrorReason(); + void request.continue({}, 0); + }); + await page.goto(server.EMPTY_PAGE).catch(() => {}); + expect(abortReason).toBe('Failed'); + }); + it('should be abortable with custom error codes', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.abort('internetdisconnected', 0); + }); + + const [failedRequest] = await Promise.all([ + waitEvent<HTTPRequest>(page, 'requestfailed'), + page.goto(server.EMPTY_PAGE).catch(() => {}), + ]); + expect(failedRequest).toBeTruthy(); + expect(failedRequest.failure()!.errorText).toBe( + 'net::ERR_INTERNET_DISCONNECTED' + ); + }); + it('should send referer', async () => { + const {page, server} = await getTestState(); + + await page.setExtraHTTPHeaders({ + referer: 'http://google.com/', + }); + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue({}, 0); + }); + const [request] = await Promise.all([ + server.waitForRequest('/grid.html'), + page.goto(server.PREFIX + '/grid.html'), + ]); + expect(request.headers['referer']).toBe('http://google.com/'); + }); + it('should fail navigation when aborting main resource', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + return request.abort('failed', 0); + }); + let error!: Error; + await page.goto(server.EMPTY_PAGE).catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + if (isChrome) { + expect(error.message).toContain('net::ERR_FAILED'); + } else { + expect(error.message).toContain('NS_ERROR_FAILURE'); + } + }); + it('should work with redirects', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + void request.continue({}, 0); + requests.push(request); + }); + server.setRedirect( + '/non-existing-page.html', + '/non-existing-page-2.html' + ); + server.setRedirect( + '/non-existing-page-2.html', + '/non-existing-page-3.html' + ); + server.setRedirect( + '/non-existing-page-3.html', + '/non-existing-page-4.html' + ); + server.setRedirect('/non-existing-page-4.html', '/empty.html'); + const response = await page.goto( + server.PREFIX + '/non-existing-page.html' + ); + expect(response!.status()).toBe(200); + expect(response!.url()).toContain('empty.html'); + expect(requests).toHaveLength(5); + expect(requests[2]!.resourceType()).toBe('document'); + // Check redirect chain + const redirectChain = response!.request().redirectChain(); + expect(redirectChain).toHaveLength(4); + expect(redirectChain[0]!.url()).toContain('/non-existing-page.html'); + expect(redirectChain[2]!.url()).toContain('/non-existing-page-3.html'); + for (let i = 0; i < redirectChain.length; ++i) { + const request = redirectChain[i]!; + expect(request.isNavigationRequest()).toBe(true); + expect(request.redirectChain().indexOf(request)).toBe(i); + } + }); + it('should work with redirects for subresources', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + void request.continue({}, 0); + if (!isFavicon(request)) { + requests.push(request); + } + }); + server.setRedirect('/one-style.css', '/two-style.css'); + server.setRedirect('/two-style.css', '/three-style.css'); + server.setRedirect('/three-style.css', '/four-style.css'); + server.setRoute('/four-style.css', (_req, res) => { + return res.end('body {box-sizing: border-box; }'); + }); + + const response = await page.goto(server.PREFIX + '/one-style.html'); + expect(response!.status()).toBe(200); + expect(response!.url()).toContain('one-style.html'); + expect(requests).toHaveLength(5); + expect(requests[0]!.resourceType()).toBe('document'); + expect(requests[1]!.resourceType()).toBe('stylesheet'); + // Check redirect chain + const redirectChain = requests[1]!.redirectChain(); + expect(redirectChain).toHaveLength(3); + expect(redirectChain[0]!.url()).toContain('/one-style.css'); + expect(redirectChain[2]!.url()).toContain('/three-style.css'); + }); + it('should be able to abort redirects', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.setRequestInterception(true); + server.setRedirect('/non-existing.json', '/non-existing-2.json'); + server.setRedirect('/non-existing-2.json', '/simple.html'); + page.on('request', request => { + if (request.url().includes('non-existing-2')) { + void request.abort('failed', 0); + } else { + void request.continue({}, 0); + } + }); + await page.goto(server.EMPTY_PAGE); + const result = await page.evaluate(async () => { + try { + return await fetch('/non-existing.json'); + } catch (error) { + return (error as Error).message; + } + }); + if (isChrome) { + expect(result).toContain('Failed to fetch'); + } else { + expect(result).toContain('NetworkError'); + } + }); + it('should work with equal requests', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + let responseCount = 1; + server.setRoute('/zzz', (_req, res) => { + return res.end(responseCount++ * 11 + ''); + }); + await page.setRequestInterception(true); + + let spinner = false; + // Cancel 2nd request. + page.on('request', request => { + if (isFavicon(request)) { + void request.continue({}, 0); + return; + } + void (spinner ? request.abort('failed', 0) : request.continue({}, 0)); + spinner = !spinner; + }); + const results = await page.evaluate(() => { + return Promise.all([ + fetch('/zzz') + .then(response => { + return response!.text(); + }) + .catch(() => { + return 'FAILED'; + }), + fetch('/zzz') + .then(response => { + return response!.text(); + }) + .catch(() => { + return 'FAILED'; + }), + fetch('/zzz') + .then(response => { + return response!.text(); + }) + .catch(() => { + return 'FAILED'; + }), + ]); + }); + expect(results).toEqual(['11', 'FAILED', '22']); + }); + it('should navigate to dataURL and fire dataURL requests', async () => { + const {page} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + requests.push(request); + void request.continue({}, 0); + }); + const dataURL = 'data:text/html,<div>yo</div>'; + const response = await page.goto(dataURL); + expect(response!.status()).toBe(200); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(dataURL); + }); + it('should be able to fetch dataURL and fire dataURL requests', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + !isFavicon(request) && requests.push(request); + void request.continue({}, 0); + }); + const dataURL = 'data:text/html,<div>yo</div>'; + const text = await page.evaluate((url: string) => { + return fetch(url).then(r => { + return r.text(); + }); + }, dataURL); + expect(text).toBe('<div>yo</div>'); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(dataURL); + }); + it('should navigate to URL with hash and fire requests without hash', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + requests.push(request); + void request.continue({}, 0); + }); + const response = await page.goto(server.EMPTY_PAGE + '#hash'); + expect(response!.status()).toBe(200); + expect(response!.url()).toBe(server.EMPTY_PAGE); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(server.EMPTY_PAGE); + }); + it('should work with encoded server', async () => { + const {page, server} = await getTestState(); + + // The requestWillBeSent will report encoded URL, whereas interception will + // report URL as-is. @see crbug.com/759388 + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue({}, 0); + }); + const response = await page.goto( + server.PREFIX + '/some nonexisting page' + ); + expect(response!.status()).toBe(404); + }); + it('should work with badly encoded server', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + server.setRoute('/malformed?rnd=%911', (_req, res) => { + return res.end(); + }); + page.on('request', request => { + return request.continue({}, 0); + }); + const response = await page.goto(server.PREFIX + '/malformed?rnd=%911'); + expect(response!.status()).toBe(200); + }); + it('should work with encoded server - 2', async () => { + const {page, server} = await getTestState(); + + // The requestWillBeSent will report URL as-is, whereas interception will + // report encoded URL for stylesheet. @see crbug.com/759388 + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + void request.continue({}, 0); + requests.push(request); + }); + const response = await page.goto( + `data:text/html,<link rel="stylesheet" href="${server.PREFIX}/fonts?helvetica|arial"/>` + ); + expect(response!.status()).toBe(200); + expect(requests).toHaveLength(2); + expect(requests[1]!.response()!.status()).toBe(404); + }); + it('should not throw "Invalid Interception Id" if the request was cancelled', async () => { + const {page, server} = await getTestState(); + + await page.setContent('<iframe></iframe>'); + await page.setRequestInterception(true); + let request!: HTTPRequest; + page.on('request', async r => { + return (request = r); + }); + void (page.$eval( + 'iframe', + (frame, url) => { + return ((frame as HTMLIFrameElement).src = url as string); + }, + server.EMPTY_PAGE + ), + // Wait for request interception. + await waitEvent(page, 'request')); + // Delete frame to cause request to be canceled. + await page.$eval('iframe', frame => { + return frame.remove(); + }); + let error!: Error; + await request.continue({}, 0).catch(error_ => { + return (error = error_); + }); + expect(error).toBeUndefined(); + }); + it('should throw if interception is not enabled', async () => { + const {page, server} = await getTestState(); + + let error!: Error; + page.on('request', async request => { + try { + await request.continue({}, 0); + } catch (error_) { + error = error_ as Error; + } + }); + await page.goto(server.EMPTY_PAGE); + expect(error.message).toContain('Request Interception is not enabled'); + }); + it('should work with file URLs', async () => { + const {page} = await getTestState(); + + await page.setRequestInterception(true); + const urls = new Set(); + page.on('request', request => { + urls.add(request.url().split('/').pop()); + void request.continue({}, 0); + }); + await page.goto( + pathToFileURL(path.join(__dirname, '../assets', 'one-style.html')) + ); + expect(urls.size).toBe(2); + expect(urls.has('one-style.html')).toBe(true); + expect(urls.has('one-style.css')).toBe(true); + }); + it('should not cache if cache disabled', async () => { + const {page, server} = await getTestState(); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + + await page.setRequestInterception(true); + await page.setCacheEnabled(false); + page.on('request', request => { + return request.continue({}, 0); + }); + + const cached: HTTPRequest[] = []; + page.on('requestservedfromcache', r => { + return cached.push(r); + }); + + await page.reload(); + expect(cached).toHaveLength(0); + }); + it('should cache if cache enabled', async () => { + const {page, server} = await getTestState(); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + + await page.setRequestInterception(true); + await page.setCacheEnabled(true); + page.on('request', request => { + return request.continue({}, 0); + }); + + const cached: HTTPRequest[] = []; + page.on('requestservedfromcache', r => { + return cached.push(r); + }); + + await page.reload(); + expect(cached).toHaveLength(1); + }); + it('should load fonts if cache enabled', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + await page.setCacheEnabled(true); + page.on('request', request => { + return request.continue({}, 0); + }); + + await page.goto(server.PREFIX + '/cached/one-style-font.html'); + await page.waitForResponse(r => { + return r.url().endsWith('/one-style.woff'); + }); + }); + }); + + describe('Request.continue', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue({}, 0); + }); + await page.goto(server.EMPTY_PAGE); + }); + it('should amend HTTP headers', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + const headers = Object.assign({}, request.headers()); + headers['FOO'] = 'bar'; + void request.continue({headers}, 0); + }); + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => { + return fetch('/sleep.zzz'); + }), + ]); + expect(request.headers['foo']).toBe('bar'); + }); + it('should redirect in a way non-observable to page', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + const redirectURL = request.url().includes('/empty.html') + ? server.PREFIX + '/consolelog.html' + : undefined; + void request.continue({url: redirectURL}, 0); + }); + + const [consoleMessage] = await Promise.all([ + waitEvent<ConsoleMessage>(page, 'console'), + page.goto(server.EMPTY_PAGE), + ]); + expect(page.url()).toBe(server.EMPTY_PAGE); + expect(consoleMessage.text()).toBe('yellow'); + }); + it('should amend method', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.continue({method: 'POST'}, 0); + }); + const [request] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => { + return fetch('/sleep.zzz'); + }), + ]); + expect(request.method).toBe('POST'); + }); + it('should amend post data', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.continue({postData: 'doggo'}, 0); + }); + const [serverRequest] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => { + return fetch('/sleep.zzz', {method: 'POST', body: 'birdy'}); + }), + ]); + expect(await serverRequest.postBody).toBe('doggo'); + }); + it('should amend both post data and method on navigation', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.continue({method: 'POST', postData: 'doggo'}, 0); + }); + const [serverRequest] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + expect(serverRequest.method).toBe('POST'); + expect(await serverRequest.postBody).toBe('doggo'); + }); + }); + + describe('Request.respond', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.respond( + { + status: 201, + headers: { + foo: 'bar', + }, + body: 'Yo, page!', + }, + 0 + ); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response!.status()).toBe(201); + expect(response!.headers()['foo']).toBe('bar'); + expect( + await page.evaluate(() => { + return document.body.textContent; + }) + ).toBe('Yo, page!'); + }); + it('should be able to access the response', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.respond( + { + status: 200, + body: 'Yo, page!', + }, + 0 + ); + }); + let response = null; + page.on('request', request => { + response = request.responseForRequest(); + void request.continue({}, 0); + }); + await page.goto(server.EMPTY_PAGE); + expect(response).toEqual({status: 200, body: 'Yo, page!'}); + }); + it('should work with status code 422', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.respond( + { + status: 422, + body: 'Yo, page!', + }, + 0 + ); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response!.status()).toBe(422); + expect(response!.statusText()).toBe('Unprocessable Entity'); + expect( + await page.evaluate(() => { + return document.body.textContent; + }) + ).toBe('Yo, page!'); + }); + it('should redirect', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + if (!request.url().includes('rrredirect')) { + void request.continue({}, 0); + return; + } + void request.respond( + { + status: 302, + headers: { + location: server.EMPTY_PAGE, + }, + }, + 0 + ); + }); + const response = await page.goto(server.PREFIX + '/rrredirect'); + expect(response!.request().redirectChain()).toHaveLength(1); + expect(response!.request().redirectChain()[0]!.url()).toBe( + server.PREFIX + '/rrredirect' + ); + expect(response!.url()).toBe(server.EMPTY_PAGE); + }); + it('should allow mocking binary responses', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + const imageBuffer = fs.readFileSync( + path.join(__dirname, '../assets', 'pptr.png') + ); + void request.respond( + { + contentType: 'image/png', + body: imageBuffer, + }, + 0 + ); + }); + await page.evaluate(PREFIX => { + const img = document.createElement('img'); + img.src = PREFIX + '/does-not-exist.png'; + document.body.appendChild(img); + return new Promise(fulfill => { + return (img.onload = fulfill); + }); + }, server.PREFIX); + using img = (await page.$('img'))!; + expect(await img.screenshot()).toBeGolden('mock-binary-response.png'); + }); + it('should stringify intercepted request response headers', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.respond( + { + status: 200, + headers: { + foo: true, + }, + body: 'Yo, page!', + }, + 0 + ); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response!.status()).toBe(200); + const headers = response!.headers(); + expect(headers['foo']).toBe('true'); + expect( + await page.evaluate(() => { + return document.body.textContent; + }) + ).toBe('Yo, page!'); + }); + it('should indicate already-handled if an intercept has been handled', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.continue(); + }); + page.on('request', request => { + expect(request.isInterceptResolutionHandled()).toBeTruthy(); + }); + page.on('request', request => { + const {action} = request.interceptResolutionState(); + expect(action).toBe(InterceptResolutionAction.AlreadyHandled); + }); + await page.goto(server.EMPTY_PAGE); + }); + }); +}); + +function pathToFileURL(path: string): string { + let pathName = path.replace(/\\/g, '/'); + // Windows drive letter must be prefixed with a slash. + if (!pathName.startsWith('/')) { + pathName = '/' + pathName; + } + return 'file://' + pathName; +} diff --git a/remote/test/puppeteer/test/src/requestinterception.spec.ts b/remote/test/puppeteer/test/src/requestinterception.spec.ts new file mode 100644 index 0000000000..45827bb3cf --- /dev/null +++ b/remote/test/puppeteer/test/src/requestinterception.spec.ts @@ -0,0 +1,920 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; + +import expect from 'expect'; +import type {HTTPRequest} from 'puppeteer-core/internal/api/HTTPRequest.js'; +import type {ConsoleMessage} from 'puppeteer-core/internal/common/ConsoleMessage.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {isFavicon, waitEvent} from './utils.js'; + +describe('request interception', function () { + setupTestBrowserHooks(); + + describe('Page.setRequestInterception', function () { + it('should intercept', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + if (isFavicon(request)) { + void request.continue(); + return; + } + expect(request.url()).toContain('empty.html'); + expect(request.headers()['user-agent']).toBeTruthy(); + expect(request.headers()['accept']).toBeTruthy(); + expect(request.method()).toBe('GET'); + expect(request.postData()).toBe(undefined); + expect(request.isNavigationRequest()).toBe(true); + expect(request.resourceType()).toBe('document'); + expect(request.frame() === page.mainFrame()).toBe(true); + expect(request.frame()!.url()).toBe('about:blank'); + void request.continue(); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.ok()).toBe(true); + expect(response.remoteAddress().port).toBe(server.PORT); + }); + // @see https://github.com/puppeteer/puppeteer/pull/3105 + it('should work when POST is redirected with 302', async () => { + const {page, server} = await getTestState(); + + server.setRedirect('/rredirect', '/empty.html'); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue(); + }); + await page.setContent(` + <form action='/rredirect' method='post'> + <input type="hidden" id="foo" name="foo" value="FOOBAR"> + </form> + `); + await Promise.all([ + page.$eval('form', form => { + return (form as HTMLFormElement).submit(); + }), + page.waitForNavigation(), + ]); + }); + // @see https://github.com/puppeteer/puppeteer/issues/3973 + it('should work when header manipulation headers with redirect', async () => { + const {page, server} = await getTestState(); + + server.setRedirect('/rrredirect', '/empty.html'); + await page.setRequestInterception(true); + page.on('request', request => { + const headers = Object.assign({}, request.headers(), { + foo: 'bar', + }); + void request.continue({headers}); + }); + await page.goto(server.PREFIX + '/rrredirect'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4743 + it('should be able to remove headers', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + const headers = Object.assign({}, request.headers(), { + foo: 'bar', + origin: undefined, // remove "origin" header + }); + void request.continue({headers}); + }); + + const [serverRequest] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.PREFIX + '/empty.html'), + ]); + + expect(serverRequest.headers.origin).toBe(undefined); + }); + it('should contain referer header', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + if (!isFavicon(request)) { + requests.push(request); + } + void request.continue(); + }); + await page.goto(server.PREFIX + '/one-style.html'); + expect(requests[1]!.url()).toContain('/one-style.css'); + expect(requests[1]!.headers()['referer']).toContain('/one-style.html'); + }); + it('should work with requests without networkId', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + + const cdp = await page.target().createCDPSession(); + await cdp.send('DOM.enable'); + const urls: string[] = []; + page.on('request', request => { + urls.push(request.url()); + return request.continue(); + }); + // This causes network requests without networkId. + await cdp.send('CSS.enable'); + expect(urls).toStrictEqual([server.EMPTY_PAGE]); + }); + it('should properly return navigation response when URL has cookies', async () => { + const {page, server} = await getTestState(); + + // Setup cookie. + await page.goto(server.EMPTY_PAGE); + await page.setCookie({name: 'foo', value: 'bar'}); + + // Setup request interception. + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue(); + }); + const response = (await page.reload())!; + expect(response.status()).toBe(200); + }); + it('should stop intercepting', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.once('request', request => { + return request.continue(); + }); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(false); + await page.goto(server.EMPTY_PAGE); + }); + it('should show custom HTTP headers', async () => { + const {page, server} = await getTestState(); + + await page.setExtraHTTPHeaders({ + foo: 'bar', + }); + await page.setRequestInterception(true); + page.on('request', request => { + expect(request.headers()['foo']).toBe('bar'); + void request.continue(); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.ok()).toBe(true); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4337 + it('should work with redirect inside sync XHR', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + server.setRedirect('/logo.png', '/pptr.png'); + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue(); + }); + const status = await page.evaluate(async () => { + const request = new XMLHttpRequest(); + request.open('GET', '/logo.png', false); // `false` makes the request synchronous + request.send(null); + return request.status; + }); + expect(status).toBe(200); + }); + it('should work with custom referer headers', async () => { + const {page, server} = await getTestState(); + + await page.setExtraHTTPHeaders({referer: server.EMPTY_PAGE}); + await page.setRequestInterception(true); + page.on('request', request => { + expect(request.headers()['referer']).toBe(server.EMPTY_PAGE); + void request.continue(); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.ok()).toBe(true); + }); + it('should be abortable', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + if (request.url().endsWith('.css')) { + void request.abort(); + } else { + void request.continue(); + } + }); + let failedRequests = 0; + page.on('requestfailed', () => { + return ++failedRequests; + }); + const response = (await page.goto(server.PREFIX + '/one-style.html'))!; + expect(response.ok()).toBe(true); + expect(response.request().failure()).toBe(null); + expect(failedRequests).toBe(1); + }); + it('should be abortable with custom error codes', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.abort('internetdisconnected'); + }); + const [failedRequest] = await Promise.all([ + waitEvent<HTTPRequest>(page, 'requestfailed'), + page.goto(server.EMPTY_PAGE).catch(() => {}), + ]); + + expect(failedRequest).toBeTruthy(); + expect(failedRequest.failure()!.errorText).toBe( + 'net::ERR_INTERNET_DISCONNECTED' + ); + }); + it('should send referer', async () => { + const {page, server} = await getTestState(); + + await page.setExtraHTTPHeaders({ + referer: 'http://google.com/', + }); + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue(); + }); + const [request] = await Promise.all([ + server.waitForRequest('/grid.html'), + page.goto(server.PREFIX + '/grid.html'), + ]); + expect(request.headers['referer']).toBe('http://google.com/'); + }); + it('should fail navigation when aborting main resource', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + return request.abort(); + }); + let error!: Error; + await page.goto(server.EMPTY_PAGE).catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + if (isChrome) { + expect(error.message).toContain('net::ERR_FAILED'); + } else { + expect(error.message).toContain('NS_ERROR_FAILURE'); + } + }); + it('should work with redirects', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + void request.continue(); + requests.push(request); + }); + server.setRedirect( + '/non-existing-page.html', + '/non-existing-page-2.html' + ); + server.setRedirect( + '/non-existing-page-2.html', + '/non-existing-page-3.html' + ); + server.setRedirect( + '/non-existing-page-3.html', + '/non-existing-page-4.html' + ); + server.setRedirect('/non-existing-page-4.html', '/empty.html'); + const response = (await page.goto( + server.PREFIX + '/non-existing-page.html' + ))!; + expect(response.status()).toBe(200); + expect(response.url()).toContain('empty.html'); + expect(requests).toHaveLength(5); + expect(requests[2]!.resourceType()).toBe('document'); + // Check redirect chain + const redirectChain = response.request().redirectChain(); + expect(redirectChain).toHaveLength(4); + expect(redirectChain[0]!.url()).toContain('/non-existing-page.html'); + expect(redirectChain[2]!.url()).toContain('/non-existing-page-3.html'); + for (let i = 0; i < redirectChain.length; ++i) { + const request = redirectChain[i]!; + expect(request.isNavigationRequest()).toBe(true); + expect(request.redirectChain().indexOf(request)).toBe(i); + } + }); + it('should work with redirects for subresources', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + void request.continue(); + if (!isFavicon(request)) { + requests.push(request); + } + }); + server.setRedirect('/one-style.css', '/two-style.css'); + server.setRedirect('/two-style.css', '/three-style.css'); + server.setRedirect('/three-style.css', '/four-style.css'); + server.setRoute('/four-style.css', (_req, res) => { + return res.end('body {box-sizing: border-box; }'); + }); + + const response = (await page.goto(server.PREFIX + '/one-style.html'))!; + expect(response.status()).toBe(200); + expect(response.url()).toContain('one-style.html'); + expect(requests).toHaveLength(5); + expect(requests[0]!.resourceType()).toBe('document'); + expect(requests[1]!.resourceType()).toBe('stylesheet'); + // Check redirect chain + const redirectChain = requests[1]!.redirectChain(); + expect(redirectChain).toHaveLength(3); + expect(redirectChain[0]!.url()).toContain('/one-style.css'); + expect(redirectChain[2]!.url()).toContain('/three-style.css'); + }); + it('should be able to abort redirects', async () => { + const {page, server, isChrome} = await getTestState(); + + await page.setRequestInterception(true); + server.setRedirect('/non-existing.json', '/non-existing-2.json'); + server.setRedirect('/non-existing-2.json', '/simple.html'); + page.on('request', request => { + if (request.url().includes('non-existing-2')) { + void request.abort(); + } else { + void request.continue(); + } + }); + await page.goto(server.EMPTY_PAGE); + const result = await page.evaluate(async () => { + try { + return await fetch('/non-existing.json'); + } catch (error) { + return (error as Error).message; + } + }); + if (isChrome) { + expect(result).toContain('Failed to fetch'); + } else { + expect(result).toContain('NetworkError'); + } + }); + it('should work with equal requests', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + let responseCount = 1; + server.setRoute('/zzz', (_req, res) => { + return res.end(responseCount++ * 11 + ''); + }); + await page.setRequestInterception(true); + + let spinner = false; + // Cancel 2nd request. + page.on('request', request => { + if (isFavicon(request)) { + void request.continue(); + return; + } + void (spinner ? request.abort() : request.continue()); + spinner = !spinner; + }); + const results = await page.evaluate(() => { + return Promise.all([ + fetch('/zzz') + .then(response => { + return response.text(); + }) + .catch(() => { + return 'FAILED'; + }), + fetch('/zzz') + .then(response => { + return response.text(); + }) + .catch(() => { + return 'FAILED'; + }), + fetch('/zzz') + .then(response => { + return response.text(); + }) + .catch(() => { + return 'FAILED'; + }), + ]); + }); + expect(results).toEqual(['11', 'FAILED', '22']); + }); + it('should navigate to dataURL and fire dataURL requests', async () => { + const {page} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + requests.push(request); + void request.continue(); + }); + const dataURL = 'data:text/html,<div>yo</div>'; + const response = (await page.goto(dataURL))!; + expect(response.status()).toBe(200); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(dataURL); + }); + it('should be able to fetch dataURL and fire dataURL requests', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + !isFavicon(request) && requests.push(request); + void request.continue(); + }); + const dataURL = 'data:text/html,<div>yo</div>'; + const text = await page.evaluate((url: string) => { + return fetch(url).then(r => { + return r.text(); + }); + }, dataURL); + expect(text).toBe('<div>yo</div>'); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(dataURL); + }); + it('should navigate to URL with hash and fire requests without hash', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + requests.push(request); + void request.continue(); + }); + const response = (await page.goto(server.EMPTY_PAGE + '#hash'))!; + expect(response.status()).toBe(200); + expect(response.url()).toBe(server.EMPTY_PAGE); + expect(requests).toHaveLength(1); + expect(requests[0]!.url()).toBe(server.EMPTY_PAGE); + }); + it('should work with encoded server', async () => { + const {page, server} = await getTestState(); + + // The requestWillBeSent will report encoded URL, whereas interception will + // report URL as-is. @see crbug.com/759388 + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue(); + }); + const response = (await page.goto( + server.PREFIX + '/some nonexisting page' + ))!; + expect(response.status()).toBe(404); + }); + it('should work with badly encoded server', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + server.setRoute('/malformed?rnd=%911', (_req, res) => { + return res.end(); + }); + page.on('request', request => { + return request.continue(); + }); + const response = (await page.goto( + server.PREFIX + '/malformed?rnd=%911' + ))!; + expect(response.status()).toBe(200); + }); + it('should work wit h encoded server - 2', async () => { + const {page, server} = await getTestState(); + + // The requestWillBeSent will report URL as-is, whereas interception will + // report encoded URL for stylesheet. @see crbug.com/759388 + await page.setRequestInterception(true); + const requests: HTTPRequest[] = []; + page.on('request', request => { + void request.continue(); + requests.push(request); + }); + const response = (await page.goto( + `data:text/html,<link rel="stylesheet" href="${server.PREFIX}/fonts?helvetica|arial"/>` + ))!; + expect(response.status()).toBe(200); + expect(requests).toHaveLength(2); + expect(requests[1]!.response()!.status()).toBe(404); + }); + it('should not throw "Invalid Interception Id" if the request was cancelled', async () => { + const {page, server} = await getTestState(); + + await page.setContent('<iframe></iframe>'); + await page.setRequestInterception(true); + let request!: HTTPRequest; + page.on('request', async r => { + return (request = r); + }); + void (page.$eval( + 'iframe', + (frame, url) => { + return ((frame as HTMLIFrameElement).src = url as string); + }, + server.EMPTY_PAGE + ), + // Wait for request interception. + await waitEvent(page, 'request')); + // Delete frame to cause request to be canceled. + await page.$eval('iframe', frame => { + return frame.remove(); + }); + let error!: Error; + await request.continue().catch(error_ => { + return (error = error_); + }); + expect(error).toBeUndefined(); + }); + it('should throw if interception is not enabled', async () => { + const {page, server} = await getTestState(); + + let error!: Error; + page.on('request', async request => { + try { + await request.continue(); + } catch (error_) { + error = error_ as Error; + } + }); + await page.goto(server.EMPTY_PAGE); + expect(error.message).toContain('Request Interception is not enabled'); + }); + it('should work with file URLs', async () => { + const {page} = await getTestState(); + + await page.setRequestInterception(true); + const urls = new Set(); + page.on('request', request => { + urls.add(request.url().split('/').pop()); + void request.continue(); + }); + await page.goto( + pathToFileURL(path.join(__dirname, '../assets', 'one-style.html')) + ); + expect(urls.size).toBe(2); + expect(urls.has('one-style.html')).toBe(true); + expect(urls.has('one-style.css')).toBe(true); + }); + it('should not cache if cache disabled', async () => { + const {page, server} = await getTestState(); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + + await page.setRequestInterception(true); + await page.setCacheEnabled(false); + page.on('request', request => { + return request.continue(); + }); + + const cached: HTTPRequest[] = []; + page.on('requestservedfromcache', r => { + return cached.push(r); + }); + + await page.reload(); + expect(cached).toHaveLength(0); + }); + it('should cache if cache enabled', async () => { + const {page, server} = await getTestState(); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + + await page.setRequestInterception(true); + await page.setCacheEnabled(true); + page.on('request', request => { + return request.continue(); + }); + + const cached: HTTPRequest[] = []; + page.on('requestservedfromcache', r => { + return cached.push(r); + }); + + await page.reload(); + expect(cached).toHaveLength(1); + }); + it('should load fonts if cache enabled', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + await page.setCacheEnabled(true); + page.on('request', request => { + return request.continue(); + }); + + const responsePromise = page.waitForResponse(r => { + return r.url().endsWith('/one-style.woff'); + }); + await page.goto(server.PREFIX + '/cached/one-style-font.html'); + await responsePromise; + }); + }); + + describe('Request.continue', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + return request.continue(); + }); + await page.goto(server.EMPTY_PAGE); + }); + it('should amend HTTP headers', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + const headers = Object.assign({}, request.headers()); + headers['FOO'] = 'bar'; + void request.continue({headers}); + }); + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => { + return fetch('/sleep.zzz'); + }), + ]); + expect(request.headers['foo']).toBe('bar'); + }); + it('should redirect in a way non-observable to page', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + const redirectURL = request.url().includes('/empty.html') + ? server.PREFIX + '/consolelog.html' + : undefined; + void request.continue({url: redirectURL}); + }); + const [consoleMessage] = await Promise.all([ + waitEvent<ConsoleMessage>(page, 'console'), + page.goto(server.EMPTY_PAGE), + ]); + expect(page.url()).toBe(server.EMPTY_PAGE); + expect(consoleMessage.text()).toBe('yellow'); + }); + it('should amend method', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.continue({method: 'POST'}); + }); + const [request] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => { + return fetch('/sleep.zzz'); + }), + ]); + expect(request.method).toBe('POST'); + }); + it('should amend post data', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.continue({postData: 'doggo'}); + }); + const [serverRequest] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => { + return fetch('/sleep.zzz', {method: 'POST', body: 'birdy'}); + }), + ]); + expect(await serverRequest.postBody).toBe('doggo'); + }); + it('should amend both post data and method on navigation', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.continue({method: 'POST', postData: 'doggo'}); + }); + const [serverRequest] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + expect(serverRequest.method).toBe('POST'); + expect(await serverRequest.postBody).toBe('doggo'); + }); + it('should fail if the header value is invalid', async () => { + const {page, server} = await getTestState(); + + let error!: Error; + await page.setRequestInterception(true); + page.on('request', async request => { + await request + .continue({ + headers: { + 'X-Invalid-Header': 'a\nb', + }, + }) + .catch(error_ => { + error = error_ as Error; + }); + await request.continue(); + }); + await page.goto(server.PREFIX + '/empty.html'); + expect(error.message).toMatch(/Invalid header/); + }); + }); + + describe('Request.respond', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.respond({ + status: 201, + headers: { + foo: 'bar', + }, + body: 'Yo, page!', + }); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.status()).toBe(201); + expect(response.headers()['foo']).toBe('bar'); + expect( + await page.evaluate(() => { + return document.body.textContent; + }) + ).toBe('Yo, page!'); + }); + it('should work with status code 422', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.respond({ + status: 422, + body: 'Yo, page!', + }); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.status()).toBe(422); + expect(response.statusText()).toBe('Unprocessable Entity'); + expect( + await page.evaluate(() => { + return document.body.textContent; + }) + ).toBe('Yo, page!'); + }); + it('should redirect', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + if (!request.url().includes('rrredirect')) { + void request.continue(); + return; + } + void request.respond({ + status: 302, + headers: { + location: server.EMPTY_PAGE, + }, + }); + }); + const response = (await page.goto(server.PREFIX + '/rrredirect'))!; + expect(response.request().redirectChain()).toHaveLength(1); + expect(response.request().redirectChain()[0]!.url()).toBe( + server.PREFIX + '/rrredirect' + ); + expect(response.url()).toBe(server.EMPTY_PAGE); + }); + it('should allow mocking multiple headers with same key', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.respond({ + status: 200, + headers: { + foo: 'bar', + arr: ['1', '2'], + 'set-cookie': ['first=1', 'second=2'], + }, + body: 'Hello world', + }); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + const cookies = await page.cookies(); + const firstCookie = cookies.find(cookie => { + return cookie.name === 'first'; + }); + const secondCookie = cookies.find(cookie => { + return cookie.name === 'second'; + }); + expect(response.status()).toBe(200); + expect(response.headers()['foo']).toBe('bar'); + expect(response.headers()['arr']).toBe('1\n2'); + // request.respond() will not trigger Network.responseReceivedExtraInfo + // fail to get 'set-cookie' header from response + expect(firstCookie?.value).toBe('1'); + expect(secondCookie?.value).toBe('2'); + }); + it('should allow mocking binary responses', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + const imageBuffer = fs.readFileSync( + path.join(__dirname, '../assets', 'pptr.png') + ); + void request.respond({ + contentType: 'image/png', + body: imageBuffer, + }); + }); + await page.evaluate(PREFIX => { + const img = document.createElement('img'); + img.src = PREFIX + '/does-not-exist.png'; + document.body.appendChild(img); + return new Promise(fulfill => { + return (img.onload = fulfill); + }); + }, server.PREFIX); + using img = (await page.$('img'))!; + expect(await img.screenshot()).toBeGolden('mock-binary-response.png'); + }); + it('should stringify intercepted request response headers', async () => { + const {page, server} = await getTestState(); + + await page.setRequestInterception(true); + page.on('request', request => { + void request.respond({ + status: 200, + headers: { + foo: true, + }, + body: 'Yo, page!', + }); + }); + const response = (await page.goto(server.EMPTY_PAGE))!; + expect(response.status()).toBe(200); + const headers = response.headers(); + expect(headers['foo']).toBe('true'); + expect( + await page.evaluate(() => { + return document.body.textContent; + }) + ).toBe('Yo, page!'); + }); + it('should fail if the header value is invalid', async () => { + const {page, server} = await getTestState(); + + let error!: Error; + await page.setRequestInterception(true); + page.on('request', async request => { + await request + .respond({ + headers: { + 'X-Invalid-Header': 'a\nb', + }, + }) + .catch(error_ => { + error = error_ as Error; + }); + await request.respond({ + status: 200, + body: 'Hello World', + }); + }); + await page.goto(server.PREFIX + '/empty.html'); + expect(error.message).toMatch(/Invalid header/); + }); + }); +}); + +function pathToFileURL(path: string): string { + let pathName = path.replace(/\\/g, '/'); + // Windows drive letter must be prefixed with a slash. + if (!pathName.startsWith('/')) { + pathName = '/' + pathName; + } + return 'file://' + pathName; +} diff --git a/remote/test/puppeteer/test/src/screencast.spec.ts b/remote/test/puppeteer/test/src/screencast.spec.ts new file mode 100644 index 0000000000..b645f55da7 --- /dev/null +++ b/remote/test/puppeteer/test/src/screencast.spec.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {statSync} from 'fs'; + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {getUniqueVideoFilePlaceholder} from './utils.js'; + +describe('Screencasts', function () { + setupTestBrowserHooks(); + + describe('Page.screencast', function () { + it('should work', async () => { + using file = getUniqueVideoFilePlaceholder(); + + const {page} = await getTestState(); + + const recorder = await page.screencast({ + path: file.filename, + scale: 0.5, + crop: {width: 100, height: 100, x: 0, y: 0}, + speed: 0.5, + }); + + await page.goto('data:text/html,<input>'); + using input = await page.locator('input').waitHandle(); + await input.type('ab', {delay: 100}); + + await recorder.stop(); + + expect(statSync(file.filename).size).toBeGreaterThan(0); + }); + it('should work concurrently', async () => { + using file1 = getUniqueVideoFilePlaceholder(); + using file2 = getUniqueVideoFilePlaceholder(); + + const {page} = await getTestState(); + + const recorder = await page.screencast({path: file1.filename}); + const recorder2 = await page.screencast({path: file2.filename}); + + await page.goto('data:text/html,<input>'); + using input = await page.locator('input').waitHandle(); + + await input.type('ab', {delay: 100}); + await recorder.stop(); + + await input.type('ab', {delay: 100}); + await recorder2.stop(); + + // Since file2 spent about double the time of file1 recording, so file2 + // should be around double the size of file1. + const ratio = + statSync(file2.filename).size / statSync(file1.filename).size; + + // We use a range because we cannot be precise. + const DELTA = 1.3; + expect(ratio).toBeGreaterThan(2 - DELTA); + expect(ratio).toBeLessThan(2 + DELTA); + }); + it('should validate options', async () => { + const {page} = await getTestState(); + + await expect(page.screencast({scale: 0})).rejects.toBeDefined(); + await expect(page.screencast({scale: -1})).rejects.toBeDefined(); + + await expect(page.screencast({speed: 0})).rejects.toBeDefined(); + await expect(page.screencast({speed: -1})).rejects.toBeDefined(); + + await expect( + page.screencast({crop: {x: 0, y: 0, height: 1, width: 0}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: 0, y: 0, height: 0, width: 1}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: -1, y: 0, height: 1, width: 1}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: 0, y: -1, height: 1, width: 1}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: 0, y: 0, height: 10000, width: 1}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: 0, y: 0, height: 1, width: 10000}}) + ).rejects.toBeDefined(); + + await expect( + page.screencast({ffmpegPath: 'non-existent-path'}) + ).rejects.toBeDefined(); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/screenshot.spec.ts b/remote/test/puppeteer/test/src/screenshot.spec.ts new file mode 100644 index 0000000000..ad53b60e95 --- /dev/null +++ b/remote/test/puppeteer/test/src/screenshot.spec.ts @@ -0,0 +1,453 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; + +import expect from 'expect'; + +import { + getTestState, + isHeadless, + launch, + setupTestBrowserHooks, +} from './mocha-utils.js'; + +describe('Screenshots', function () { + setupTestBrowserHooks(); + + describe('Page.screenshot', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('screenshot-sanity.png'); + }); + it('should clip rect', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + clip: { + x: 50, + y: 100, + width: 150, + height: 100, + }, + }); + expect(screenshot).toBeGolden('screenshot-clip-rect.png'); + }); + it('should get screenshot bigger than the viewport', async () => { + const {page, server} = await getTestState(); + await page.setViewport({width: 50, height: 50}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + clip: { + x: 25, + y: 25, + width: 100, + height: 100, + }, + }); + expect(screenshot).toBeGolden('screenshot-offscreen-clip.png'); + }); + it('should clip clip bigger than the viewport without "captureBeyondViewport"', async () => { + const {page, server} = await getTestState(); + await page.setViewport({width: 50, height: 50}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + captureBeyondViewport: false, + clip: { + x: 25, + y: 25, + width: 100, + height: 100, + }, + }); + expect(screenshot).toBeGolden('screenshot-offscreen-clip-2.png'); + }); + it('should run in parallel', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const promises = []; + for (let i = 0; i < 3; ++i) { + promises.push( + page.screenshot({ + clip: { + x: 50 * i, + y: 0, + width: 50, + height: 50, + }, + }) + ); + } + const screenshots = await Promise.all(promises); + expect(screenshots[1]).toBeGolden('grid-cell-1.png'); + }); + it('should take fullPage screenshots', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + fullPage: true, + }); + expect(screenshot).toBeGolden('screenshot-grid-fullpage.png'); + }); + it('should take fullPage screenshots without captureBeyondViewport', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + fullPage: true, + captureBeyondViewport: false, + }); + expect(screenshot).toBeGolden('screenshot-grid-fullpage-2.png'); + expect(page.viewport()).toMatchObject({width: 500, height: 500}); + }); + it('should run in parallel in multiple pages', async () => { + const {server, context} = await getTestState(); + + const N = 2; + const pages = await Promise.all( + Array(N) + .fill(0) + .map(async () => { + const page = await context.newPage(); + await page.goto(server.PREFIX + '/grid.html'); + return page; + }) + ); + const promises = []; + for (let i = 0; i < N; ++i) { + promises.push( + pages[i]!.screenshot({ + clip: {x: 50 * i, y: 0, width: 50, height: 50}, + }) + ); + } + const screenshots = await Promise.all(promises); + for (let i = 0; i < N; ++i) { + expect(screenshots[i]).toBeGolden(`grid-cell-${i}.png`); + } + await Promise.all( + pages.map(page => { + return page.close(); + }) + ); + }); + it('should work with odd clip size on Retina displays', async () => { + const {page} = await getTestState(); + + // Make sure documentElement height is at least 11px. + await page.setContent(`<div style="width: 11px; height: 11px;">`); + + const screenshot = await page.screenshot({ + clip: { + x: 0, + y: 0, + width: 11, + height: 11, + }, + }); + expect(screenshot).toBeGolden('screenshot-clip-odd-size.png'); + }); + it('should return base64', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + encoding: 'base64', + }); + expect(Buffer.from(screenshot, 'base64')).toBeGolden( + 'screenshot-sanity.png' + ); + }); + }); + + describe('ElementHandle.screenshot', function () { + it('should work', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + await page.evaluate(() => { + return window.scrollBy(50, 100); + }); + using elementHandle = (await page.$('.box:nth-of-type(3)'))!; + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-bounding-box.png'); + }); + it('should work with a null viewport', async () => { + const {server} = await getTestState({ + skipLaunch: true, + }); + const {browser, close} = await launch({ + defaultViewport: null, + }); + + try { + const page = await browser.newPage(); + await page.goto(server.PREFIX + '/grid.html'); + await page.evaluate(() => { + return window.scrollBy(50, 100); + }); + using elementHandle = await page.$('.box:nth-of-type(3)'); + assert(elementHandle); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeTruthy(); + } finally { + await close(); + } + }); + it('should take into account padding and border', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + something above + <style>div { + border: 2px solid blue; + background: green; + width: 50px; + height: 50px; + } + </style> + <div></div> + `); + using elementHandle = (await page.$('div'))!; + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-padding-border.png'); + }); + it('should capture full element when larger than viewport', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + + await page.setContent(` + something above + <style> + :root { + scrollbar-width: none; + } + div.to-screenshot { + border: 1px solid blue; + width: 600px; + height: 600px; + margin-left: 50px; + } + </style> + <div class="to-screenshot"></div> + `); + using elementHandle = (await page.$('div.to-screenshot'))!; + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden( + 'screenshot-element-larger-than-viewport.png' + ); + + expect( + await page.evaluate(() => { + return { + w: window.innerWidth, + h: window.innerHeight, + }; + }) + ).toEqual({w: 500, h: 500}); + }); + it('should scroll element into view', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + something above + <style>div.above { + border: 2px solid blue; + background: red; + height: 1500px; + } + div.to-screenshot { + border: 2px solid blue; + background: green; + width: 50px; + height: 50px; + } + </style> + <div class="above"></div> + <div class="to-screenshot"></div> + `); + using elementHandle = (await page.$('div.to-screenshot'))!; + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden( + 'screenshot-element-scrolled-into-view.png' + ); + }); + it('should work with a rotated element', async () => { + const {page} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(`<div style="position:absolute; + top: 100px; + left: 100px; + width: 100px; + height: 100px; + background: green; + transform: rotateZ(200deg);"> </div>`); + using elementHandle = (await page.$('div'))!; + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-rotate.png'); + }); + it('should fail to screenshot a detached element', async () => { + const {page} = await getTestState(); + + await page.setContent('<h1>remove this</h1>'); + using elementHandle = (await page.$('h1'))!; + await page.evaluate((element: HTMLElement) => { + return element.remove(); + }, elementHandle); + const screenshotError = await elementHandle.screenshot().catch(error => { + return error; + }); + expect(screenshotError.message).toBe( + 'Node is either not visible or not an HTMLElement' + ); + }); + it('should not hang with zero width/height element', async () => { + const {page} = await getTestState(); + + await page.setContent('<div style="width: 50px; height: 0"></div>'); + using div = (await page.$('div'))!; + const error = await div.screenshot().catch(error_ => { + return error_; + }); + expect(error.message).toBe('Node has 0 height.'); + }); + it('should work for an element with fractional dimensions', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<div style="width:48.51px;height:19.8px;border:1px solid black;"></div>' + ); + using elementHandle = (await page.$('div'))!; + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-fractional.png'); + }); + it('should work for an element with an offset', async () => { + const {page} = await getTestState(); + + await page.setContent( + '<div style="position:absolute; top: 10.3px; left: 20.4px;width:50.3px;height:20.2px;border:1px solid black;"></div>' + ); + using elementHandle = (await page.$('div'))!; + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-fractional-offset.png'); + }); + it('should work with webp', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 100, height: 100}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + type: 'webp', + }); + + expect(screenshot).toBeInstanceOf(Buffer); + }); + + it('should run in parallel in multiple pages', async () => { + const {browser, server} = await getTestState(); + + const context = await browser.createIncognitoBrowserContext(); + + const N = 2; + const pages = await Promise.all( + Array(N) + .fill(0) + .map(async () => { + const page = await context.newPage(); + await page.goto(server.PREFIX + '/grid.html'); + return page; + }) + ); + const promises = []; + for (let i = 0; i < N; ++i) { + promises.push( + pages[i]!.screenshot({ + clip: {x: 50 * i, y: 0, width: 50, height: 50}, + }) + ); + } + const screenshots = await Promise.all(promises); + for (let i = 0; i < N; ++i) { + expect(screenshots[i]).toBeGolden(`grid-cell-${i}.png`); + } + await Promise.all( + pages.map(page => { + return page.close(); + }) + ); + + await context.close(); + }); + }); + + describe('Cdp', () => { + it('should use scale for clip', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + clip: { + x: 50, + y: 100, + width: 150, + height: 100, + scale: 2, + }, + }); + expect(screenshot).toBeGolden('screenshot-clip-rect-scale2.png'); + }); + it('should allow transparency', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 100, height: 100}); + await page.goto(server.EMPTY_PAGE); + const screenshot = await page.screenshot({omitBackground: true}); + expect(screenshot).toBeGolden('transparent.png'); + }); + it('should render white background on jpeg file', async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 100, height: 100}); + await page.goto(server.EMPTY_PAGE); + const screenshot = await page.screenshot({ + omitBackground: true, + type: 'jpeg', + }); + expect(screenshot).toBeGolden('white.jpg'); + }); + (!isHeadless ? it : it.skip)( + 'should work in "fromSurface: false" mode', + async () => { + const {page, server} = await getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + fromSurface: false, + }); + expect(screenshot).toBeDefined(); // toBeGolden('screenshot-fromsurface-false.png'); + } + ); + }); +}); diff --git a/remote/test/puppeteer/test/src/stacktrace.spec.ts b/remote/test/puppeteer/test/src/stacktrace.spec.ts new file mode 100644 index 0000000000..b36ee56661 --- /dev/null +++ b/remote/test/puppeteer/test/src/stacktrace.spec.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {waitEvent} from './utils.js'; + +const FILENAME = __filename.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); +const parseStackTrace = (stack: string): string => { + stack = stack.replace(new RegExp(FILENAME, 'g'), '<filename>'); + stack = stack.replace(/<filename>:(\d+):(\d+)/g, '<filename>:<line>:<col>'); + stack = stack.replace(/<anonymous>:(\d+):(\d+)/g, '<anonymous>:<line>:<col>'); + return stack; +}; + +describe('Stack trace', function () { + setupTestBrowserHooks(); + + it('should work', async () => { + const {page} = await getTestState(); + + const error = (await page + .evaluate(() => { + throw new Error('Test'); + }) + .catch((error: Error) => { + return error; + })) as Error; + + expect(error.name).toEqual('Error'); + expect(error.message).toEqual('Test'); + assert(error.stack); + error.stack = error.stack.replace(new RegExp(FILENAME, 'g'), '<filename>'); + expect( + parseStackTrace(error.stack).split('\n at ').slice(0, 2) + ).toMatchObject({ + ...[ + 'Error: Test', + 'evaluate (evaluate at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + ], + }); + }); + + it('should work with handles', async () => { + const {page} = await getTestState(); + + const error = (await page + .evaluateHandle(() => { + throw new Error('Test'); + }) + .catch((error: Error) => { + return error; + })) as Error; + + expect(error.name).toEqual('Error'); + expect(error.message).toEqual('Test'); + assert(error.stack); + expect( + parseStackTrace(error.stack).split('\n at ').slice(0, 2) + ).toMatchObject({ + ...[ + 'Error: Test', + 'evaluateHandle (evaluateHandle at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + ], + }); + }); + + it('should work with contiguous evaluation', async () => { + const {page} = await getTestState(); + + using thrower = await page.evaluateHandle(() => { + return () => { + throw new Error('Test'); + }; + }); + const error = (await thrower + .evaluate(thrower => { + thrower(); + }) + .catch((error: Error) => { + return error; + })) as Error; + + expect(error.name).toEqual('Error'); + expect(error.message).toEqual('Test'); + assert(error.stack); + expect( + parseStackTrace(error.stack).split('\n at ').slice(0, 3) + ).toMatchObject({ + ...[ + 'Error: Test', + 'evaluateHandle (evaluateHandle at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + 'evaluate (evaluate at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + ], + }); + }); + + it('should work with nested function calls', async () => { + const {page} = await getTestState(); + + const error = (await page + .evaluate(() => { + function a() { + throw new Error('Test'); + } + function b() { + a(); + } + function c() { + b(); + } + function d() { + c(); + } + d(); + }) + .catch((error: Error) => { + return error; + })) as Error; + + expect(error.name).toEqual('Error'); + expect(error.message).toEqual('Test'); + assert(error.stack); + expect( + parseStackTrace(error.stack).split('\n at ').slice(0, 6) + ).toMatchObject({ + ...[ + 'Error: Test', + 'a (evaluate at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + 'b (evaluate at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + 'c (evaluate at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + 'd (evaluate at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + 'evaluate (evaluate at Context.<anonymous> (<filename>:<line>:<col>), <anonymous>:<line>:<col>)', + ], + }); + }); + + it('should work for none error objects', async () => { + const {page} = await getTestState(); + + const [error] = await Promise.all([ + waitEvent<Error>(page, 'pageerror'), + page.evaluate(() => { + // This can happen when a 404 with HTML is returned + void Promise.reject(new Response()); + }), + ]); + + expect(error).toBeTruthy(); + }); +}); diff --git a/remote/test/puppeteer/test/src/target.spec.ts b/remote/test/puppeteer/test/src/target.spec.ts new file mode 100644 index 0000000000..28d17a4030 --- /dev/null +++ b/remote/test/puppeteer/test/src/target.spec.ts @@ -0,0 +1,343 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ServerResponse} from 'http'; + +import expect from 'expect'; +import {type Target, TimeoutError} from 'puppeteer'; +import type {Page} from 'puppeteer-core/internal/api/Page.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {waitEvent} from './utils.js'; + +describe('Target', function () { + setupTestBrowserHooks(); + + it('Browser.targets should return all of the targets', async () => { + const {browser} = await getTestState(); + + // The pages will be the testing page and the original newtab page + const targets = browser.targets(); + expect( + targets.some(target => { + return target.type() === 'page' && target.url() === 'about:blank'; + }) + ).toBeTruthy(); + expect( + targets.some(target => { + return target.type() === 'browser'; + }) + ).toBeTruthy(); + }); + it('Browser.pages should return all of the pages', async () => { + const {page, context} = await getTestState(); + + // The pages will be the testing page + const allPages = await context.pages(); + expect(allPages).toHaveLength(1); + expect(allPages).toContain(page); + }); + it('should contain browser target', async () => { + const {browser} = await getTestState(); + + const targets = browser.targets(); + const browserTarget = targets.find(target => { + return target.type() === 'browser'; + }); + expect(browserTarget).toBeTruthy(); + }); + it('should be able to use the default page in the browser', async () => { + const {page, browser} = await getTestState(); + + // The pages will be the testing page and the original newtab page + const allPages = await browser.pages(); + const originalPage = allPages.find(p => { + return p !== page; + })!; + expect( + await originalPage.evaluate(() => { + return ['Hello', 'world'].join(' '); + }) + ).toBe('Hello world'); + expect(await originalPage.$('body')).toBeTruthy(); + }); + it('should be able to use async waitForTarget', async () => { + const {page, server, context} = await getTestState(); + + const [otherPage] = await Promise.all([ + context + .waitForTarget( + target => { + return target.page().then(page => { + return ( + page!.url() === server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + }); + }, + {timeout: 3000} + ) + .then(target => { + return target.page(); + }), + page.evaluate((url: string) => { + return window.open(url); + }, server.CROSS_PROCESS_PREFIX + '/empty.html'), + ]); + expect(otherPage!.url()).toEqual( + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + expect(page).not.toBe(otherPage); + }); + it('should report when a new page is created and closed', async () => { + const {page, server, context} = await getTestState(); + + const [otherPage] = await Promise.all([ + context + .waitForTarget( + target => { + return target.url() === server.CROSS_PROCESS_PREFIX + '/empty.html'; + }, + {timeout: 3000} + ) + .then(target => { + return target.page(); + }), + page.evaluate((url: string) => { + return window.open(url); + }, server.CROSS_PROCESS_PREFIX + '/empty.html'), + ]); + expect(otherPage!.url()).toContain(server.CROSS_PROCESS_PREFIX); + expect( + await otherPage!.evaluate(() => { + return ['Hello', 'world'].join(' '); + }) + ).toBe('Hello world'); + expect(await otherPage!.$('body')).toBeTruthy(); + + let allPages = await context.pages(); + expect(allPages).toContain(page); + expect(allPages).toContain(otherPage); + + const [closedTarget] = await Promise.all([ + waitEvent<Target>(context, 'targetdestroyed'), + otherPage!.close(), + ]); + expect(await closedTarget.page()).toBe(otherPage); + + allPages = (await Promise.all( + context.targets().map(target => { + return target.page(); + }) + )) as Page[]; + expect(allPages).toContain(page); + expect(allPages).not.toContain(otherPage); + }); + it('should report when a service worker is created and destroyed', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const createdTarget = waitEvent(context, 'targetcreated'); + + await page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'); + + expect((await createdTarget).type()).toBe('service_worker'); + expect((await createdTarget).url()).toBe( + server.PREFIX + '/serviceworkers/empty/sw.js' + ); + + const destroyedTarget = waitEvent(context, 'targetdestroyed'); + await page.evaluate(() => { + return ( + globalThis as unknown as { + registrationPromise: Promise<{unregister: () => void}>; + } + ).registrationPromise.then((registration: any) => { + return registration.unregister(); + }); + }); + expect(await destroyedTarget).toBe(await createdTarget); + }); + it('should create a worker from a service worker', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'); + + const target = await context.waitForTarget( + target => { + return target.type() === 'service_worker'; + }, + {timeout: 3000} + ); + const worker = (await target.worker())!; + + expect( + await worker.evaluate(() => { + return self.toString(); + }) + ).toBe('[object ServiceWorkerGlobalScope]'); + }); + it('should create a worker from a shared worker', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + new SharedWorker('data:text/javascript,console.log("hi")'); + }); + const target = await context.waitForTarget( + target => { + return target.type() === 'shared_worker'; + }, + {timeout: 3000} + ); + const worker = (await target.worker())!; + expect( + await worker.evaluate(() => { + return self.toString(); + }) + ).toBe('[object SharedWorkerGlobalScope]'); + }); + it('should report when a target url changes', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + let changedTarget = waitEvent(context, 'targetchanged'); + await page.goto(server.CROSS_PROCESS_PREFIX + '/'); + expect((await changedTarget).url()).toBe(server.CROSS_PROCESS_PREFIX + '/'); + + changedTarget = waitEvent(context, 'targetchanged'); + await page.goto(server.EMPTY_PAGE); + expect((await changedTarget).url()).toBe(server.EMPTY_PAGE); + }); + it('should not report uninitialized pages', async () => { + const {context} = await getTestState(); + + let targetChanged = false; + const listener = () => { + targetChanged = true; + }; + context.on('targetchanged', listener); + const targetPromise = waitEvent<Target>(context, 'targetcreated'); + const newPagePromise = context.newPage(); + const target = await targetPromise; + expect(target.url()).toBe('about:blank'); + + const newPage = await newPagePromise; + const targetPromise2 = waitEvent<Target>(context, 'targetcreated'); + const evaluatePromise = newPage.evaluate(() => { + return window.open('about:blank'); + }); + const target2 = await targetPromise2; + expect(target2.url()).toBe('about:blank'); + await evaluatePromise; + await newPage.close(); + expect(targetChanged).toBe(false); + context.off('targetchanged', listener); + }); + + it('should not crash while redirecting if original request was missed', async () => { + const {page, server, context} = await getTestState(); + + let serverResponse!: ServerResponse; + server.setRoute('/one-style.css', (_req, res) => { + return (serverResponse = res); + }); + // Open a new page. Use window.open to connect to the page later. + await Promise.all([ + page.evaluate((url: string) => { + return window.open(url); + }, server.PREFIX + '/one-style.html'), + server.waitForRequest('/one-style.css'), + ]); + // Connect to the opened page. + const target = await context.waitForTarget( + target => { + return target.url().includes('one-style.html'); + }, + {timeout: 3000} + ); + const newPage = (await target.page())!; + const loadEvent = waitEvent(newPage, 'load'); + // Issue a redirect. + serverResponse.writeHead(302, {location: '/injectedstyle.css'}); + serverResponse.end(); + // Wait for the new page to load. + await loadEvent; + // Cleanup. + await newPage.close(); + }); + it('should have an opener', async () => { + const {page, server, context} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [createdTarget] = await Promise.all([ + waitEvent<Target>(context, 'targetcreated'), + page.goto(server.PREFIX + '/popup/window-open.html'), + ]); + expect((await createdTarget.page())!.url()).toBe( + server.PREFIX + '/popup/popup.html' + ); + expect(createdTarget.opener()).toBe(page.target()); + expect(page.target().opener()).toBeUndefined(); + }); + + describe('Browser.waitForTarget', () => { + it('should wait for a target', async () => { + const {browser, server} = await getTestState(); + + let resolved = false; + const targetPromise = browser.waitForTarget( + target => { + return target.url() === server.EMPTY_PAGE; + }, + {timeout: 3000} + ); + targetPromise + .then(() => { + return (resolved = true); + }) + .catch(error => { + resolved = true; + if (error instanceof TimeoutError) { + console.error(error); + } else { + throw error; + } + }); + const page = await browser.newPage(); + expect(resolved).toBe(false); + await page.goto(server.EMPTY_PAGE); + try { + const target = await targetPromise; + expect(await target.page()).toBe(page); + } catch (error) { + if (error instanceof TimeoutError) { + console.error(error); + } else { + throw error; + } + } + await page.close(); + }); + it('should timeout waiting for a non-existent target', async () => { + const {browser, server} = await getTestState(); + + let error!: Error; + await browser + .waitForTarget( + target => { + return target.url() === server.PREFIX + '/does-not-exist.html'; + }, + { + timeout: 1, + } + ) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/touchscreen.spec.ts b/remote/test/puppeteer/test/src/touchscreen.spec.ts new file mode 100644 index 0000000000..28a18ec449 --- /dev/null +++ b/remote/test/puppeteer/test/src/touchscreen.spec.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; + +declare const allEvents: Array<{type: string}>; + +describe('Touchscreen', () => { + setupTestBrowserHooks(); + + describe('Touchscreen.prototype.tap', () => { + it('should work', async () => { + const {page, server, isHeadless} = await getTestState(); + await page.goto(server.PREFIX + '/input/touchscreen.html'); + + await page.tap('button'); + expect( + ( + await page.evaluate(() => { + return allEvents; + }) + ).filter(({type}) => { + return type !== 'pointermove' || isHeadless; + }) + ).toMatchObject([ + {height: 1, type: 'pointerdown', width: 1, x: 5, y: 5}, + {touches: [[5, 5, 0.5, 0.5]], type: 'touchstart'}, + {height: 1, type: 'pointerup', width: 1, x: 5, y: 5}, + {touches: [[5, 5, 0.5, 0.5]], type: 'touchend'}, + {height: 1, type: 'click', width: 1, x: 5, y: 5}, + ]); + }); + }); + + describe('Touchscreen.prototype.touchMove', () => { + it('should work', async () => { + const {page, server, isHeadless} = await getTestState(); + await page.goto(server.PREFIX + '/input/touchscreen.html'); + + await page.touchscreen.touchStart(0, 0); + await page.touchscreen.touchMove(10, 10); + await page.touchscreen.touchMove(15.5, 15); + await page.touchscreen.touchMove(20, 20.4); + await page.touchscreen.touchMove(40, 30); + await page.touchscreen.touchEnd(); + expect( + ( + await page.evaluate(() => { + return allEvents; + }) + ).filter(({type}) => { + return type !== 'pointermove' || isHeadless; + }) + ).toMatchObject( + [ + {type: 'pointerdown', x: 0, y: 0, width: 1, height: 1}, + {type: 'touchstart', touches: [[0, 0, 0.5, 0.5]]}, + {type: 'pointermove', x: 10, y: 10, width: 1, height: 1}, + {type: 'touchmove', touches: [[10, 10, 0.5, 0.5]]}, + {type: 'pointermove', x: 16, y: 15, width: 1, height: 1}, + {type: 'touchmove', touches: [[16, 15, 0.5, 0.5]]}, + {type: 'pointermove', x: 20, y: 20, width: 1, height: 1}, + {type: 'touchmove', touches: [[20, 20, 0.5, 0.5]]}, + {type: 'pointermove', x: 40, y: 30, width: 1, height: 1}, + {type: 'touchmove', touches: [[40, 30, 0.5, 0.5]]}, + {type: 'pointerup', x: 40, y: 30, width: 1, height: 1}, + {type: 'touchend', touches: [[40, 30, 0.5, 0.5]]}, + ].filter(({type}) => { + return type !== 'pointermove' || isHeadless; + }) + ); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/tracing.spec.ts b/remote/test/puppeteer/test/src/tracing.spec.ts new file mode 100644 index 0000000000..2c0a5aff19 --- /dev/null +++ b/remote/test/puppeteer/test/src/tracing.spec.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; + +import expect from 'expect'; + +import {launch} from './mocha-utils.js'; + +describe('Tracing', function () { + let outputFile!: string; + let testState: Awaited<ReturnType<typeof launch>>; + + /* we manually manage the browser here as we want a new browser for each + * individual test, which isn't the default behaviour of getTestState() + */ + beforeEach(async () => { + testState = await launch({}); + outputFile = path.join(__dirname, 'trace.json'); + }); + + afterEach(async () => { + await testState.close(); + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + }); + + it('should output a trace', async () => { + const {server, page} = testState; + await page.tracing.start({screenshots: true, path: outputFile}); + await page.goto(server.PREFIX + '/grid.html'); + await page.tracing.stop(); + expect(fs.existsSync(outputFile)).toBe(true); + }); + + it('should run with custom categories if provided', async () => { + const {page} = testState; + await page.tracing.start({ + path: outputFile, + categories: ['-*', 'disabled-by-default-devtools.timeline.frame'], + }); + await page.tracing.stop(); + + const traceJson = JSON.parse( + fs.readFileSync(outputFile, {encoding: 'utf8'}) + ); + const traceConfig = JSON.parse(traceJson.metadata['trace-config']); + expect(traceConfig.included_categories).toEqual([ + 'disabled-by-default-devtools.timeline.frame', + ]); + expect(traceConfig.excluded_categories).toEqual(['*']); + expect(traceJson.traceEvents).not.toContainEqual( + expect.objectContaining({ + cat: 'toplevel', + }) + ); + }); + + it('should run with default categories', async () => { + const {page} = testState; + await page.tracing.start({ + path: outputFile, + }); + await page.tracing.stop(); + + const traceJson = JSON.parse( + fs.readFileSync(outputFile, {encoding: 'utf8'}) + ); + expect(traceJson.traceEvents).toContainEqual( + expect.objectContaining({ + cat: 'toplevel', + }) + ); + }); + it('should throw if tracing on two pages', async () => { + const {page, browser} = testState; + await page.tracing.start({path: outputFile}); + const newPage = await browser.newPage(); + let error!: Error; + await newPage.tracing.start({path: outputFile}).catch(error_ => { + return (error = error_); + }); + await newPage.close(); + expect(error).toBeTruthy(); + await page.tracing.stop(); + }); + it('should return a buffer', async () => { + const {page, server} = testState; + + await page.tracing.start({screenshots: true, path: outputFile}); + await page.goto(server.PREFIX + '/grid.html'); + const trace = (await page.tracing.stop())!; + const buf = fs.readFileSync(outputFile); + expect(trace.toString()).toEqual(buf.toString()); + }); + it('should work without options', async () => { + const {page, server} = testState; + + await page.tracing.start(); + await page.goto(server.PREFIX + '/grid.html'); + const trace = await page.tracing.stop(); + expect(trace).toBeTruthy(); + }); + + it('should return undefined in case of Buffer error', async () => { + const {page, server} = testState; + + await page.tracing.start({screenshots: true}); + await page.goto(server.PREFIX + '/grid.html'); + + const oldBufferConcat = Buffer.concat; + try { + Buffer.concat = () => { + throw new Error('error'); + }; + const trace = await page.tracing.stop(); + expect(trace).toEqual(undefined); + } finally { + Buffer.concat = oldBufferConcat; + } + }); + + it('should support a buffer without a path', async () => { + const {page, server} = testState; + + await page.tracing.start({screenshots: true}); + await page.goto(server.PREFIX + '/grid.html'); + const trace = (await page.tracing.stop())!; + expect(trace.toString()).toContain('screenshot'); + }); + + it('should properly fail if readProtocolStream errors out', async () => { + const {page} = testState; + await page.tracing.start({path: __dirname}); + + let error!: Error; + try { + await page.tracing.stop(); + } catch (error_) { + error = error_ as Error; + } + expect(error).toBeDefined(); + }); +}); diff --git a/remote/test/puppeteer/test/src/utils.ts b/remote/test/puppeteer/test/src/utils.ts new file mode 100644 index 0000000000..d1bad65a16 --- /dev/null +++ b/remote/test/puppeteer/test/src/utils.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {rm} from 'fs/promises'; +import {tmpdir} from 'os'; +import path from 'path'; + +import expect from 'expect'; +import type {Frame} from 'puppeteer-core/internal/api/Frame.js'; +import type {Page} from 'puppeteer-core/internal/api/Page.js'; +import type {EventEmitter} from 'puppeteer-core/internal/common/EventEmitter.js'; +import {Deferred} from 'puppeteer-core/internal/util/Deferred.js'; + +import {compare} from './golden-utils.js'; + +const PROJECT_ROOT = path.join(__dirname, '..', '..'); + +declare module 'expect' { + interface Matchers<R> { + toBeGolden(pathOrBuffer: string | Buffer): R; + } +} + +export const extendExpectWithToBeGolden = ( + goldenDir: string, + outputDir: string +): void => { + expect.extend({ + toBeGolden: (testScreenshot: string | Buffer, goldenFilePath: string) => { + const result = compare( + goldenDir, + outputDir, + testScreenshot, + goldenFilePath + ); + + if (result.pass) { + return { + pass: true, + message: () => { + return ''; + }, + }; + } else { + return { + pass: false, + message: () => { + return result.message; + }, + }; + } + }, + }); +}; + +export const projectRoot = (): string => { + return PROJECT_ROOT; +}; + +export const attachFrame = async ( + pageOrFrame: Page | Frame, + frameId: string, + url: string +): Promise<Frame | undefined> => { + using handle = await pageOrFrame.evaluateHandle(attachFrame, frameId, url); + return (await handle.asElement()?.contentFrame()) ?? undefined; + + async function attachFrame(frameId: string, url: string) { + const frame = document.createElement('iframe'); + frame.src = url; + frame.id = frameId; + document.body.appendChild(frame); + await new Promise(x => { + return (frame.onload = x); + }); + return frame; + } +}; + +export const isFavicon = (request: {url: () => string | string[]}): boolean => { + return request.url().includes('favicon.ico'); +}; + +export async function detachFrame( + pageOrFrame: Page | Frame, + frameId: string +): Promise<void> { + await pageOrFrame.evaluate(detachFrame, frameId); + + function detachFrame(frameId: string) { + const frame = document.getElementById(frameId) as HTMLIFrameElement; + frame.remove(); + } +} + +export async function navigateFrame( + pageOrFrame: Page | Frame, + frameId: string, + url: string +): Promise<void> { + await pageOrFrame.evaluate(navigateFrame, frameId, url); + + function navigateFrame(frameId: string, url: string) { + const frame = document.getElementById(frameId) as HTMLIFrameElement; + frame.src = url; + return new Promise(x => { + return (frame.onload = x); + }); + } +} + +export const dumpFrames = (frame: Frame, indentation?: string): string[] => { + indentation = indentation || ''; + let description = frame.url().replace(/:\d{4,5}\//, ':<PORT>/'); + if (frame.name()) { + description += ' (' + frame.name() + ')'; + } + const result = [indentation + description]; + for (const child of frame.childFrames()) { + result.push(...dumpFrames(child, ' ' + indentation)); + } + return result; +}; + +export const waitEvent = async <T = any>( + emitter: EventEmitter<any>, + eventName: string, + predicate: (event: T) => boolean = () => { + return true; + } +): Promise<T> => { + const deferred = Deferred.create<T>({ + timeout: 5000, + message: `Waiting for ${eventName} event timed out.`, + }); + const handler = (event: T) => { + if (!predicate(event)) { + return; + } + deferred.resolve(event); + }; + emitter.on(eventName, handler); + try { + return await deferred.valueOrThrow(); + } finally { + emitter.off(eventName, handler); + } +}; + +export interface FilePlaceholder { + filename: `${string}.webm`; + [Symbol.dispose](): void; +} + +export function getUniqueVideoFilePlaceholder(): FilePlaceholder { + return { + filename: `${tmpdir()}/test-video-${Math.round( + Math.random() * 10000 + )}.webm`, + [Symbol.dispose]() { + void rmIfExists(this.filename); + }, + }; +} + +export function rmIfExists(file: string): Promise<void> { + return rm(file).catch(() => {}); +} diff --git a/remote/test/puppeteer/test/src/waittask.spec.ts b/remote/test/puppeteer/test/src/waittask.spec.ts new file mode 100644 index 0000000000..8ff52db16f --- /dev/null +++ b/remote/test/puppeteer/test/src/waittask.spec.ts @@ -0,0 +1,867 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import {TimeoutError, ElementHandle} from 'puppeteer'; +import {isErrorLike} from 'puppeteer-core/internal/util/ErrorLike.js'; + +import { + createTimeout, + getTestState, + setupTestBrowserHooks, +} from './mocha-utils.js'; +import {attachFrame, detachFrame} from './utils.js'; + +describe('waittask specs', function () { + setupTestBrowserHooks(); + + describe('Frame.waitForFunction', function () { + it('should accept a string', async () => { + const {page} = await getTestState(); + + const watchdog = page.waitForFunction('self.__FOO === 1'); + await page.evaluate(() => { + return ((self as unknown as {__FOO: number}).__FOO = 1); + }); + await watchdog; + }); + it('should work when resolved right before execution context disposal', async () => { + const {page} = await getTestState(); + + await page.evaluateOnNewDocument(() => { + return ((globalThis as any).__RELOADED = true); + }); + await page.waitForFunction(() => { + if (!(globalThis as any).__RELOADED) { + window.location.reload(); + return false; + } + return true; + }); + }); + it('should poll on interval', async () => { + const {page} = await getTestState(); + const startTime = Date.now(); + const polling = 100; + const watchdog = page.waitForFunction( + () => { + return (globalThis as any).__FOO === 'hit'; + }, + {polling} + ); + await page.evaluate(() => { + setTimeout(() => { + (globalThis as any).__FOO = 'hit'; + }, 50); + }); + await watchdog; + expect(Date.now() - startTime).not.toBeLessThan(polling / 2); + }); + it('should poll on mutation', async () => { + const {page} = await getTestState(); + + let success = false; + const watchdog = page + .waitForFunction( + () => { + return (globalThis as any).__FOO === 'hit'; + }, + { + polling: 'mutation', + } + ) + .then(() => { + return (success = true); + }); + await page.evaluate(() => { + return ((globalThis as any).__FOO = 'hit'); + }); + expect(success).toBe(false); + await page.evaluate(() => { + return document.body.appendChild(document.createElement('div')); + }); + await watchdog; + }); + it('should poll on mutation async', async () => { + const {page} = await getTestState(); + + let success = false; + const watchdog = page + .waitForFunction( + async () => { + return (globalThis as any).__FOO === 'hit'; + }, + { + polling: 'mutation', + } + ) + .then(() => { + return (success = true); + }); + await page.evaluate(async () => { + return ((globalThis as any).__FOO = 'hit'); + }); + expect(success).toBe(false); + await page.evaluate(async () => { + return document.body.appendChild(document.createElement('div')); + }); + await watchdog; + }); + it('should poll on raf', async () => { + const {page} = await getTestState(); + + const watchdog = page.waitForFunction( + () => { + return (globalThis as any).__FOO === 'hit'; + }, + { + polling: 'raf', + } + ); + await page.evaluate(() => { + return ((globalThis as any).__FOO = 'hit'); + }); + await watchdog; + }); + it('should poll on raf async', async () => { + const {page} = await getTestState(); + + const watchdog = page.waitForFunction( + async () => { + return (globalThis as any).__FOO === 'hit'; + }, + { + polling: 'raf', + } + ); + await page.evaluate(async () => { + return ((globalThis as any).__FOO = 'hit'); + }); + await watchdog; + }); + it('should work with strict CSP policy', async () => { + const {page, server} = await getTestState(); + + server.setCSP('/empty.html', 'script-src ' + server.PREFIX); + await page.goto(server.EMPTY_PAGE); + let error!: Error; + await Promise.all([ + page + .waitForFunction( + () => { + return (globalThis as any).__FOO === 'hit'; + }, + { + polling: 'raf', + } + ) + .catch(error_ => { + return (error = error_); + }), + page.evaluate(() => { + return ((globalThis as any).__FOO = 'hit'); + }), + ]); + expect(error).toBeUndefined(); + }); + it('should throw negative polling interval', async () => { + const {page} = await getTestState(); + + let error!: Error; + try { + await page.waitForFunction( + () => { + return !!document.body; + }, + {polling: -10} + ); + } catch (error_) { + if (isErrorLike(error_)) { + error = error_ as Error; + } + } + expect(error?.message).toContain( + 'Cannot poll with non-positive interval' + ); + }); + it('should return the success value as a JSHandle', async () => { + const {page} = await getTestState(); + + expect( + await ( + await page.waitForFunction(() => { + return 5; + }) + ).jsonValue() + ).toBe(5); + }); + it('should return the window as a success value', async () => { + const {page} = await getTestState(); + + expect( + await page.waitForFunction(() => { + return window; + }) + ).toBeTruthy(); + }); + it('should accept ElementHandle arguments', async () => { + const {page} = await getTestState(); + + await page.setContent('<div></div>'); + using div = (await page.$('div'))!; + let resolved = false; + const waitForFunction = page + .waitForFunction( + element => { + return element.localName === 'div' && !element.parentElement; + }, + {}, + div + ) + .then(() => { + return (resolved = true); + }); + expect(resolved).toBe(false); + await page.evaluate((element: HTMLElement) => { + return element.remove(); + }, div); + await waitForFunction; + }); + it('should respect timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page + .waitForFunction( + () => { + return false; + }, + {timeout: 10} + ) + .catch(error_ => { + return (error = error_); + }); + + expect(error).toBeInstanceOf(TimeoutError); + expect(error?.message).toContain('Waiting failed: 10ms exceeded'); + }); + it('should respect default timeout', async () => { + const {page} = await getTestState(); + + page.setDefaultTimeout(1); + let error!: Error; + await page + .waitForFunction(() => { + return false; + }) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + expect(error?.message).toContain('Waiting failed: 1ms exceeded'); + }); + it('should disable timeout when its set to 0', async () => { + const {page} = await getTestState(); + + const watchdog = page.waitForFunction( + () => { + (globalThis as any).__counter = + ((globalThis as any).__counter || 0) + 1; + return (globalThis as any).__injected; + }, + {timeout: 0, polling: 10} + ); + await page.waitForFunction(() => { + return (globalThis as any).__counter > 10; + }); + await page.evaluate(() => { + return ((globalThis as any).__injected = true); + }); + await watchdog; + }); + it('should survive cross-process navigation', async () => { + const {page, server} = await getTestState(); + + let fooFound = false; + const waitForFunction = page + .waitForFunction(() => { + return (globalThis as unknown as {__FOO: number}).__FOO === 1; + }) + .then(() => { + return (fooFound = true); + }); + await page.goto(server.EMPTY_PAGE); + expect(fooFound).toBe(false); + await page.reload(); + expect(fooFound).toBe(false); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + expect(fooFound).toBe(false); + await page.evaluate(() => { + return ((globalThis as any).__FOO = 1); + }); + await waitForFunction; + expect(fooFound).toBe(true); + }); + it('should survive navigations', async () => { + const {page, server} = await getTestState(); + + const watchdog = page.waitForFunction(() => { + return (globalThis as any).__done; + }); + await page.goto(server.EMPTY_PAGE); + await page.goto(server.PREFIX + '/consolelog.html'); + await page.evaluate(() => { + return ((globalThis as any).__done = true); + }); + await watchdog; + }); + it('should be cancellable', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const abortController = new AbortController(); + const task = page.waitForFunction( + () => { + return (globalThis as any).__done; + }, + { + signal: abortController.signal, + } + ); + abortController.abort(); + await expect(task).rejects.toThrow(/aborted/); + }); + }); + + describe('Page.waitForTimeout', () => { + it('waits for the given timeout before resolving', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + const startTime = Date.now(); + await page.waitForTimeout(1000); + const endTime = Date.now(); + /* In a perfect world endTime - startTime would be exactly 1000 but we + * expect some fluctuations and for it to be off by a little bit. So to + * avoid a flaky test we'll make sure it waited for roughly 1 second. + */ + expect(endTime - startTime).toBeGreaterThan(700); + expect(endTime - startTime).toBeLessThan(1300); + }); + }); + + describe('Frame.waitForTimeout', () => { + it('waits for the given timeout before resolving', async () => { + const {page, server} = await getTestState(); + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const startTime = Date.now(); + await frame.waitForTimeout(1000); + const endTime = Date.now(); + /* In a perfect world endTime - startTime would be exactly 1000 but we + * expect some fluctuations and for it to be off by a little bit. So to + * avoid a flaky test we'll make sure it waited for roughly 1 second + */ + expect(endTime - startTime).toBeGreaterThan(700); + expect(endTime - startTime).toBeLessThan(1300); + }); + }); + + describe('Frame.waitForSelector', function () { + const addElement = (tag: string) => { + return document.body.appendChild(document.createElement(tag)); + }; + + it('should immediately resolve promise if node exists', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + await frame.waitForSelector('*'); + await frame.evaluate(addElement, 'div'); + await frame.waitForSelector('div'); + }); + + it('should be cancellable', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const abortController = new AbortController(); + const task = page.waitForSelector('wrong', { + signal: abortController.signal, + }); + abortController.abort(); + await expect(task).rejects.toThrow(/aborted/); + }); + + it('should work with removed MutationObserver', async () => { + const {page} = await getTestState(); + + await page.evaluate(() => { + // @ts-expect-error We want to remove it for the test. + return delete window.MutationObserver; + }); + const [handle] = await Promise.all([ + page.waitForSelector('.zombo'), + page.setContent(`<div class='zombo'>anything</div>`), + ]); + expect( + await page.evaluate(x => { + return x?.textContent; + }, handle) + ).toBe('anything'); + }); + + it('should resolve promise when node is added', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const watchdog = frame.waitForSelector('div'); + await frame.evaluate(addElement, 'br'); + await frame.evaluate(addElement, 'div'); + using eHandle = (await watchdog)!; + const tagName = await (await eHandle.getProperty('tagName')).jsonValue(); + expect(tagName).toBe('DIV'); + }); + + it('should work when node is added through innerHTML', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + const watchdog = page.waitForSelector('h3 div'); + await page.evaluate(addElement, 'span'); + await page.evaluate(() => { + return (document.querySelector('span')!.innerHTML = + '<h3><div></div></h3>'); + }); + await watchdog; + }); + + it('Page.waitForSelector is shortcut for main frame', async () => { + const {page, server} = await getTestState(); + + await page.goto(server.EMPTY_PAGE); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + const otherFrame = page.frames()[1]!; + const watchdog = page.waitForSelector('div'); + await otherFrame.evaluate(addElement, 'div'); + await page.evaluate(addElement, 'div'); + using eHandle = await watchdog; + expect(eHandle?.frame).toBe(page.mainFrame()); + }); + + it('should run in specified frame', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]!; + const frame2 = page.frames()[2]!; + const waitForSelectorPromise = frame2.waitForSelector('div'); + await frame1.evaluate(addElement, 'div'); + await frame2.evaluate(addElement, 'div'); + using eHandle = await waitForSelectorPromise; + expect(eHandle?.frame).toBe(frame2); + }); + + it('should throw when frame is detached', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]!; + let waitError: Error | undefined; + const waitPromise = frame.waitForSelector('.box').catch(error => { + return (waitError = error); + }); + await detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError?.message).toContain( + 'waitForFunction failed: frame got detached.' + ); + }); + it('should survive cross-process navigation', async () => { + const {page, server} = await getTestState(); + + let boxFound = false; + const waitForSelector = page.waitForSelector('.box').then(() => { + return (boxFound = true); + }); + await page.goto(server.EMPTY_PAGE); + expect(boxFound).toBe(false); + await page.reload(); + expect(boxFound).toBe(false); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + await waitForSelector; + expect(boxFound).toBe(true); + }); + it('should wait for element to be visible (display)', async () => { + const {page} = await getTestState(); + + const promise = page.waitForSelector('div', {visible: true}); + await page.setContent('<div style="display: none">text</div>'); + using element = await page.evaluateHandle(() => { + return document.getElementsByTagName('div')[0]!; + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + e.style.removeProperty('display'); + }); + await expect(promise).resolves.toBeTruthy(); + }); + it('should wait for element to be visible (visibility)', async () => { + const {page} = await getTestState(); + + const promise = page.waitForSelector('div', {visible: true}); + await page.setContent('<div style="visibility: hidden">text</div>'); + using element = await page.evaluateHandle(() => { + return document.getElementsByTagName('div')[0]!; + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + e.style.setProperty('visibility', 'collapse'); + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + e.style.removeProperty('visibility'); + }); + await expect(promise).resolves.toBeTruthy(); + }); + it('should wait for element to be visible (bounding box)', async () => { + const {page} = await getTestState(); + + const promise = page.waitForSelector('div', {visible: true}); + await page.setContent('<div style="width: 0">text</div>'); + using element = await page.evaluateHandle(() => { + return document.getElementsByTagName('div')[0]!; + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + e.style.setProperty('height', '0'); + e.style.removeProperty('width'); + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + e.style.removeProperty('height'); + }); + await expect(promise).resolves.toBeTruthy(); + }); + it('should wait for element to be visible recursively', async () => { + const {page} = await getTestState(); + + const promise = page.waitForSelector('div#inner', { + visible: true, + }); + await page.setContent( + `<div style='display: none; visibility: hidden;'><div id="inner">hi</div></div>` + ); + using element = await page.evaluateHandle(() => { + return document.getElementsByTagName('div')[0]!; + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + return e.style.removeProperty('display'); + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + return e.style.removeProperty('visibility'); + }); + await expect(promise).resolves.toBeTruthy(); + }); + it('should wait for element to be hidden (visibility)', async () => { + const {page} = await getTestState(); + + const promise = page.waitForSelector('div', {hidden: true}); + await page.setContent(`<div style='display: block;'>text</div>`); + using element = await page.evaluateHandle(() => { + return document.getElementsByTagName('div')[0]!; + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + return e.style.setProperty('visibility', 'hidden'); + }); + await expect(promise).resolves.toBeTruthy(); + }); + it('should wait for element to be hidden (display)', async () => { + const {page} = await getTestState(); + + const promise = page.waitForSelector('div', {hidden: true}); + await page.setContent(`<div style='display: block;'>text</div>`); + using element = await page.evaluateHandle(() => { + return document.getElementsByTagName('div')[0]!; + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + return e.style.setProperty('display', 'none'); + }); + await expect(promise).resolves.toBeTruthy(); + }); + it('should wait for element to be hidden (bounding box)', async () => { + const {page} = await getTestState(); + + const promise = page.waitForSelector('div', {hidden: true}); + await page.setContent('<div>text</div>'); + using element = await page.evaluateHandle(() => { + return document.getElementsByTagName('div')[0]!; + }); + await expect( + Promise.race([promise, createTimeout(40)]) + ).resolves.toBeFalsy(); + await element.evaluate(e => { + e.style.setProperty('height', '0'); + }); + await expect(promise).resolves.toBeTruthy(); + }); + it('should wait for element to be hidden (removal)', async () => { + const {page} = await getTestState(); + + const promise = page.waitForSelector('div', {hidden: true}); + await page.setContent(`<div>text</div>`); + using element = await page.evaluateHandle(() => { + return document.getElementsByTagName('div')[0]!; + }); + await expect( + Promise.race([promise, createTimeout(40, true)]) + ).resolves.toBeTruthy(); + await element.evaluate(e => { + e.remove(); + }); + await expect(promise).resolves.toBeFalsy(); + }); + it('should return null if waiting to hide non-existing element', async () => { + const {page} = await getTestState(); + + using handle = await page.waitForSelector('non-existing', { + hidden: true, + }); + expect(handle).toBe(null); + }); + it('should respect timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.waitForSelector('div', {timeout: 10}).catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + expect(error?.message).toContain( + 'Waiting for selector `div` failed: Waiting failed: 10ms exceeded' + ); + }); + it('should have an error message specifically for awaiting an element to be hidden', async () => { + const {page} = await getTestState(); + + await page.setContent(`<div>text</div>`); + let error!: Error; + await page + .waitForSelector('div', {hidden: true, timeout: 10}) + .catch(error_ => { + return (error = error_); + }); + expect(error).toBeTruthy(); + expect(error?.message).toContain( + 'Waiting for selector `div` failed: Waiting failed: 10ms exceeded' + ); + }); + + it('should respond to node attribute mutation', async () => { + const {page} = await getTestState(); + + let divFound = false; + const waitForSelector = page.waitForSelector('.zombo').then(() => { + return (divFound = true); + }); + await page.setContent(`<div class='notZombo'></div>`); + expect(divFound).toBe(false); + await page.evaluate(() => { + return (document.querySelector('div')!.className = 'zombo'); + }); + expect(await waitForSelector).toBe(true); + }); + it('should return the element handle', async () => { + const {page} = await getTestState(); + + const waitForSelector = page.waitForSelector('.zombo'); + await page.setContent(`<div class='zombo'>anything</div>`); + expect( + await page.evaluate( + x => { + return x?.textContent; + }, + await waitForSelector + ) + ).toBe('anything'); + }); + it('should have correct stack trace for timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.waitForSelector('.zombo', {timeout: 10}).catch(error_ => { + return (error = error_); + }); + expect(error?.stack).toContain( + 'Waiting for selector `.zombo` failed: Waiting failed: 10ms exceeded' + ); + // The extension is ts here as Mocha maps back via sourcemaps. + expect(error?.stack).toContain('WaitTask.ts'); + }); + }); + + describe('Frame.waitForXPath', function () { + const addElement = (tag: string) => { + return document.body.appendChild(document.createElement(tag)); + }; + + it('should support some fancy xpath', async () => { + const {page} = await getTestState(); + + await page.setContent(`<p>red herring</p><p>hello world </p>`); + const waitForXPath = page.waitForXPath( + '//p[normalize-space(.)="hello world"]' + ); + expect( + await page.evaluate( + x => { + return x?.textContent; + }, + await waitForXPath + ) + ).toBe('hello world '); + }); + it('should respect timeout', async () => { + const {page} = await getTestState(); + + let error!: Error; + await page.waitForXPath('//div', {timeout: 10}).catch(error_ => { + return (error = error_); + }); + expect(error).toBeInstanceOf(TimeoutError); + expect(error?.message).toContain('Waiting failed: 10ms exceeded'); + }); + it('should run in specified frame', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]!; + const frame2 = page.frames()[2]!; + const waitForXPathPromise = frame2.waitForXPath('//div'); + await frame1.evaluate(addElement, 'div'); + await frame2.evaluate(addElement, 'div'); + using eHandle = await waitForXPathPromise; + expect(eHandle?.frame).toBe(frame2); + }); + it('should throw when frame is detached', async () => { + const {page, server} = await getTestState(); + + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]!; + let waitError: Error | undefined; + const waitPromise = frame + .waitForXPath('//*[@class="box"]') + .catch(error => { + return (waitError = error); + }); + await detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError?.message).toContain( + 'waitForFunction failed: frame got detached.' + ); + }); + it('hidden should wait for display: none', async () => { + const {page} = await getTestState(); + + let divHidden = false; + await page.setContent(`<div style='display: block;'>text</div>`); + const waitForXPath = page + .waitForXPath('//div', {hidden: true}) + .then(() => { + return (divHidden = true); + }); + await page.waitForXPath('//div'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => { + return document + .querySelector('div') + ?.style.setProperty('display', 'none'); + }); + expect(await waitForXPath).toBe(true); + expect(divHidden).toBe(true); + }); + it('hidden should return null if the element is not found', async () => { + const {page} = await getTestState(); + + using waitForXPath = await page.waitForXPath('//div', {hidden: true}); + + expect(waitForXPath).toBe(null); + }); + it('hidden should return an empty element handle if the element is found', async () => { + const {page} = await getTestState(); + + await page.setContent(`<div style='display: none;'>text</div>`); + + using waitForXPath = await page.waitForXPath('//div', {hidden: true}); + + expect(waitForXPath).toBeInstanceOf(ElementHandle); + }); + it('should return the element handle', async () => { + const {page} = await getTestState(); + + const waitForXPath = page.waitForXPath('//*[@class="zombo"]'); + await page.setContent(`<div class='zombo'>anything</div>`); + expect( + await page.evaluate( + x => { + return x?.textContent; + }, + await waitForXPath + ) + ).toBe('anything'); + }); + it('should allow you to select a text node', async () => { + const {page} = await getTestState(); + + await page.setContent(`<div>some text</div>`); + using text = await page.waitForXPath('//div/text()'); + expect(await (await text!.getProperty('nodeType')!).jsonValue()).toBe( + 3 /* Node.TEXT_NODE */ + ); + }); + it('should allow you to select an element with single slash', async () => { + const {page} = await getTestState(); + + await page.setContent(`<div>some text</div>`); + const waitForXPath = page.waitForXPath('/html/body/div'); + expect( + await page.evaluate( + x => { + return x?.textContent; + }, + await waitForXPath + ) + ).toBe('some text'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/src/worker.spec.ts b/remote/test/puppeteer/test/src/worker.spec.ts new file mode 100644 index 0000000000..254ff4a514 --- /dev/null +++ b/remote/test/puppeteer/test/src/worker.spec.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from 'expect'; +import type {WebWorker} from 'puppeteer-core/internal/api/WebWorker.js'; +import type {ConsoleMessage} from 'puppeteer-core/internal/common/ConsoleMessage.js'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {waitEvent} from './utils.js'; + +describe('Workers', function () { + setupTestBrowserHooks(); + + it('Page.workers', async () => { + const {page, server} = await getTestState(); + + await Promise.all([ + waitEvent(page, 'workercreated'), + page.goto(server.PREFIX + '/worker/worker.html'), + ]); + const worker = page.workers()[0]!; + expect(worker?.url()).toContain('worker.js'); + + expect( + await worker?.evaluate(() => { + return (globalThis as any).workerFunction(); + }) + ).toBe('worker function result'); + + await page.goto(server.EMPTY_PAGE); + expect(page.workers()).toHaveLength(0); + }); + it('should emit created and destroyed events', async () => { + const {page} = await getTestState(); + + const workerCreatedPromise = waitEvent<WebWorker>(page, 'workercreated'); + using workerObj = await page.evaluateHandle(() => { + return new Worker('data:text/javascript,1'); + }); + const worker = await workerCreatedPromise; + using workerThisObj = await worker.evaluateHandle(() => { + return this; + }); + const workerDestroyedPromise = waitEvent(page, 'workerdestroyed'); + await page.evaluate((workerObj: Worker) => { + return workerObj.terminate(); + }, workerObj); + expect(await workerDestroyedPromise).toBe(worker); + const error = await workerThisObj.getProperty('self').catch(error => { + return error; + }); + expect(error.message).toContain('Most likely the worker has been closed.'); + }); + it('should report console logs', async () => { + const {page} = await getTestState(); + + const [message] = await Promise.all([ + waitEvent(page, 'console'), + page.evaluate(() => { + return new Worker(`data:text/javascript,console.log(1)`); + }), + ]); + expect(message.text()).toBe('1'); + expect(message.location()).toEqual({ + url: '', + lineNumber: 0, + columnNumber: 8, + }); + }); + it('should have JSHandles for console logs', async () => { + const {page} = await getTestState(); + + const logPromise = waitEvent<ConsoleMessage>(page, 'console'); + await page.evaluate(() => { + return new Worker(`data:text/javascript,console.log(1,2,3,this)`); + }); + const log = await logPromise; + expect(log.text()).toBe('1 2 3 JSHandle@object'); + expect(log.args()).toHaveLength(4); + expect(await (await log.args()[3]!.getProperty('origin')).jsonValue()).toBe( + 'null' + ); + }); + it('should have an execution context', async () => { + const {page} = await getTestState(); + + const workerCreatedPromise = waitEvent<WebWorker>(page, 'workercreated'); + await page.evaluate(() => { + return new Worker(`data:text/javascript,console.log(1)`); + }); + const worker = await workerCreatedPromise; + expect(await worker.evaluate('1+1')).toBe(2); + }); + it('should report errors', async () => { + const {page} = await getTestState(); + + const errorPromise = waitEvent<Error>(page, 'pageerror'); + await page.evaluate(() => { + return new Worker( + `data:text/javascript, throw new Error('this is my error');` + ); + }); + const errorLog = await errorPromise; + expect(errorLog.message).toContain('this is my error'); + }); +}); diff --git a/remote/test/puppeteer/test/tsconfig.json b/remote/test/puppeteer/test/tsconfig.json new file mode 100644 index 0000000000..554d034ff1 --- /dev/null +++ b/remote/test/puppeteer/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "build", + "rootDir": "src", + }, + "include": ["src"], +} diff --git a/remote/test/puppeteer/test/tsdoc.json b/remote/test/puppeteer/test/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/test/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/analyze_issue.mjs b/remote/test/puppeteer/tools/analyze_issue.mjs new file mode 100755 index 0000000000..9592112de0 --- /dev/null +++ b/remote/test/puppeteer/tools/analyze_issue.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// @ts-check + +'use strict'; + +import {writeFile, mkdir, copyFile} from 'fs/promises'; +import {dirname, join} from 'path'; +import {fileURLToPath} from 'url'; + +import core from '@actions/core'; +import semver from 'semver'; + +import packageJson from '../packages/puppeteer-core/package.json' assert {type: 'json'}; + +const codifyAndJoinValues = values => { + return values + .map(value => { + return `\`${value}\``; + }) + .join(' ,'); +}; +const formatMessage = value => { + return value.trim(); +}; +const removeVersionPrefix = value => { + return value.startsWith('v') ? value.slice(1) : value; +}; + +const LAST_PUPPETEER_VERSION = packageJson.version; +if (!LAST_PUPPETEER_VERSION) { + core.setFailed('No maintained version found.'); +} +const LAST_SUPPORTED_NODE_VERSION = removeVersionPrefix( + packageJson.engines.node.slice(2).trim() +); + +const SUPPORTED_OSES = ['windows', 'macos', 'linux']; +const SUPPORTED_PACKAGE_MANAGERS = ['yarn', 'npm', 'pnpm']; + +const ERROR_MESSAGES = { + unsupportedOs(value) { + return formatMessage(` +This issue has an unsupported OS: \`${value}\`. Only the following operating systems are supported: ${codifyAndJoinValues( + SUPPORTED_OSES + )}. Please verify the issue on a supported OS and update the form. +`); + }, + unsupportedPackageManager(value) { + return formatMessage(` +This issue has an unsupported package manager: \`${value}\`. Only the following package managers are supported: ${codifyAndJoinValues( + SUPPORTED_PACKAGE_MANAGERS + )}. Please verify the issue using a supported package manager and update the form. +`); + }, + invalidPackageManagerVersion(value) { + return formatMessage(` +This issue has an invalid package manager version: \`${value}\`. Versions must follow [SemVer](https://semver.org/) formatting. Please update the form with a valid version. +`); + }, + unsupportedNodeVersion(value) { + return formatMessage(` +This issue has an unsupported Node.js version: \`${value}\`. Only versions above \`v${LAST_SUPPORTED_NODE_VERSION}\` are supported. Please verify the issue on a supported version of Node.js and update the form. +`); + }, + invalidNodeVersion(value) { + return formatMessage(` +This issue has an invalid Node.js version: \`${value}\`. Versions must follow [SemVer](https://semver.org/) formatting. Please update the form with a valid version. +`); + }, + unsupportedPuppeteerVersion(value) { + return formatMessage(` +This issue has an outdated Puppeteer version: \`${value}\`. Please verify your issue on the latest \`${LAST_PUPPETEER_VERSION}\` version. Then update the form accordingly. +`); + }, + invalidPuppeteerVersion(value) { + return formatMessage(` +This issue has an invalid Puppeteer version: \`${value}\`. Versions must follow [SemVer](https://semver.org/) formatting. Please update the form with a valid version. +`); + }, +}; + +(async () => { + let input = ''; + for await (const chunk of process.stdin.iterator({ + destroyOnReturn: false, + })) { + input += chunk; + } + input = JSON.parse(input).trim(); + + let mvce = ''; + let error = ''; + let configuration = ''; + let puppeteerVersion = ''; + let nodeVersion = ''; + let packageManagerVersion = ''; + let packageManager = ''; + let os = ''; + const behavior = {}; + const lines = input.split('\n'); + { + /** @type {(value: string) => void} */ + let set = () => { + return void 0; + }; + let j = 1; + let i = 1; + for (; i < lines.length; ++i) { + if (lines[i].startsWith('### Bug behavior')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + if (value.match(/\[x\] Flaky/i)) { + behavior.flaky = true; + } + if (value.match(/\[x\] pdf/i)) { + behavior.noError = true; + } + }; + } else if (lines[i].startsWith('### Minimal, reproducible example')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + mvce = value; + }; + } else if (lines[i].startsWith('### Error string')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + if (value.match(/no error/i)) { + behavior.noError = true; + } else { + error = value; + } + }; + } else if (lines[i].startsWith('### Puppeteer configuration')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + configuration = value; + }; + } else if (lines[i].startsWith('### Puppeteer version')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + puppeteerVersion = removeVersionPrefix(value); + }; + } else if (lines[i].startsWith('### Node version')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + nodeVersion = removeVersionPrefix(value); + }; + } else if (lines[i].startsWith('### Package manager version')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + packageManagerVersion = removeVersionPrefix(value); + }; + } else if (lines[i].startsWith('### Package manager')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + packageManager = value.toLowerCase(); + }; + } else if (lines[i].startsWith('### Operating system')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + os = value.toLowerCase(); + }; + } + } + set(lines.slice(j, i).join('\n').trim()); + } + + let runsOn; + switch (os) { + case 'windows': + runsOn = 'windows-latest'; + break; + case 'macos': + runsOn = 'macos-latest'; + break; + case 'linux': + runsOn = 'ubuntu-latest'; + break; + default: + core.setOutput('errorMessage', ERROR_MESSAGES.unsupportedOs(os)); + core.setFailed(`Unsupported OS: ${os}`); + } + + if (!SUPPORTED_PACKAGE_MANAGERS.includes(packageManager)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.unsupportedPackageManager(packageManager) + ); + core.setFailed(`Unsupported package manager: ${packageManager}`); + } + + if (!semver.valid(nodeVersion)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.invalidNodeVersion(nodeVersion) + ); + core.setFailed('Invalid Node version'); + } + if (semver.lt(nodeVersion, LAST_SUPPORTED_NODE_VERSION)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.unsupportedNodeVersion(nodeVersion) + ); + core.setFailed(`Unsupported node version: ${nodeVersion}`); + } + + if (!semver.valid(puppeteerVersion)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.invalidPuppeteerVersion(puppeteerVersion) + ); + core.setFailed(`Invalid puppeteer version: ${puppeteerVersion}`); + } + if ( + !LAST_PUPPETEER_VERSION || + semver.lt(puppeteerVersion, LAST_PUPPETEER_VERSION) + ) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.unsupportedPuppeteerVersion(puppeteerVersion) + ); + core.setFailed(`Unsupported puppeteer version: ${puppeteerVersion}`); + } + + if (!semver.valid(packageManagerVersion)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.invalidPackageManagerVersion(packageManagerVersion) + ); + core.setFailed(`Invalid package manager version: ${packageManagerVersion}`); + } + + core.setOutput('errorMessage', ''); + core.setOutput('runsOn', runsOn); + core.setOutput('nodeVersion', nodeVersion); + core.setOutput('packageManager', packageManager); + + await mkdir('out'); + Promise.all([ + writeFile(join('out', 'main.ts'), mvce.split('\n').slice(1, -1).join('\n')), + writeFile(join('out', 'puppeteer-error.txt'), error), + writeFile( + join('out', 'puppeteer.config.js'), + configuration.split('\n').slice(1, -1).join('\n') + ), + writeFile(join('out', 'puppeteer-behavior.json'), JSON.stringify(behavior)), + writeFile( + join('out', 'package.json'), + JSON.stringify({ + packageManager: `${packageManager}@${packageManagerVersion}`, + scripts: { + start: 'tsx main.ts', + verify: 'tsx verify_issue.ts', + }, + dependencies: { + puppeteer: puppeteerVersion, + }, + devDependencies: { + tsx: 'latest', + }, + }) + ), + copyFile( + join( + dirname(fileURLToPath(import.meta.url)), + 'assets', + 'verify_issue.ts' + ), + join('out', 'verify_issue.ts') + ), + ]); +})(); diff --git a/remote/test/puppeteer/tools/assets/verify_issue.ts b/remote/test/puppeteer/tools/assets/verify_issue.ts new file mode 100755 index 0000000000..5814eff66c --- /dev/null +++ b/remote/test/puppeteer/tools/assets/verify_issue.ts @@ -0,0 +1,68 @@ +import {spawnSync} from 'child_process'; +import {readFile, writeFile} from 'fs/promises'; + +(async () => { + const error = await readFile('puppeteer-error.txt', 'utf-8'); + const behavior = JSON.parse( + await readFile('puppeteer-behavior.json', 'utf-8') + ) as {flaky?: boolean; noError?: boolean}; + + let maxRepetitions = 1; + if (behavior.flaky) { + maxRepetitions = 100; + } + + let status: number | null = null; + let stderr = ''; + let stdout = ''; + + const preHook = async () => { + console.log('Writing output and error logs...'); + await Promise.all([ + writeFile('output.log', stdout), + writeFile('error.log', stderr), + ]); + }; + + let checkStatusWithError: () => Promise<void>; + if (behavior.noError) { + checkStatusWithError = async () => { + if (status === 0) { + await preHook(); + console.log('Script ran successfully; no error found.'); + process.exit(0); + } + }; + } else { + checkStatusWithError = async () => { + if (status !== 0) { + await preHook(); + if (stderr.toLowerCase().includes(error.toLowerCase())) { + console.log('Script failed; error found.'); + process.exit(0); + } + console.error('Script failed; unknown error found.'); + process.exit(1); + } + }; + } + + for (let i = 0; i < maxRepetitions; ++i) { + const result = spawnSync('npm', ['start'], { + shell: true, + encoding: 'utf-8', + }); + status = result.status; + stdout = result.stdout ?? ''; + stderr = result.stderr ?? ''; + await checkStatusWithError(); + } + + await preHook(); + if (behavior.noError) { + console.error('Script failed; unknown error found.'); + } else { + console.error('Script ran successfully; no error found.'); + } + process.exit(1); +})(); diff --git a/remote/test/puppeteer/tools/chmod.ts b/remote/test/puppeteer/tools/chmod.ts new file mode 100644 index 0000000000..da15b64fae --- /dev/null +++ b/remote/test/puppeteer/tools/chmod.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; + +/** + * Calls chmod with the mode in argv[2] on paths in argv[3...length-1]. + */ +const mode = process.argv[2]; + +for (let i = 3; i < process.argv.length; i++) { + fs.chmodSync(process.argv[i], mode); +} diff --git a/remote/test/puppeteer/tools/clean.js b/remote/test/puppeteer/tools/clean.js new file mode 100755 index 0000000000..049fdc0434 --- /dev/null +++ b/remote/test/puppeteer/tools/clean.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +const {exec} = require('child_process'); +const {readdirSync} = require('fs'); + +exec( + `git clean -Xf ${readdirSync(process.cwd()) + .filter(file => { + return file !== 'node_modules'; + }) + .join(' ')}` +); diff --git a/remote/test/puppeteer/tools/cp.ts b/remote/test/puppeteer/tools/cp.ts new file mode 100644 index 0000000000..2915389e19 --- /dev/null +++ b/remote/test/puppeteer/tools/cp.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; + +/** + * Copies single file in argv[2] to argv[3] + */ +fs.cpSync(process.argv[2], process.argv[3]); diff --git a/remote/test/puppeteer/tools/docgen/package.json b/remote/test/puppeteer/tools/docgen/package.json new file mode 100644 index 0000000000..f1ca4ea127 --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/package.json @@ -0,0 +1,33 @@ +{ + "name": "@puppeteer/docgen", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "./lib/docgen.js", + "description": "Documentation generator for Puppeteer", + "license": "Apache-2.0", + "scripts": { + "build": "wireit", + "clean": "../clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "lib/**", + "tsconfig.tsbuildinfo" + ] + } + }, + "devDependencies": { + "@microsoft/api-extractor": "7.39.4", + "@microsoft/api-documenter": "7.23.20", + "@microsoft/api-extractor-model": "7.28.7", + "@microsoft/tsdoc": "0.14.2", + "@rushstack/node-core-library": "3.64.2" + } +} diff --git a/remote/test/puppeteer/tools/docgen/src/custom_markdown_documenter.ts b/remote/test/puppeteer/tools/docgen/src/custom_markdown_documenter.ts new file mode 100644 index 0000000000..d63a8b96ef --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/src/custom_markdown_documenter.ts @@ -0,0 +1,1495 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the +// MIT license. See LICENSE in the project root for license information. + +// Taken from +// https://github.com/microsoft/rushstack/blob/main/apps/api-documenter/src/documenters/MarkdownDocumenter.ts +// This file has been edited to morph into Docusaurus's expected inputs. + +import * as path from 'path'; + +import type {DocumenterConfig} from '@microsoft/api-documenter/lib/documenters/DocumenterConfig.js'; +import {CustomMarkdownEmitter as ApiFormatterMarkdownEmitter} from '@microsoft/api-documenter/lib/markdown/CustomMarkdownEmitter.js'; +import {CustomDocNodes} from '@microsoft/api-documenter/lib/nodes/CustomDocNodeKind.js'; +import {DocEmphasisSpan} from '@microsoft/api-documenter/lib/nodes/DocEmphasisSpan.js'; +import {DocHeading} from '@microsoft/api-documenter/lib/nodes/DocHeading.js'; +import {DocNoteBox} from '@microsoft/api-documenter/lib/nodes/DocNoteBox.js'; +import {DocTable} from '@microsoft/api-documenter/lib/nodes/DocTable.js'; +import {DocTableCell} from '@microsoft/api-documenter/lib/nodes/DocTableCell.js'; +import {DocTableRow} from '@microsoft/api-documenter/lib/nodes/DocTableRow.js'; +import {MarkdownDocumenterAccessor} from '@microsoft/api-documenter/lib/plugin/MarkdownDocumenterAccessor.js'; +import { + type IMarkdownDocumenterFeatureOnBeforeWritePageArgs, + MarkdownDocumenterFeatureContext, +} from '@microsoft/api-documenter/lib/plugin/MarkdownDocumenterFeature.js'; +import {PluginLoader} from '@microsoft/api-documenter/lib/plugin/PluginLoader.js'; +import {Utilities} from '@microsoft/api-documenter/lib/utils/Utilities.js'; +import { + ApiClass, + ApiDeclaredItem, + ApiDocumentedItem, + type ApiEnum, + ApiInitializerMixin, + ApiInterface, + type ApiItem, + ApiItemKind, + type ApiModel, + type ApiNamespace, + ApiOptionalMixin, + type ApiPackage, + ApiParameterListMixin, + ApiPropertyItem, + ApiProtectedMixin, + ApiReadonlyMixin, + ApiReleaseTagMixin, + ApiReturnTypeMixin, + ApiStaticMixin, + ApiTypeAlias, + type Excerpt, + type ExcerptToken, + ExcerptTokenKind, + type IResolveDeclarationReferenceResult, + ReleaseTag, +} from '@microsoft/api-extractor-model'; +import { + type DocBlock, + DocCodeSpan, + type DocComment, + DocFencedCode, + DocLinkTag, + type DocNodeContainer, + DocNodeKind, + DocParagraph, + DocPlainText, + DocSection, + StandardTags, + StringBuilder, + type TSDocConfiguration, +} from '@microsoft/tsdoc'; +import { + FileSystem, + NewlineKind, + PackageName, +} from '@rushstack/node-core-library'; + +export interface IMarkdownDocumenterOptions { + apiModel: ApiModel; + documenterConfig: DocumenterConfig | undefined; + outputFolder: string; +} + +export class CustomMarkdownEmitter extends ApiFormatterMarkdownEmitter { + protected override getEscapedText(text: string): string { + const textWithBackslashes: string = text + .replace(/\\/g, '\\\\') // first replace the escape character + .replace(/[*#[\]_|`~]/g, x => { + return '\\' + x; + }) // then escape any special characters + .replace(/---/g, '\\-\\-\\-') // hyphens only if it's 3 or more + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\{/g, '{') + .replace(/\}/g, '}'); + return textWithBackslashes; + } + + protected override getTableEscapedText(text: string): string { + return text + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\|/g, '|'); + } +} + +/** + * Renders API documentation in the Markdown file format. + * For more info: https://en.wikipedia.org/wiki/Markdown + */ +export class MarkdownDocumenter { + private readonly _apiModel: ApiModel; + private readonly _documenterConfig: DocumenterConfig | undefined; + private readonly _tsdocConfiguration: TSDocConfiguration; + private readonly _markdownEmitter: CustomMarkdownEmitter; + private readonly _outputFolder: string; + private readonly _pluginLoader: PluginLoader; + + public constructor(options: IMarkdownDocumenterOptions) { + this._apiModel = options.apiModel; + this._documenterConfig = options.documenterConfig; + this._outputFolder = options.outputFolder; + this._tsdocConfiguration = CustomDocNodes.configuration; + this._markdownEmitter = new CustomMarkdownEmitter(this._apiModel); + + this._pluginLoader = new PluginLoader(); + } + + public generateFiles(): void { + if (this._documenterConfig) { + this._pluginLoader.load(this._documenterConfig, () => { + return new MarkdownDocumenterFeatureContext({ + apiModel: this._apiModel, + outputFolder: this._outputFolder, + documenter: new MarkdownDocumenterAccessor({ + getLinkForApiItem: (apiItem: ApiItem) => { + return this._getLinkFilenameForApiItem(apiItem); + }, + }), + }); + }); + } + + this._deleteOldOutputFiles(); + + this._writeApiItemPage(this._apiModel.members[0]!); + + if (this._pluginLoader.markdownDocumenterFeature) { + this._pluginLoader.markdownDocumenterFeature.onFinished({}); + } + } + + private _writeApiItemPage(apiItem: ApiItem): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + const output: DocSection = new DocSection({ + configuration: this._tsdocConfiguration, + }); + + const scopedName: string = apiItem.getScopedNameWithinPackage(); + + switch (apiItem.kind) { + case ApiItemKind.Class: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} class`}) + ); + break; + case ApiItemKind.Enum: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} enum`}) + ); + break; + case ApiItemKind.Interface: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} interface`}) + ); + break; + case ApiItemKind.Constructor: + case ApiItemKind.ConstructSignature: + output.appendNode(new DocHeading({configuration, title: scopedName})); + break; + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} method`}) + ); + break; + case ApiItemKind.Function: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} function`}) + ); + break; + case ApiItemKind.Model: + output.appendNode( + new DocHeading({configuration, title: `API Reference`}) + ); + break; + case ApiItemKind.Namespace: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} namespace`}) + ); + break; + case ApiItemKind.Package: + console.log(`Writing ${apiItem.displayName} package`); + output.appendNode( + new DocHeading({ + configuration, + title: `API Reference`, + }) + ); + break; + case ApiItemKind.Property: + case ApiItemKind.PropertySignature: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} property`}) + ); + break; + case ApiItemKind.TypeAlias: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} type`}) + ); + break; + case ApiItemKind.Variable: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} variable`}) + ); + break; + default: + throw new Error('Unsupported API item kind: ' + apiItem.kind); + } + + if (ApiReleaseTagMixin.isBaseClassOf(apiItem)) { + if (apiItem.releaseTag === ReleaseTag.Beta) { + this._writeBetaWarning(output); + } + } + + const decoratorBlocks: DocBlock[] = []; + + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + decoratorBlocks.push( + ...tsdocComment.customBlocks.filter(block => { + return ( + block.blockTag.tagNameWithUpperCase === + StandardTags.decorator.tagNameWithUpperCase + ); + }) + ); + + if (tsdocComment.deprecatedBlock) { + output.appendNode( + new DocNoteBox({configuration: this._tsdocConfiguration}, [ + new DocParagraph({configuration: this._tsdocConfiguration}, [ + new DocPlainText({ + configuration: this._tsdocConfiguration, + text: 'Warning: This API is now obsolete. ', + }), + ]), + ...tsdocComment.deprecatedBlock.content.nodes, + ]) + ); + } + + this._appendSection(output, tsdocComment.summarySection); + } + } + + if (apiItem instanceof ApiDeclaredItem) { + if (apiItem.excerpt.text.length > 0) { + output.appendNode( + new DocHeading({configuration, title: 'Signature:', level: 4}) + ); + + let code: string; + switch (apiItem.parent?.kind) { + case ApiItemKind.Class: + code = `class ${ + apiItem.parent.displayName + } {${apiItem.getExcerptWithModifiers()}}`; + break; + case ApiItemKind.Interface: + code = `interface ${ + apiItem.parent.displayName + } {${apiItem.getExcerptWithModifiers()}}`; + break; + default: + code = apiItem.getExcerptWithModifiers(); + } + output.appendNode( + new DocFencedCode({ + configuration, + code: code, + language: 'typescript', + }) + ); + } + + this._writeHeritageTypes(output, apiItem); + } + + if (decoratorBlocks.length > 0) { + output.appendNode( + new DocHeading({configuration, title: 'Decorators:', level: 4}) + ); + for (const decoratorBlock of decoratorBlocks) { + output.appendNodes(decoratorBlock.content.nodes); + } + } + + let appendRemarks = true; + switch (apiItem.kind) { + case ApiItemKind.Class: + case ApiItemKind.Interface: + case ApiItemKind.Namespace: + case ApiItemKind.Package: + this._writeRemarksSection(output, apiItem); + appendRemarks = false; + break; + } + + switch (apiItem.kind) { + case ApiItemKind.Class: + this._writeClassTables(output, apiItem as ApiClass); + break; + case ApiItemKind.Enum: + this._writeEnumTables(output, apiItem as ApiEnum); + break; + case ApiItemKind.Interface: + this._writeInterfaceTables(output, apiItem as ApiInterface); + break; + case ApiItemKind.Constructor: + case ApiItemKind.ConstructSignature: + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + case ApiItemKind.Function: + this._writeParameterTables(output, apiItem as ApiParameterListMixin); + this._writeThrowsSection(output, apiItem); + break; + case ApiItemKind.Namespace: + this._writePackageOrNamespaceTables(output, apiItem as ApiNamespace); + break; + case ApiItemKind.Model: + this._writeModelTable(output, apiItem as ApiModel); + break; + case ApiItemKind.Package: + this._writePackageOrNamespaceTables(output, apiItem as ApiPackage); + break; + case ApiItemKind.Property: + case ApiItemKind.PropertySignature: + break; + case ApiItemKind.TypeAlias: + break; + case ApiItemKind.Variable: + break; + default: + throw new Error('Unsupported API item kind: ' + apiItem.kind); + } + + this._writeDefaultValueSection(output, apiItem); + + if (appendRemarks) { + this._writeRemarksSection(output, apiItem); + } + + const filename: string = path.join( + this._outputFolder, + this._getFilenameForApiItem(apiItem) + ); + const stringBuilder: StringBuilder = new StringBuilder(); + + this._markdownEmitter.emit(stringBuilder, output, { + contextApiItem: apiItem, + onGetFilenameForApiItem: (apiItemForFilename: ApiItem) => { + return this._getLinkFilenameForApiItem(apiItemForFilename); + }, + }); + + let pageContent: string = stringBuilder.toString(); + + if (this._pluginLoader.markdownDocumenterFeature) { + // Allow the plugin to customize the pageContent + const eventArgs: IMarkdownDocumenterFeatureOnBeforeWritePageArgs = { + apiItem: apiItem, + outputFilename: filename, + pageContent: pageContent, + }; + this._pluginLoader.markdownDocumenterFeature.onBeforeWritePage(eventArgs); + pageContent = eventArgs.pageContent; + } + + pageContent = + `---\nsidebar_label: ${this._getSidebarLabelForApiItem(apiItem)}\n---` + + pageContent; + pageContent = pageContent.replace('##', '#'); + pageContent = pageContent.replace(/<!-- -->/g, ''); + pageContent = pageContent.replace(/\\\*\\\*/g, '**'); + pageContent = pageContent.replace(/<b>|<\/b>/g, '**'); + FileSystem.writeFile(filename, pageContent, { + convertLineEndings: this._documenterConfig + ? this._documenterConfig.newlineKind + : NewlineKind.CrLf, + }); + } + + private _writeHeritageTypes( + output: DocSection, + apiItem: ApiDeclaredItem + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + if (apiItem instanceof ApiClass) { + if (apiItem.extendsType) { + const extendsParagraph: DocParagraph = new DocParagraph( + {configuration}, + [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'Extends: '}), + ]), + ] + ); + this._appendExcerptWithHyperlinks( + extendsParagraph, + apiItem.extendsType.excerpt + ); + output.appendNode(extendsParagraph); + } + if (apiItem.implementsTypes.length > 0) { + const extendsParagraph: DocParagraph = new DocParagraph( + {configuration}, + [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'Implements: '}), + ]), + ] + ); + let needsComma = false; + for (const implementsType of apiItem.implementsTypes) { + if (needsComma) { + extendsParagraph.appendNode( + new DocPlainText({configuration, text: ', '}) + ); + } + this._appendExcerptWithHyperlinks( + extendsParagraph, + implementsType.excerpt + ); + needsComma = true; + } + output.appendNode(extendsParagraph); + } + } + + if (apiItem instanceof ApiInterface) { + if (apiItem.extendsTypes.length > 0) { + const extendsParagraph: DocParagraph = new DocParagraph( + {configuration}, + [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'Extends: '}), + ]), + ] + ); + let needsComma = false; + for (const extendsType of apiItem.extendsTypes) { + if (needsComma) { + extendsParagraph.appendNode( + new DocPlainText({configuration, text: ', '}) + ); + } + this._appendExcerptWithHyperlinks( + extendsParagraph, + extendsType.excerpt + ); + needsComma = true; + } + output.appendNode(extendsParagraph); + } + } + + if (apiItem instanceof ApiTypeAlias) { + const refs: ExcerptToken[] = apiItem.excerptTokens.filter(token => { + return ( + token.kind === ExcerptTokenKind.Reference && + token.canonicalReference && + this._apiModel.resolveDeclarationReference( + token.canonicalReference, + undefined + ).resolvedApiItem + ); + }); + if (refs.length > 0) { + const referencesParagraph: DocParagraph = new DocParagraph( + {configuration}, + [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'References: '}), + ]), + ] + ); + let needsComma = false; + const visited = new Set<string>(); + for (const ref of refs) { + if (visited.has(ref.text)) { + continue; + } + visited.add(ref.text); + + if (needsComma) { + referencesParagraph.appendNode( + new DocPlainText({configuration, text: ', '}) + ); + } + + this._appendExcerptTokenWithHyperlinks(referencesParagraph, ref); + needsComma = true; + } + output.appendNode(referencesParagraph); + } + } + } + + private _writeDefaultValueSection(output: DocSection, apiItem: ApiItem) { + if (apiItem instanceof ApiDocumentedItem) { + const block = apiItem.tsdocComment?.customBlocks.find(block => { + return ( + block.blockTag.tagNameWithUpperCase === + StandardTags.defaultValue.tagNameWithUpperCase + ); + }); + if (block) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Default value:', + level: 4, + }) + ); + this._appendSection(output, block.content); + } + } + } + + private _writeRemarksSection(output: DocSection, apiItem: ApiItem): void { + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + // Write the @remarks block + if (tsdocComment.remarksBlock) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Remarks', + }) + ); + this._appendSection(output, tsdocComment.remarksBlock.content); + } + + // Write the @example blocks + const exampleBlocks: DocBlock[] = tsdocComment.customBlocks.filter( + x => { + return ( + x.blockTag.tagNameWithUpperCase === + StandardTags.example.tagNameWithUpperCase + ); + } + ); + + let exampleNumber = 1; + for (const exampleBlock of exampleBlocks) { + const heading: string = + exampleBlocks.length > 1 ? `Example ${exampleNumber}` : 'Example'; + + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: heading, + }) + ); + + this._appendSection(output, exampleBlock.content); + + ++exampleNumber; + } + } + } + } + + private _writeThrowsSection(output: DocSection, apiItem: ApiItem): void { + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + // Write the @throws blocks + const throwsBlocks: DocBlock[] = tsdocComment.customBlocks.filter(x => { + return ( + x.blockTag.tagNameWithUpperCase === + StandardTags.throws.tagNameWithUpperCase + ); + }); + + if (throwsBlocks.length > 0) { + const heading = 'Exceptions'; + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: heading, + }) + ); + + for (const throwsBlock of throwsBlocks) { + this._appendSection(output, throwsBlock.content); + } + } + } + } + } + + /** + * GENERATE PAGE: MODEL + */ + private _writeModelTable(output: DocSection, apiModel: ApiModel): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const packagesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Package', 'Description'], + }); + + for (const apiMember of apiModel.members) { + const row: DocTableRow = new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createDescriptionCell(apiMember), + ]); + + switch (apiMember.kind) { + case ApiItemKind.Package: + packagesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + } + } + + if (packagesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Packages', + }) + ); + output.appendNode(packagesTable); + } + } + + /** + * GENERATE PAGE: PACKAGE or NAMESPACE + */ + private _writePackageOrNamespaceTables( + output: DocSection, + apiContainer: ApiPackage | ApiNamespace + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const classesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Class', 'Description'], + }); + + const enumerationsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Enumeration', 'Description'], + }); + + const functionsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Function', 'Description'], + }); + + const interfacesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Interface', 'Description'], + }); + + const namespacesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Namespace', 'Description'], + }); + + const variablesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Variable', 'Description'], + }); + + const typeAliasesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Type Alias', 'Description'], + }); + + const apiMembers: readonly ApiItem[] = + apiContainer.kind === ApiItemKind.Package + ? (apiContainer as ApiPackage).entryPoints[0]!.members + : (apiContainer as ApiNamespace).members; + + for (const apiMember of apiMembers) { + const row: DocTableRow = new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createDescriptionCell(apiMember), + ]); + + switch (apiMember.kind) { + case ApiItemKind.Class: + classesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Enum: + enumerationsTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Interface: + interfacesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Namespace: + namespacesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Function: + functionsTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.TypeAlias: + typeAliasesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Variable: + variablesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + } + } + + if (classesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Classes', + }) + ); + output.appendNode(classesTable); + } + + if (enumerationsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Enumerations', + }) + ); + output.appendNode(enumerationsTable); + } + if (functionsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Functions', + }) + ); + output.appendNode(functionsTable); + } + + if (interfacesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Interfaces', + }) + ); + output.appendNode(interfacesTable); + } + + if (namespacesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Namespaces', + }) + ); + output.appendNode(namespacesTable); + } + + if (variablesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Variables', + }) + ); + output.appendNode(variablesTable); + } + + if (typeAliasesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Type Aliases', + }) + ); + output.appendNode(typeAliasesTable); + } + } + + /** + * GENERATE PAGE: CLASS + */ + private _writeClassTables(output: DocSection, apiClass: ApiClass): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const eventsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Property', 'Modifiers', 'Type', 'Description'], + }); + + const constructorsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Constructor', 'Modifiers', 'Description'], + }); + + const propertiesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Property', 'Modifiers', 'Type', 'Description'], + }); + + const methodsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Method', 'Modifiers', 'Description'], + }); + + for (const apiMember of apiClass.members) { + switch (apiMember.kind) { + case ApiItemKind.Constructor: { + constructorsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createModifiersCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.Method: { + methodsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createModifiersCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.Property: { + if ((apiMember as ApiPropertyItem).isEventProperty) { + eventsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember, true), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + } else { + propertiesTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember, true), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + } + break; + } + } + } + + if (eventsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Events', + }) + ); + output.appendNode(eventsTable); + } + + if (constructorsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Constructors', + }) + ); + output.appendNode(constructorsTable); + } + + if (propertiesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Properties', + }) + ); + output.appendNode(propertiesTable); + } + + if (methodsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Methods', + }) + ); + output.appendNode(methodsTable); + } + } + + /** + * GENERATE PAGE: ENUM + */ + private _writeEnumTables(output: DocSection, apiEnum: ApiEnum): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const enumMembersTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Member', 'Value', 'Description'], + }); + + for (const apiEnumMember of apiEnum.members) { + enumMembersTable.addRow( + new DocTableRow({configuration}, [ + new DocTableCell({configuration}, [ + new DocParagraph({configuration}, [ + new DocPlainText({ + configuration, + text: Utilities.getConciseSignature(apiEnumMember), + }), + ]), + ]), + this._createInitializerCell(apiEnumMember), + this._createDescriptionCell(apiEnumMember), + ]) + ); + } + + if (enumMembersTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Enumeration Members', + }) + ); + output.appendNode(enumMembersTable); + } + } + + /** + * GENERATE PAGE: INTERFACE + */ + private _writeInterfaceTables( + output: DocSection, + apiClass: ApiInterface + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const eventsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Property', 'Modifiers', 'Type', 'Description'], + }); + + const propertiesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Property', 'Modifiers', 'Type', 'Description', 'Default'], + }); + + const methodsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Method', 'Description'], + }); + + for (const apiMember of apiClass.members) { + switch (apiMember.kind) { + case ApiItemKind.ConstructSignature: + case ApiItemKind.MethodSignature: { + methodsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.PropertySignature: { + if ((apiMember as ApiPropertyItem).isEventProperty) { + eventsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember, true), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + } else { + propertiesTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember, true), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell(apiMember), + this._createDefaultCell(apiMember), + ]) + ); + } + break; + } + } + } + + if (eventsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Events', + }) + ); + output.appendNode(eventsTable); + } + + if (propertiesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Properties', + }) + ); + output.appendNode(propertiesTable); + } + + if (methodsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Methods', + }) + ); + output.appendNode(methodsTable); + } + } + + /** + * GENERATE PAGE: FUNCTION-LIKE + */ + private _writeParameterTables( + output: DocSection, + apiParameterListMixin: ApiParameterListMixin + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const parametersTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Parameter', 'Type', 'Description'], + }); + for (const apiParameter of apiParameterListMixin.parameters) { + const parameterDescription: DocSection = new DocSection({configuration}); + + if (apiParameter.isOptional) { + parameterDescription.appendNodesInParagraph([ + new DocEmphasisSpan({configuration, italic: true}, [ + new DocPlainText({configuration, text: '(Optional)'}), + ]), + new DocPlainText({configuration, text: ' '}), + ]); + } + + if (apiParameter.tsdocParamBlock) { + this._appendAndMergeSection( + parameterDescription, + apiParameter.tsdocParamBlock.content + ); + } + + parametersTable.addRow( + new DocTableRow({configuration}, [ + new DocTableCell({configuration}, [ + new DocParagraph({configuration}, [ + new DocPlainText({configuration, text: apiParameter.name}), + ]), + ]), + new DocTableCell({configuration}, [ + this._createParagraphForTypeExcerpt( + apiParameter.parameterTypeExcerpt + ), + ]), + new DocTableCell({configuration}, parameterDescription.nodes), + ]) + ); + } + + if (parametersTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Parameters', + }) + ); + output.appendNode(parametersTable); + } + + if (ApiReturnTypeMixin.isBaseClassOf(apiParameterListMixin)) { + const returnTypeExcerpt: Excerpt = + apiParameterListMixin.returnTypeExcerpt; + output.appendNode( + new DocParagraph({configuration}, [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'Returns:'}), + ]), + ]) + ); + + output.appendNode(this._createParagraphForTypeExcerpt(returnTypeExcerpt)); + + if (apiParameterListMixin instanceof ApiDocumentedItem) { + if ( + apiParameterListMixin.tsdocComment && + apiParameterListMixin.tsdocComment.returnsBlock + ) { + this._appendSection( + output, + apiParameterListMixin.tsdocComment.returnsBlock.content + ); + } + } + } + } + + private _createParagraphForTypeExcerpt(excerpt: Excerpt): DocParagraph { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const paragraph: DocParagraph = new DocParagraph({configuration}); + if (!excerpt.text.trim()) { + paragraph.appendNode( + new DocPlainText({configuration, text: '(not declared)'}) + ); + } else { + this._appendExcerptWithHyperlinks(paragraph, excerpt); + } + + return paragraph; + } + + private _appendExcerptWithHyperlinks( + docNodeContainer: DocNodeContainer, + excerpt: Excerpt + ): void { + for (const token of excerpt.spannedTokens) { + this._appendExcerptTokenWithHyperlinks(docNodeContainer, token); + } + } + + private _appendExcerptTokenWithHyperlinks( + docNodeContainer: DocNodeContainer, + token: ExcerptToken + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + // Markdown doesn't provide a standardized syntax for hyperlinks inside code + // spans, so we will render the type expression as DocPlainText. Instead of + // creating multiple DocParagraphs, we can simply discard any newlines and + // let the renderer do normal word-wrapping. + const unwrappedTokenText: string = token.text.replace(/[\r\n]+/g, ' '); + + // If it's hyperlinkable, then append a DocLinkTag + if (token.kind === ExcerptTokenKind.Reference && token.canonicalReference) { + const apiItemResult: IResolveDeclarationReferenceResult = + this._apiModel.resolveDeclarationReference( + token.canonicalReference, + undefined + ); + + if (apiItemResult.resolvedApiItem) { + docNodeContainer.appendNode( + new DocLinkTag({ + configuration, + tagName: StandardTags.link.tagName, + linkText: unwrappedTokenText, + urlDestination: this._getLinkFilenameForApiItem( + apiItemResult.resolvedApiItem + ), + }) + ); + return; + } + } + + // Otherwise append non-hyperlinked text + docNodeContainer.appendNode( + new DocPlainText({configuration, text: unwrappedTokenText}) + ); + } + + private _createTitleCell(apiItem: ApiItem, plain = false): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const text: string = Utilities.getConciseSignature(apiItem); + + return new DocTableCell({configuration}, [ + new DocParagraph({configuration}, [ + plain + ? new DocPlainText({configuration, text}) + : new DocLinkTag({ + configuration, + tagName: '@link', + linkText: text, + urlDestination: this._getLinkFilenameForApiItem(apiItem), + }), + ]), + ]); + } + + /** + * This generates a DocTableCell for an ApiItem including the summary section + * and "(BETA)" annotation. + * + * @remarks + * We mostly assume that the input is an ApiDocumentedItem, but it's easier to + * perform this as a runtime check than to have each caller perform a type + * cast. + */ + private _createDescriptionCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const section: DocSection = new DocSection({configuration}); + + if (ApiReleaseTagMixin.isBaseClassOf(apiItem)) { + if (apiItem.releaseTag === ReleaseTag.Beta) { + section.appendNodesInParagraph([ + new DocEmphasisSpan({configuration, bold: true, italic: true}, [ + new DocPlainText({configuration, text: '(BETA)'}), + ]), + new DocPlainText({configuration, text: ' '}), + ]); + } + } + + if (apiItem instanceof ApiDocumentedItem) { + if (apiItem.tsdocComment !== undefined) { + this._appendAndMergeSection( + section, + apiItem.tsdocComment.summarySection + ); + } + } + + return new DocTableCell({configuration}, section.nodes); + } + + private _createDefaultCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + if (apiItem instanceof ApiDocumentedItem) { + const block = apiItem.tsdocComment?.customBlocks.find(block => { + return ( + block.blockTag.tagNameWithUpperCase === + StandardTags.defaultValue.tagNameWithUpperCase + ); + }); + if (block !== undefined) { + return new DocTableCell({configuration}, block.content.getChildNodes()); + } + } + + return new DocTableCell({configuration}, []); + } + + private _createModifiersCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const section: DocSection = new DocSection({configuration}); + + if (ApiProtectedMixin.isBaseClassOf(apiItem)) { + if (apiItem.isProtected) { + section.appendNode( + new DocParagraph({configuration}, [ + new DocCodeSpan({configuration, code: 'protected'}), + ]) + ); + } + } + + if (ApiReadonlyMixin.isBaseClassOf(apiItem)) { + if (apiItem.isReadonly) { + section.appendNode( + new DocParagraph({configuration}, [ + new DocCodeSpan({configuration, code: 'readonly'}), + ]) + ); + } + } + + if (ApiStaticMixin.isBaseClassOf(apiItem)) { + if (apiItem.isStatic) { + section.appendNode( + new DocParagraph({configuration}, [ + new DocCodeSpan({configuration, code: 'static'}), + ]) + ); + } + } + + if (ApiOptionalMixin.isBaseClassOf(apiItem)) { + if (apiItem.isOptional) { + section.appendNode( + new DocParagraph({configuration}, [ + new DocCodeSpan({configuration, code: 'optional'}), + ]) + ); + } + } + + return new DocTableCell({configuration}, section.nodes); + } + + private _createPropertyTypeCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const section: DocSection = new DocSection({configuration}); + + if (apiItem instanceof ApiPropertyItem) { + section.appendNode( + this._createParagraphForTypeExcerpt(apiItem.propertyTypeExcerpt) + ); + } + + return new DocTableCell({configuration}, section.nodes); + } + + private _createInitializerCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const section: DocSection = new DocSection({configuration}); + + if (ApiInitializerMixin.isBaseClassOf(apiItem)) { + if (apiItem.initializerExcerpt) { + section.appendNodeInParagraph( + new DocCodeSpan({ + configuration, + code: apiItem.initializerExcerpt.text, + }) + ); + } + } + + return new DocTableCell({configuration}, section.nodes); + } + + private _writeBetaWarning(output: DocSection): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + const betaWarning: string = + 'This API is provided as a preview for developers and may change' + + ' based on feedback that we receive. Do not use this API in a production environment.'; + output.appendNode( + new DocNoteBox({configuration}, [ + new DocParagraph({configuration}, [ + new DocPlainText({configuration, text: betaWarning}), + ]), + ]) + ); + } + + private _appendSection(output: DocSection, docSection: DocSection): void { + for (const node of docSection.nodes) { + output.appendNode(node); + } + } + + private _appendAndMergeSection( + output: DocSection, + docSection: DocSection + ): void { + let firstNode = true; + for (const node of docSection.nodes) { + if (firstNode) { + if (node.kind === DocNodeKind.Paragraph) { + output.appendNodesInParagraph(node.getChildNodes()); + firstNode = false; + continue; + } + } + firstNode = false; + + output.appendNode(node); + } + } + + private _getSidebarLabelForApiItem(apiItem: ApiItem): string { + if (apiItem.kind === ApiItemKind.Package) { + return 'API'; + } + + let baseName = ''; + for (const hierarchyItem of apiItem.getHierarchy()) { + // For overloaded methods, add a suffix such as "MyClass.myMethod_2". + let qualifiedName: string = hierarchyItem.displayName; + if (ApiParameterListMixin.isBaseClassOf(hierarchyItem)) { + if (hierarchyItem.overloadIndex > 1) { + // Subtract one for compatibility with earlier releases of API Documenter. + qualifiedName += `_${hierarchyItem.overloadIndex - 1}`; + } + } + + switch (hierarchyItem.kind) { + case ApiItemKind.Model: + case ApiItemKind.EntryPoint: + case ApiItemKind.EnumMember: + case ApiItemKind.Package: + break; + default: + baseName += qualifiedName + '.'; + } + } + return baseName.slice(0, baseName.length - 1); + } + + private _getFilenameForApiItem(apiItem: ApiItem): string { + if (apiItem.kind === ApiItemKind.Package) { + return 'index.md'; + } + + let baseName = ''; + for (const hierarchyItem of apiItem.getHierarchy()) { + // For overloaded methods, add a suffix such as "MyClass.myMethod_2". + let qualifiedName: string = Utilities.getSafeFilenameForName( + hierarchyItem.displayName + ); + if (ApiParameterListMixin.isBaseClassOf(hierarchyItem)) { + if (hierarchyItem.overloadIndex > 1) { + // Subtract one for compatibility with earlier releases of API Documenter. + // (This will get revamped when we fix GitHub issue #1308) + qualifiedName += `_${hierarchyItem.overloadIndex - 1}`; + } + } + + switch (hierarchyItem.kind) { + case ApiItemKind.Model: + case ApiItemKind.EntryPoint: + case ApiItemKind.EnumMember: + break; + case ApiItemKind.Package: + baseName = Utilities.getSafeFilenameForName( + PackageName.getUnscopedName(hierarchyItem.displayName) + ); + break; + default: + baseName += '.' + qualifiedName; + } + } + return baseName + '.md'; + } + + private _getLinkFilenameForApiItem(apiItem: ApiItem): string { + return './' + this._getFilenameForApiItem(apiItem); + } + + private _deleteOldOutputFiles(): void { + console.log('Deleting old output from ' + this._outputFolder); + FileSystem.ensureEmptyFolder(this._outputFolder); + } +} diff --git a/remote/test/puppeteer/tools/docgen/src/docgen.ts b/remote/test/puppeteer/tools/docgen/src/docgen.ts new file mode 100644 index 0000000000..c7bafdab3d --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/src/docgen.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ApiModel} from '@microsoft/api-extractor-model'; + +import {MarkdownDocumenter} from './custom_markdown_documenter.js'; + +export function docgen(jsonPath: string, outputDir: string): void { + const apiModel = new ApiModel(); + apiModel.loadPackage(jsonPath); + + const markdownDocumenter: MarkdownDocumenter = new MarkdownDocumenter({ + apiModel: apiModel, + documenterConfig: undefined, + outputFolder: outputDir, + }); + markdownDocumenter.generateFiles(); +} + +export function spliceIntoSection( + sectionName: string, + content: string, + sectionContent: string +): string { + const lines = content.split('\n'); + const offset = + lines.findIndex(line => { + return line.includes(`<!-- ${sectionName}-start -->`); + }) + 1; + const limit = lines.slice(offset).findIndex(line => { + return line.includes(`<!-- ${sectionName}-end -->`); + }); + lines.splice(offset, limit, ...sectionContent.split('\n')); + return lines.join('\n'); +} diff --git a/remote/test/puppeteer/tools/docgen/tsconfig.json b/remote/test/puppeteer/tools/docgen/tsconfig.json new file mode 100644 index 0000000000..fcaf1db737 --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "sourceMap": true, + "declaration": false, + "declarationMap": false, + "composite": false, + }, +} diff --git a/remote/test/puppeteer/tools/docgen/tsdoc.json b/remote/test/puppeteer/tools/docgen/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/doctest/package.json b/remote/test/puppeteer/tools/doctest/package.json new file mode 100644 index 0000000000..8c7e9544d0 --- /dev/null +++ b/remote/test/puppeteer/tools/doctest/package.json @@ -0,0 +1,39 @@ +{ + "name": "@puppeteer/doctest", + "version": "0.1.0", + "type": "module", + "private": true, + "bin": "./bin/doctest.js", + "description": "Tests JSDoc @example code within a file.", + "license": "Apache-2.0", + "scripts": { + "build": "wireit", + "clean": "../clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b && chmod +x ./bin/doctest.js", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "bin/**", + "tsconfig.tsbuildinfo" + ] + } + }, + "devDependencies": { + "@swc/core": "1.3.107", + "@types/doctrine": "0.0.9", + "@types/source-map-support": "0.5.10", + "@types/yargs": "17.0.32", + "acorn": "8.11.3", + "doctrine": "3.0.0", + "glob": "10.3.10", + "pkg-dir": "8.0.0", + "source-map-support": "0.5.21", + "source-map": "0.7.4", + "yargs": "17.7.2" + } +} diff --git a/remote/test/puppeteer/tools/doctest/src/doctest.ts b/remote/test/puppeteer/tools/doctest/src/doctest.ts new file mode 100644 index 0000000000..34349ef766 --- /dev/null +++ b/remote/test/puppeteer/tools/doctest/src/doctest.ts @@ -0,0 +1,349 @@ +#! /usr/bin/env -S node --test-reporter spec + +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * `@puppeteer/doctest` tests `@example` code within a JavaScript file. + * + * There are a few reasonable assumptions for this tool to work: + * + * 1. Examples are written in block comments, not line comments. + * 2. Examples do not use packages that are not available to the file it exists + * in. (Note the package will always be available). + * 3. Examples are strictly written between code fences (\`\`\`) on separate + * lines. For example, \`\`\`console.log(1)\`\`\` is not allowed. + * 4. Code is written using ES modules. + * + * By default, code blocks are interpreted as JavaScript. Use \`\`\`ts to change + * the language. In general, the format is "\`\`\`[language] [ignore] [fail]". + * + * If there are several code blocks within an example, they are concatenated. + */ +import 'source-map-support/register.js'; + +import assert from 'node:assert'; +import {createHash} from 'node:crypto'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {basename, dirname, join, relative, resolve} from 'node:path'; +import {test} from 'node:test'; +import {pathToFileURL} from 'node:url'; + +import {transform, type Output} from '@swc/core'; +import {parse as parseJs} from 'acorn'; +import {parse, type Tag} from 'doctrine'; +import {Glob} from 'glob'; +import {packageDirectory} from 'pkg-dir'; +import { + SourceMapConsumer, + SourceMapGenerator, + type RawSourceMap, +} from 'source-map'; +import yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; + +// This is 1-indexed. +interface Position { + line: number; + column: number; +} + +interface Comment { + file: string; + text: string; + position: Position; +} + +interface ExtractedSourceLocation { + // File path to the original source code. + origin: string; + // Mappings from the extracted code to the original code. + positions: Array<{ + // The 1-indexed line number for the extracted code. + extracted: number; + // The position in the original code. + original: Position; + }>; +} + +interface ExampleCode extends ExtractedSourceLocation { + language: Language; + code: string; + fail: boolean; +} + +const enum Language { + JavaScript, + TypeScript, +} + +const CODE_FENCE = '```'; +const BLOCK_COMMENT_START = ' * '; + +const {files = []} = await yargs(hideBin(process.argv)) + .scriptName('@puppeteer/doctest') + .command('* <files..>', `JSDoc @example code tester.`) + .positional('files', { + describe: 'Files to test', + type: 'string', + }) + .array('files') + .version(false) + .help() + .parse(); + +for await (const file of new Glob(files, {})) { + void test(file, async context => { + const testDirectory = await createTestDirectory(file); + context.after(async () => { + if (!process.env['KEEP_TESTS']) { + await rm(testDirectory, {force: true, recursive: true}); + } + }); + const tests = []; + for (const example of await extractJSDocComments(file).then( + extractExampleCode + )) { + tests.push( + context.test( + `${file}:${example.positions[0]!.original.line}:${ + example.positions[0]!.original.column + }`, + async () => { + await run(testDirectory, example); + } + ) + ); + } + await Promise.all(tests); + }); +} + +async function createTestDirectory(file: string) { + const dir = await packageDirectory({cwd: dirname(file)}); + if (!dir) { + throw new Error(`Could not find package root for ${file}.`); + } + + return await mkdtemp(join(dir, 'doctest-')); +} + +async function run(tempdir: string, example: Readonly<ExampleCode>) { + const path = getTestPath(tempdir, example.code); + await compile(example.language, example.code, path, example); + try { + await import(pathToFileURL(path).toString()); + if (example.fail) { + throw new Error(`Expected failure.`); + } + } catch (error) { + if (!example.fail) { + throw error; + } + } +} + +function getTestPath(dir: string, code: string) { + return join( + dir, + `doctest-${createHash('md5').update(code).digest('hex')}.js` + ); +} + +async function compile( + language: Language, + sourceCode: string, + filePath: string, + location: ExtractedSourceLocation +) { + const output = await compileCode(language, sourceCode); + const map = await getExtractSourceMap(output.map, filePath, location); + await writeFile(filePath, inlineSourceMap(output.code, map)); +} + +function inlineSourceMap(code: string, sourceMap: RawSourceMap) { + return `${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from( + JSON.stringify(sourceMap) + ).toString('base64')}`; +} + +async function getExtractSourceMap( + map: string, + generatedFile: string, + location: ExtractedSourceLocation +) { + const sourceMap = JSON.parse(map) as RawSourceMap; + sourceMap.file = basename(generatedFile); + sourceMap.sourceRoot = ''; + sourceMap.sources = [ + relative(dirname(generatedFile), resolve(location.origin)), + ]; + const consumer = await new SourceMapConsumer(sourceMap); + const generator = new SourceMapGenerator({ + file: consumer.file, + sourceRoot: consumer.sourceRoot, + }); + // We want descending order of the `generated` property. + const positions = [...location.positions].reverse(); + consumer.eachMapping(mapping => { + // Note `mapping.originalLine` is the line number with respect to the + // extracted, raw code. + const {extracted, original} = positions.find(({extracted}) => { + return mapping.originalLine >= extracted; + })!; + + // `original.line` will account for `extracted`, so we need to subtract + // `extracted` to avoid duplicity. We also subtract 1 because `extracted` is + // 1-indexed. + mapping.originalLine -= extracted - 1; + + generator.addMapping({ + ...mapping, + original: { + line: mapping.originalLine + original.line - 1, + column: mapping.originalColumn + original.column - 1, + }, + generated: { + line: mapping.generatedLine, + column: mapping.generatedColumn, + }, + }); + }); + return generator.toJSON(); +} + +const LANGUAGE_TO_SYNTAX = { + [Language.TypeScript]: 'typescript', + [Language.JavaScript]: 'ecmascript', +} as const; + +async function compileCode(language: Language, code: string) { + return (await transform(code, { + sourceMaps: true, + inlineSourcesContent: false, + jsc: { + parser: { + syntax: LANGUAGE_TO_SYNTAX[language], + }, + target: 'es2022', + }, + })) as Required<Output>; +} + +const enum Option { + Ignore = 'ignore', + Fail = 'fail', +} + +function* extractExampleCode( + comments: Iterable<Readonly<Comment>> +): Iterable<Readonly<ExampleCode>> { + interface Context { + language: Language; + fail: boolean; + start: number; + } + for (const {file, text, position: loc} of comments) { + const {tags} = parse(text, { + unwrap: true, + tags: ['example'], + lineNumbers: true, + preserveWhitespace: true, + }); + for (const {description, lineNumber} of tags as Array< + Tag & {lineNumber: number} + >) { + if (!description) { + continue; + } + const lines = description.split('\n'); + const blocks: ExampleCode[] = []; + let context: Context | undefined; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const borderIndex = line.indexOf(CODE_FENCE); + if (borderIndex === -1) { + continue; + } + if (context) { + blocks.push({ + language: context.language, + code: lines.slice(context.start, i).join('\n'), + origin: file, + positions: [ + { + extracted: 1, + original: { + line: loc.line + lineNumber + context.start, + column: + loc.column + borderIndex + BLOCK_COMMENT_START.length + 1, + }, + }, + ], + fail: context.fail, + }); + context = undefined; + continue; + } + const [tag, ...options] = line + .slice(borderIndex + CODE_FENCE.length) + .split(' '); + if (options.includes(Option.Ignore)) { + // Ignore the code sample. + continue; + } + const fail = options.includes(Option.Fail); + // Code starts on the next line. + const start = i + 1; + if (!tag || tag.match(/js|javascript/)) { + context = {language: Language.JavaScript, fail, start}; + } else if (tag.match(/ts|typescript/)) { + context = {language: Language.TypeScript, fail, start}; + } + } + // Merging the blocks into a single block. + yield blocks.reduce( + (context, {language, code, positions: [position], fail}, index) => { + assert(position); + return { + origin: file, + language: language || context.language, + code: `${context.code}\n${code}`, + positions: [ + ...context.positions, + { + ...position, + extracted: + context.code.split('\n').length + + context.positions.at(-1)!.extracted - + // We subtract this because of the accumulated '\n'. + (index - 1), + }, + ], + fail: fail || context.fail, + }; + } + ); + } + } +} + +async function extractJSDocComments(file: string) { + const contents = await readFile(file, 'utf8'); + const comments: Comment[] = []; + parseJs(contents, { + ecmaVersion: 'latest', + sourceType: 'module', + locations: true, + sourceFile: file, + onComment(isBlock, text, _, __, loc) { + if (isBlock) { + comments.push({file, text, position: loc!}); + } + }, + }); + return comments; +} diff --git a/remote/test/puppeteer/tools/doctest/tsconfig.json b/remote/test/puppeteer/tools/doctest/tsconfig.json new file mode 100644 index 0000000000..bd70c0bd5e --- /dev/null +++ b/remote/test/puppeteer/tools/doctest/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./bin", + "sourceMap": true, + "declaration": false, + "declarationMap": false, + "composite": false, + }, +} diff --git a/remote/test/puppeteer/tools/doctest/tsdoc.json b/remote/test/puppeteer/tools/doctest/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/doctest/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/download_chrome_bidi.mjs b/remote/test/puppeteer/tools/download_chrome_bidi.mjs new file mode 100644 index 0000000000..faa73d9a95 --- /dev/null +++ b/remote/test/puppeteer/tools/download_chrome_bidi.mjs @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-console */ + +/** + * @fileoverview Installs a browser defined in `.browser` for Chromium-BiDi using + * `@puppeteer/browsers` to the directory provided as the first argument + * (default: cwd). The executable path is written to the `executablePath` output + * param for GitHub actions. + * + * Examples: + * + * - `node install-browser.mjs` + * - `node install-browser.mjs /tmp/cache` + */ +import {readFile} from 'node:fs/promises'; +import {createRequire} from 'node:module'; + +import actions from '@actions/core'; + +import {computeExecutablePath, install} from '@puppeteer/browsers'; + +const require = createRequire(import.meta.url); + +try { + const browserSpec = await readFile( + require.resolve('chromium-bidi/.browser', { + paths: [require.resolve('puppeteer-core')], + }), + 'utf-8' + ); + const cacheDir = process.argv[2] || process.cwd(); + // See .browser for the format. + const browser = browserSpec.split('@')[0]; + const buildId = browserSpec.split('@')[1]; + await install({ + browser, + buildId, + cacheDir, + }); + const executablePath = computeExecutablePath({ + cacheDir, + browser, + buildId, + }); + if (process.argv.indexOf('--shell') === -1) { + actions.setOutput('executablePath', executablePath); + } + console.log(executablePath); +} catch (err) { + actions.setFailed(`Failed to download the browser: ${err.message}`); +} diff --git a/remote/test/puppeteer/tools/ensure-pinned-deps.ts b/remote/test/puppeteer/tools/ensure-pinned-deps.ts new file mode 100644 index 0000000000..eb21fc647b --- /dev/null +++ b/remote/test/puppeteer/tools/ensure-pinned-deps.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2021 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {readdirSync, readFileSync} from 'fs'; +import {join} from 'path'; + +import {devDependencies} from '../package.json'; + +const LOCAL_PACKAGE_NAMES: string[] = []; + +const packagesDir = join(__dirname, '..', 'packages'); +for (const packageName of readdirSync(packagesDir)) { + const {name} = JSON.parse( + readFileSync(join(packagesDir, packageName, 'package.json'), 'utf8') + ); + LOCAL_PACKAGE_NAMES.push(name); +} + +const allDeps = {...devDependencies}; + +const invalidDeps = new Map<string, string>(); + +for (const [depKey, depValue] of Object.entries(allDeps)) { + if (depValue.startsWith('file:')) { + continue; + } + if (LOCAL_PACKAGE_NAMES.includes(depKey)) { + continue; + } + if (/[0-9]/.test(depValue[0]!)) { + continue; + } + + invalidDeps.set(depKey, depValue); +} + +if (invalidDeps.size > 0) { + console.error('Found non-pinned dependencies in package.json:'); + console.log( + [...invalidDeps.keys()] + .map(k => { + return ` ${k}`; + }) + .join('\n') + ); + process.exit(1); +} + +process.exit(0); diff --git a/remote/test/puppeteer/tools/eslint/package.json b/remote/test/puppeteer/tools/eslint/package.json new file mode 100644 index 0000000000..190367ae43 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/package.json @@ -0,0 +1,37 @@ +{ + "name": "@puppeteer/eslint", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/tools/eslint" + }, + "scripts": { + "build": "wireit", + "prepare": "wireit" + }, + "wireit": { + "build": { + "command": "tsc -b", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "lib/**", + "tsconfig.tsbuildinfo" + ] + }, + "prepare": { + "dependencies": [ + "build" + ] + } + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "devDependencies": { + "@prettier/sync": "0.5.0" + } +} diff --git a/remote/test/puppeteer/tools/eslint/src/check-license.ts b/remote/test/puppeteer/tools/eslint/src/check-license.ts new file mode 100644 index 0000000000..7ae1a54384 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/src/check-license.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {TSESTree} from '@typescript-eslint/utils'; +import {ESLintUtils} from '@typescript-eslint/utils'; + +const createRule = ESLintUtils.RuleCreator(name => { + return `https://github.com/puppeteer/puppeteer/tree/main/tools/eslint/${name}.ts`; +}); + +const copyrightPattern = /Copyright ([0-9]{4}) Google Inc\./; + +// const currentYear = new Date().getFullYear; + +// const licenseHeader = `/** +// * @license +// * Copyright ${currentYear} Google Inc. +// * SPDX-License-Identifier: Apache-2.0 +// */`; + +const enforceLicenseRule = createRule<[], 'licenseRule'>({ + name: 'check-license', + meta: { + type: 'layout', + docs: { + description: 'Validate existence of license header', + requiresTypeChecking: false, + }, + fixable: undefined, // TODO: change to 'code' once fixer works. + schema: [], + messages: { + licenseRule: 'Add license header.', + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const comments = sourceCode.getAllComments(); + const header = + comments[0]?.type === 'Block' && isHeaderComment(comments[0]) + ? comments[0] + : null; + + function isHeaderComment(comment: TSESTree.Comment) { + if (comment && comment.range[0] >= 0 && comment.range[1] <= 88) { + return true; + } else { + return false; + } + } + + return { + Program(node) { + if ( + header && + header.value.includes('@license') && + header.value.includes('SPDX-License-Identifier: Apache-2.0') && + copyrightPattern.test(header.value) + ) { + return; + } + + // Add header license + if (!header || !header.value.includes('@license')) { + // const startLoc: [number, number] = [0, 88]; + context.report({ + node: node, + messageId: 'licenseRule', + // TODO: fix the fixer. + // fix(fixer) { + // return fixer.insertTextBeforeRange(startLoc, licenseHeader); + // }, + }); + } + }, + }; + }, +}); + +export = enforceLicenseRule; diff --git a/remote/test/puppeteer/tools/eslint/src/extensions.ts b/remote/test/puppeteer/tools/eslint/src/extensions.ts new file mode 100644 index 0000000000..89b9279625 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/src/extensions.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ESLintUtils} from '@typescript-eslint/utils'; + +const createRule = ESLintUtils.RuleCreator(name => { + return `https://github.com/puppeteer/puppeteer/tree/main/tools/eslint/${name}.js`; +}); + +const enforceExtensionRule = createRule<[], 'extensionsRule'>({ + name: 'extensions', + meta: { + docs: { + description: 'Requires `.js` for imports', + requiresTypeChecking: false, + }, + messages: { + extensionsRule: 'Add `.js` to import.', + }, + schema: [], + fixable: 'code', + type: 'problem', + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node): void { + const file = node.source.value.split('/').pop(); + + if (!node.source.value.startsWith('.') || file?.includes('.')) { + return; + } + context.report({ + node: node.source, + messageId: 'extensionsRule', + fix(fixer) { + return fixer.replaceText(node.source, `'${node.source.value}.js'`); + }, + }); + }, + }; + }, +}); + +export = enforceExtensionRule; diff --git a/remote/test/puppeteer/tools/eslint/src/prettier-comments.js b/remote/test/puppeteer/tools/eslint/src/prettier-comments.js new file mode 100644 index 0000000000..3cbaad2909 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/src/prettier-comments.js @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// @ts-nocheck +// TODO: We should convert this to types. + +const prettier = require('@prettier/sync'); + +const prettierConfigPath = '../../../.prettierrc.cjs'; +const prettierConfig = require(prettierConfigPath); + +const cleanupBlockComment = value => { + return value + .trim() + .split('\n') + .map(value => { + value = value.trim(); + if (value.startsWith('*')) { + value = value.slice(1); + if (value.startsWith(' ')) { + value = value.slice(1); + } + } + return value.trimEnd(); + }) + .join('\n') + .trim(); +}; + +const format = (value, offset) => { + return prettier + .format(value, { + ...prettierConfig, + parser: 'markdown', + // This is the print width minus 3 (the length of ` * `) and the offset. + printWidth: 80 - (offset + 3), + }) + .trim(); +}; + +const buildBlockComment = (value, offset) => { + const spaces = ' '.repeat(offset); + const lines = value.split('\n').map(line => { + return ` * ${line}`; + }); + lines.unshift('/**'); + lines.push(' */'); + lines.forEach((line, i) => { + lines[i] = `${spaces}${line}`; + }); + return lines.join('\n'); +}; + +/** + * @type import("eslint").Rule.RuleModule + */ +const prettierCommentsRule = { + meta: { + type: 'suggestion', + docs: { + description: 'Enforce Prettier formatting on comments', + recommended: false, + }, + fixable: 'code', + schema: [], + messages: {}, + }, + + create(context) { + for (const comment of context.sourceCode.getAllComments()) { + switch (comment.type) { + case 'Block': { + const offset = comment.loc.start.column; + const value = cleanupBlockComment(comment.value); + const formattedValue = format(value, offset); + if (formattedValue !== value) { + context.report({ + node: comment, + message: `Comment is not formatted correctly.`, + fix(fixer) { + return fixer.replaceText( + comment, + buildBlockComment(formattedValue, offset).trimStart() + ); + }, + }); + } + break; + } + } + } + return {}; + }, +}; + +module.exports = prettierCommentsRule; diff --git a/remote/test/puppeteer/tools/eslint/src/use-using.ts b/remote/test/puppeteer/tools/eslint/src/use-using.ts new file mode 100644 index 0000000000..0c727a4334 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/src/use-using.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ESLintUtils, TSESTree} from '@typescript-eslint/utils'; + +const usingSymbols = ['ElementHandle', 'JSHandle']; + +const createRule = ESLintUtils.RuleCreator(name => { + return `https://github.com/puppeteer/puppeteer/tree/main/tools/eslint/${name}.js`; +}); + +const useUsingRule = createRule<[], 'useUsing' | 'useUsingFix'>({ + name: 'use-using', + meta: { + docs: { + description: "Requires 'using' for element/JS handles.", + requiresTypeChecking: true, + }, + hasSuggestions: true, + messages: { + useUsing: "Use 'using'.", + useUsingFix: "Replace with 'using' to ignore.", + }, + schema: [], + type: 'problem', + }, + defaultOptions: [], + create(context) { + const services = ESLintUtils.getParserServices(context); + const checker = services.program.getTypeChecker(); + + return { + VariableDeclaration(node): void { + if (['using', 'await using'].includes(node.kind) || node.declare) { + return; + } + for (const declaration of node.declarations) { + if (declaration.id.type === TSESTree.AST_NODE_TYPES.Identifier) { + const tsNode = services.esTreeNodeToTSNodeMap.get(declaration.id); + const type = checker.getTypeAtLocation(tsNode); + let isElementHandleReference = false; + if (type.isUnionOrIntersection()) { + for (const member of type.types) { + if ( + member.symbol !== undefined && + usingSymbols.includes(member.symbol.escapedName as string) + ) { + isElementHandleReference = true; + break; + } + } + } else { + isElementHandleReference = + type.symbol !== undefined + ? usingSymbols.includes(type.symbol.escapedName as string) + : false; + } + if (isElementHandleReference) { + context.report({ + node: declaration.id, + messageId: 'useUsing', + suggest: [ + { + messageId: 'useUsingFix', + fix(fixer) { + return fixer.replaceTextRange( + [node.range[0], node.range[0] + node.kind.length], + 'using' + ); + }, + }, + ], + }); + } + } + } + }, + }; + }, +}); + +export = useUsingRule; diff --git a/remote/test/puppeteer/tools/eslint/tsconfig.json b/remote/test/puppeteer/tools/eslint/tsconfig.json new file mode 100644 index 0000000000..da26cc936b --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "./src", + "outDir": "./lib", + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "composite": false, + "removeComments": true, + }, +} diff --git a/remote/test/puppeteer/tools/eslint/tsdoc.json b/remote/test/puppeteer/tools/eslint/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/generate_module_package_json.ts b/remote/test/puppeteer/tools/generate_module_package_json.ts new file mode 100644 index 0000000000..f13672e9d3 --- /dev/null +++ b/remote/test/puppeteer/tools/generate_module_package_json.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {mkdirSync, writeFileSync} from 'fs'; +import {dirname} from 'path'; + +/** + * Outputs the dummy package.json file to the path specified + * by the first argument. + */ +mkdirSync(dirname(process.argv[2]), {recursive: true}); +writeFileSync(process.argv[2], `{"type": "module"}`); diff --git a/remote/test/puppeteer/tools/get_deprecated_version_range.js b/remote/test/puppeteer/tools/get_deprecated_version_range.js new file mode 100644 index 0000000000..bac40e3677 --- /dev/null +++ b/remote/test/puppeteer/tools/get_deprecated_version_range.js @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const { + versionsPerRelease, + lastMaintainedChromeVersion, +} = require('../versions.js'); + +const version = versionsPerRelease.get(lastMaintainedChromeVersion); +if (version.toLowerCase() === 'next') { + console.error('Unexpected NEXT Puppeteer version in versions.js'); + process.exit(1); +} +console.log(`< ${version.substring(1)}`); +process.exit(0); diff --git a/remote/test/puppeteer/tools/mocha-runner/README.md b/remote/test/puppeteer/tools/mocha-runner/README.md new file mode 100644 index 0000000000..0bdd9f253b --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/README.md @@ -0,0 +1,103 @@ +# Mocha Runner + +Mocha Runner is a test runner on top of mocha. +It uses `/test/TestSuites.json` and `/test/TestExpectations.json` files to run mocha tests in multiple configurations and interpret results. + +## Running tests for Mocha Runner itself. + +```bash +npm test +``` + +## Running tests using Mocha Runner + +```bash +npm run build && npm run test +``` + +By default, the runner runs all test suites applicable to the current platform. +To pick a test suite, provide the `--test-suite` arguments. For example, + +```bash +npm run build && npm run test -- --test-suite chrome-headless +``` + +## TestSuites.json + +Define test suites via the `testSuites` attribute. `parameters` can be used in the `TestExpectations.json` to disable tests +based on parameters. The meaning for parameters is defined in `parameterDefinitions` which tell what env object corresponds +to the given parameter. + +## TestExpectations.json + +An expectation looks like this: + +```json +{ + "testIdPattern": "[accessibility.spec]", + "platforms": ["darwin", "win32", "linux"], + "parameters": ["firefox"], + "expectations": ["SKIP"] +} +``` + +| Field | Description | Type | Match Logic | +| --------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------- | +| `testIdPattern` | Defines the full name (or pattern) to match against test name | string | - | +| `platforms` | Defines the platforms the expectation is for | Array<`linux` \| `win32` \|`darwin`> | `OR` | +| `parameters` | Defines the parameters that the test has to match | Array<[ParameterDefinitions](https://github.com/puppeteer/puppeteer/blob/main/test/TestSuites.json)> | `AND` | +| `expectations` | The list of test results that are considered to be acceptable | Array<`PASS` \| `FAIL` \| `TIMEOUT` \| `SKIP`> | `OR` | + +> Order of defining expectations matters. The latest expectation that is set will take president over earlier ones. + +> Adding `SKIP` to `expectations` will prevent the test from running, no matter if there are other expectations. + +### Using pattern in `testIdPattern` + +Sometimes we want a whole group of test to run. For that we can use a +pattern to achieve. +Pattern are defined with the use of `*` (using greedy method). + +Examples: +| Pattern | Description | Example Pattern | Example match | +|------------------------|---------------------------------------------------------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------| +| `*` | Match all tests | - | - | +| `[test.spec] *` | Matches tests for the given file | `[jshandle.spec] *` | `[jshandle] JSHandle JSHandle.toString should work for primitives` | +| `[test.spec] <text> *` | Matches tests with for a given test with a specific prefixed test (usually a describe node) | `[page.spec] Page Page.goto *` | `[page.spec] Page Page.goto should work`,<br>`[page.spec] Page Page.goto should work with anchor navigation` | +| `[test.spec] * <text>` | Matches test with a surfix | `[navigation.spec] * should work` | `[navigation.spec] navigation Page.goto should work`,<br>`[navigation.spec] navigation Page.waitForNavigation should work` | + +## Updating Expectations + +Currently, expectations are updated manually. The test runner outputs the +suggested changes to the expectation file if the test run does not match +expectations. + +## Debugging flaky test + +### Utility functions: + +| Utility | Params | Description | +| ------------------------ | ------------------------------- | --------------------------------------------------------------------------------- | +| `describe.withDebugLogs` | `(title, <DescribeBody>)` | Capture and print debug logs for each test that failed | +| `it.deflake` | `(repeat, title, <itFunction>)` | Reruns the test N number of times and print the debug logs if for the failed runs | +| `it.deflakeOnly` | `(repeat, title, <itFunction>)` | Same as `it.deflake` but runs only this specific test | + +### With Environment variable + +Run the test with the following environment variable to wrap it around `describe.withDebugLogs`. Example: + +```bash +PUPPETEER_DEFLAKE_TESTS="[navigation.spec] navigation Page.goto should navigate to empty page with networkidle0" npm run test:chrome:headless +``` + +It also works with [patterns](#1--this-is-my-header) just like `TestExpectations.json` + +```bash +PUPPETEER_DEFLAKE_TESTS="[navigation.spec] *" npm run test:chrome:headless +``` + +By default the test is rerun 100 times, but you can control this as well: + +```bash +PUPPETEER_DEFLAKE_RETRIES=1000 PUPPETEER_DEFLAKE_TESTS="[navigation.spec] *" npm run test:chrome:headless +``` diff --git a/remote/test/puppeteer/tools/mocha-runner/package.json b/remote/test/puppeteer/tools/mocha-runner/package.json new file mode 100644 index 0000000000..26612e504a --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/package.json @@ -0,0 +1,43 @@ +{ + "name": "@puppeteer/mocha-runner", + "version": "0.1.0", + "type": "commonjs", + "private": true, + "bin": "./bin/mocha-runner.js", + "description": "Mocha runner for Puppeteer", + "license": "Apache-2.0", + "scripts": { + "build": "wireit", + "test": "wireit", + "clean": "../clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b && chmod +x ./bin/mocha-runner.js", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "bin/**", + "tsconfig.tsbuildinfo" + ], + "dependencies": [ + "../../packages/puppeteer-core:build" + ] + }, + "test": { + "command": "c8 node ./bin/test.js", + "dependencies": [ + "build" + ] + } + }, + "devDependencies": { + "@types/yargs": "17.0.32", + "c8": "9.1.0", + "glob": "10.3.10", + "yargs": "17.7.2", + "zod": "3.22.4" + } +} diff --git a/remote/test/puppeteer/tools/mocha-runner/src/interface.ts b/remote/test/puppeteer/tools/mocha-runner/src/interface.ts new file mode 100644 index 0000000000..fe0f7e18b5 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/interface.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import Mocha from 'mocha'; +import commonInterface from 'mocha/lib/interfaces/common'; +import { + setLogCapture, + getCapturedLogs, +} from 'puppeteer-core/internal/common/Debug.js'; + +import {testIdMatchesExpectationPattern} from './utils.js'; + +type SuiteFunction = ((this: Mocha.Suite) => void) | undefined; +type ExclusiveSuiteFunction = (this: Mocha.Suite) => void; + +const skippedTests: Array<{testIdPattern: string; skip: true}> = process.env[ + 'PUPPETEER_SKIPPED_TEST_CONFIG' +] + ? JSON.parse(process.env['PUPPETEER_SKIPPED_TEST_CONFIG']) + : []; + +const deflakeRetries = Number( + process.env['PUPPETEER_DEFLAKE_RETRIES'] + ? process.env['PUPPETEER_DEFLAKE_RETRIES'] + : 100 +); +const deflakeTestPattern: string | undefined = + process.env['PUPPETEER_DEFLAKE_TESTS']; + +function shouldSkipTest(test: Mocha.Test): boolean { + // TODO: more efficient lookup. + const definition = skippedTests.find(skippedTest => { + return testIdMatchesExpectationPattern(test, skippedTest.testIdPattern); + }); + if (definition && definition.skip) { + return true; + } + return false; +} + +function shouldDeflakeTest(test: Mocha.Test): boolean { + if (deflakeTestPattern) { + // TODO: cache if we have seen it already + return testIdMatchesExpectationPattern(test, deflakeTestPattern); + } + return false; +} + +function dumpLogsIfFail(this: Mocha.Context) { + if (this.currentTest?.state === 'failed') { + console.log( + `\n"${this.currentTest.fullTitle()}" failed. Here is a debug log:` + ); + console.log(getCapturedLogs().join('\n') + '\n'); + } + setLogCapture(false); +} + +function customBDDInterface(suite: Mocha.Suite) { + const suites: [Mocha.Suite] = [suite]; + + suite.on( + Mocha.Suite.constants.EVENT_FILE_PRE_REQUIRE, + function (context, file, mocha) { + const common = commonInterface(suites, context, mocha); + + context['before'] = common.before; + context['after'] = common.after; + context['beforeEach'] = common.beforeEach; + context['afterEach'] = common.afterEach; + if (mocha.options.delay) { + context['run'] = common.runWithSuite(suite); + } + function describe(title: string, fn: SuiteFunction) { + return common.suite.create({ + title: title, + file: file, + fn: fn, + }); + } + describe.only = function (title: string, fn: ExclusiveSuiteFunction) { + return common.suite.only({ + title: title, + file: file, + fn: fn, + isOnly: true, + }); + }; + + describe.skip = function (title: string, fn: SuiteFunction) { + return common.suite.skip({ + title: title, + file: file, + fn: fn, + }); + }; + + describe.withDebugLogs = function ( + description: string, + body: (this: Mocha.Suite) => void + ): void { + context['describe']('with Debug Logs', () => { + context['beforeEach'](() => { + setLogCapture(true); + }); + context['afterEach'](dumpLogsIfFail); + context['describe'](description, body); + }); + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + context['describe'] = describe; + + function it(title: string, fn: Mocha.TestFunction, itOnly = false) { + const suite = suites[0]! as Mocha.Suite; + const test = new Mocha.Test(title, suite.isPending() ? undefined : fn); + test.file = file; + test.parent = suite; + + const describeOnly = Boolean( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + suite.parent?._onlySuites.find(child => { + return child === suite; + }) + ); + if (shouldDeflakeTest(test)) { + const deflakeSuit = Mocha.Suite.create(suite, 'with Debug Logs'); + test.file = file; + deflakeSuit.beforeEach(function () { + setLogCapture(true); + }); + deflakeSuit.afterEach(dumpLogsIfFail); + for (let i = 0; i < deflakeRetries; i++) { + deflakeSuit.addTest(test.clone()); + } + return test; + } else if (!(itOnly || describeOnly) && shouldSkipTest(test)) { + const test = new Mocha.Test(title); + test.file = file; + suite.addTest(test); + return test; + } else { + suite.addTest(test); + return test; + } + } + + it.only = function (title: string, fn: Mocha.TestFunction) { + return common.test.only( + mocha, + (context['it'] as unknown as typeof it)(title, fn, true) + ); + }; + + it.skip = function (title: string) { + return context['it'](title); + }; + + function wrapDeflake( + func: Function + ): (repeats: number, title: string, fn: Mocha.AsyncFunc) => void { + return (repeats: number, title: string, fn: Mocha.AsyncFunc): void => { + (context['describe'] as unknown as typeof describe).withDebugLogs( + 'with Debug Logs', + () => { + for (let i = 1; i <= repeats; i++) { + func(`${i}/${title}`, fn); + } + } + ); + }; + } + + it.deflake = wrapDeflake(it); + it.deflakeOnly = wrapDeflake(it.only); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + context.it = it; + } + ); +} + +customBDDInterface.description = 'Custom BDD'; + +module.exports = customBDDInterface; diff --git a/remote/test/puppeteer/tools/mocha-runner/src/mocha-runner.ts b/remote/test/puppeteer/tools/mocha-runner/src/mocha-runner.ts new file mode 100644 index 0000000000..1707e4cc41 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/mocha-runner.ts @@ -0,0 +1,330 @@ +#! /usr/bin/env node + +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {randomUUID} from 'crypto'; +import fs from 'fs'; +import {spawn} from 'node:child_process'; +import os from 'os'; +import path from 'path'; + +import {globSync} from 'glob'; +import yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; + +import { + zPlatform, + zTestSuiteFile, + type MochaResults, + type Platform, + type TestExpectation, + type TestSuite, + type TestSuiteFile, +} from './types.js'; +import { + extendProcessEnv, + filterByParameters, + filterByPlatform, + getExpectationUpdates, + printSuggestions, + readJSON, + writeJSON, + type RecommendedExpectation, +} from './utils.js'; + +const { + _: mochaArgs, + testSuite: testSuiteId, + saveStatsTo, + cdpTests: includeCdpTests, + suggestions: provideSuggestions, + coverage: useCoverage, + minTests, + shard, + reporter, + printMemory, +} = yargs(hideBin(process.argv)) + .parserConfiguration({'unknown-options-as-args': true}) + .scriptName('@puppeteer/mocha-runner') + .option('coverage', { + boolean: true, + default: true, + }) + .option('suggestions', { + boolean: true, + default: true, + }) + .option('cdp-tests', { + boolean: true, + default: true, + }) + .option('save-stats-to', { + string: true, + requiresArg: true, + }) + .option('min-tests', { + number: true, + default: 0, + requiresArg: true, + }) + .option('test-suite', { + string: true, + requiresArg: true, + }) + .option('shard', { + string: true, + requiresArg: true, + }) + .option('reporter', { + string: true, + requiresArg: true, + }) + .option('print-memory', { + boolean: true, + default: false, + }) + .parseSync(); + +function getApplicableTestSuites( + parsedSuitesFile: TestSuiteFile, + platform: Platform +): TestSuite[] { + let applicableSuites: TestSuite[] = []; + + if (!testSuiteId) { + applicableSuites = filterByPlatform(parsedSuitesFile.testSuites, platform); + } else { + const testSuite = parsedSuitesFile.testSuites.find(suite => { + return suite.id === testSuiteId; + }); + + if (!testSuite) { + console.error(`Test suite ${testSuiteId} is not defined`); + process.exit(1); + } + + if (!testSuite.platforms.includes(platform)) { + console.warn( + `Test suite ${testSuiteId} is not enabled for your platform. Running it anyway.` + ); + } + + applicableSuites = [testSuite]; + } + + return applicableSuites; +} + +async function main() { + let statsPath = saveStatsTo; + if (statsPath && statsPath.includes('INSERTID')) { + statsPath = statsPath.replace(/INSERTID/gi, randomUUID()); + } + + const platform = zPlatform.parse(os.platform()); + + const expectations = readJSON( + path.join(process.cwd(), 'test', 'TestExpectations.json') + ) as TestExpectation[]; + + const parsedSuitesFile = zTestSuiteFile.parse( + readJSON(path.join(process.cwd(), 'test', 'TestSuites.json')) + ); + + const applicableSuites = getApplicableTestSuites(parsedSuitesFile, platform); + + console.log('Planning to run the following test suites', applicableSuites); + if (statsPath) { + console.log('Test stats will be saved to', statsPath); + } + + let fail = false; + const recommendations: RecommendedExpectation[] = []; + try { + for (const suite of applicableSuites) { + const parameters = suite.parameters; + + const applicableExpectations = filterByParameters( + filterByPlatform(expectations, platform), + parameters + ).reverse(); + + // Add more logging when the GitHub Action Debugging option is set + // https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + const githubActionDebugging = process.env['RUNNER_DEBUG'] + ? { + DEBUG: 'puppeteer:*', + EXTRA_LAUNCH_OPTIONS: JSON.stringify({ + dumpio: true, + extraPrefsFirefox: { + 'remote.log.level': 'Trace', + }, + }), + } + : {}; + + const env = extendProcessEnv([ + ...parameters.map(param => { + return parsedSuitesFile.parameterDefinitions[param]; + }), + { + PUPPETEER_SKIPPED_TEST_CONFIG: JSON.stringify( + applicableExpectations.map(ex => { + return { + testIdPattern: ex.testIdPattern, + skip: ex.expectations.includes('SKIP'), + }; + }) + ), + }, + githubActionDebugging, + ]); + + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-test-runner-') + ); + const tmpFilename = statsPath + ? statsPath + : path.join(tmpDir, 'output.json'); + console.log('Running', JSON.stringify(parameters), tmpFilename); + const args = [ + '-u', + path.join(__dirname, 'interface.js'), + '-R', + !reporter ? path.join(__dirname, 'reporter.js') : reporter, + '-O', + `output=${tmpFilename}`, + '-n', + 'trace-warnings', + ]; + + if (printMemory) { + args.push('-n', 'expose-gc'); + } + + const specPattern = 'test/build/**/*.spec.js'; + const specs = globSync(specPattern, { + ignore: !includeCdpTests ? 'test/build/cdp/**/*.spec.js' : undefined, + }).sort((a, b) => { + return a.localeCompare(b); + }); + if (shard) { + // Shard ID is 1-based. + const [shardId, shards] = shard.split('-').map(s => { + return Number(s); + }) as [number, number]; + const argsLength = args.length; + for (let i = 0; i < specs.length; i++) { + if (i % shards === shardId - 1) { + args.push(specs[i]!); + } + } + if (argsLength === args.length) { + throw new Error('Shard did not result in any test files'); + } + console.log( + `Running shard ${shardId}-${shards}. Picked ${ + args.length - argsLength + } files out of ${specs.length}.` + ); + } else { + args.push(...specs); + } + const handle = spawn( + 'npx', + [ + ...(useCoverage + ? [ + 'c8', + '--check-coverage', + '--lines', + String(suite.expectedLineCoverage), + 'npx', + ] + : []), + 'mocha', + ...mochaArgs.map(String), + ...args, + ], + { + shell: true, + cwd: process.cwd(), + stdio: 'inherit', + env, + } + ); + await new Promise<void>((resolve, reject) => { + handle.on('error', err => { + reject(err); + }); + handle.on('close', () => { + resolve(); + }); + }); + console.log('Finished', JSON.stringify(parameters)); + try { + const results = readJSON(tmpFilename) as MochaResults; + const updates = getExpectationUpdates(results, applicableExpectations, { + platforms: [os.platform()], + parameters, + }); + const totalTests = results.stats.tests; + results.parameters = parameters; + results.platform = platform; + results.date = new Date().toISOString(); + if (updates.length > 0) { + fail = true; + recommendations.push(...updates); + results.updates = updates; + writeJSON(tmpFilename, results); + } else { + if (!shard && totalTests < minTests) { + fail = true; + console.log( + `Test run matches expectations but the number of discovered tests is too low (expected: ${minTests}, actual: ${totalTests}).` + ); + writeJSON(tmpFilename, results); + continue; + } + console.log('Test run matches expectations'); + writeJSON(tmpFilename, results); + continue; + } + } catch (err) { + fail = true; + console.error(err); + } + } + } catch (err) { + fail = true; + console.error(err); + } finally { + if (!!provideSuggestions) { + printSuggestions( + recommendations, + 'add', + 'Add the following to TestExpectations.json to ignore the error:' + ); + printSuggestions( + recommendations, + 'remove', + 'Remove the following from the TestExpectations.json to ignore the error:' + ); + printSuggestions( + recommendations, + 'update', + 'Update the following expectations in the TestExpectations.json to ignore the error:' + ); + } + process.exit(fail ? 1 : 0); + } +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/remote/test/puppeteer/tools/mocha-runner/src/reporter.ts b/remote/test/puppeteer/tools/mocha-runner/src/reporter.ts new file mode 100644 index 0000000000..7acd5319fe --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/reporter.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import Mocha from 'mocha'; + +class SpecJSONReporter extends Mocha.reporters.Spec { + constructor(runner: Mocha.Runner, options?: Mocha.MochaOptions) { + super(runner, options); + Mocha.reporters.JSON.call(this, runner, options); + } +} + +module.exports = SpecJSONReporter; diff --git a/remote/test/puppeteer/tools/mocha-runner/src/test.ts b/remote/test/puppeteer/tools/mocha-runner/src/test.ts new file mode 100644 index 0000000000..5510966235 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/test.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import type {Platform, TestExpectation, MochaTestResult} from './types.js'; +import { + filterByParameters, + getTestResultForFailure, + isWildCardPattern, + testIdMatchesExpectationPattern, + getExpectationUpdates, +} from './utils.js'; +import {getFilename, extendProcessEnv} from './utils.js'; + +describe('extendProcessEnv', () => { + it('should extend env variables for the subprocess', () => { + const env = extendProcessEnv([{TEST: 'TEST'}, {TEST2: 'TEST2'}]); + assert.equal(env['TEST'], 'TEST'); + assert.equal(env['TEST2'], 'TEST2'); + }); +}); + +describe('getFilename', () => { + it('extract filename for a path', () => { + assert.equal(getFilename('/etc/test.ts'), 'test'); + assert.equal(getFilename('/etc/test.js'), 'test'); + }); +}); + +describe('getTestResultForFailure', () => { + it('should get a test result for a mocha failure', () => { + assert.equal( + getTestResultForFailure({err: {code: 'ERR_MOCHA_TIMEOUT'}}), + 'TIMEOUT' + ); + assert.equal(getTestResultForFailure({err: {code: 'ERROR'}}), 'FAIL'); + }); +}); + +describe('filterByParameters', () => { + it('should filter a list of expectations by parameters', () => { + const expectations: TestExpectation[] = [ + { + testIdPattern: + '[oopif.spec] OOPIF "after all" hook for "should keep track of a frames OOP state"', + platforms: ['darwin'], + parameters: ['firefox', 'headless'], + expectations: ['FAIL'], + }, + ]; + assert.equal( + filterByParameters(expectations, ['firefox', 'headless']).length, + 1 + ); + assert.equal(filterByParameters(expectations, ['firefox']).length, 0); + assert.equal( + filterByParameters(expectations, ['firefox', 'headless', 'other']).length, + 1 + ); + assert.equal(filterByParameters(expectations, ['other']).length, 0); + }); +}); + +describe('isWildCardPattern', () => { + it('should detect if an expectation is a wildcard pattern', () => { + assert.equal(isWildCardPattern(''), false); + assert.equal(isWildCardPattern('a'), false); + assert.equal(isWildCardPattern('*'), true); + + assert.equal(isWildCardPattern('[queryHandler.spec]'), false); + assert.equal(isWildCardPattern('[queryHandler.spec] *'), true); + assert.equal(isWildCardPattern(' [queryHandler.spec] '), false); + + assert.equal(isWildCardPattern('[queryHandler.spec] Query'), false); + assert.equal(isWildCardPattern('[queryHandler.spec] Page *'), true); + assert.equal( + isWildCardPattern('[queryHandler.spec] Page Page.goto *'), + true + ); + }); +}); + +describe('testIdMatchesExpectationPattern', () => { + const expectations: Array<[string, boolean]> = [ + ['', false], + ['*', true], + ['* should work', true], + ['* Page.setContent *', true], + ['* should work as expected', false], + ['Page.setContent *', false], + ['[page.spec]', false], + ['[page.spec] *', true], + ['[page.spec] Page *', true], + ['[page.spec] Page Page.setContent *', true], + ['[page.spec] Page Page.setContent should work', true], + ['[page.spec] Page * should work', true], + ['[page.spec] * Page.setContent *', true], + ['[jshandle.spec] *', false], + ['[jshandle.spec] JSHandle should work', false], + ]; + + it('with MochaTest', () => { + const test = { + title: 'should work', + file: 'page.spec.ts', + fullTitle() { + return 'Page Page.setContent should work'; + }, + }; + + for (const [pattern, expected] of expectations) { + assert.equal( + testIdMatchesExpectationPattern(test, pattern), + expected, + `Expected "${pattern}" to yield "${expected}"` + ); + } + }); + + it('with MochaTestResult', () => { + const test: MochaTestResult = { + title: 'should work', + file: 'page.spec.ts', + fullTitle: 'Page Page.setContent should work', + }; + + for (const [pattern, expected] of expectations) { + assert.equal( + testIdMatchesExpectationPattern(test, pattern), + expected, + `Expected "${pattern}" to yield "${expected}"` + ); + } + }); +}); + +describe('getExpectationUpdates', () => { + it('should generate an update for expectations if a test passed with a fail expectation', () => { + const mochaResults = { + stats: {tests: 1}, + pending: [], + passes: [ + { + fullTitle: 'Page Page.setContent should work', + title: 'should work', + file: 'page.spec.ts', + }, + ], + failures: [], + }; + const expectations = [ + { + testIdPattern: '[page.spec] Page Page.setContent should work', + platforms: ['darwin'] as Platform[], + parameters: ['test'], + expectations: ['FAIL' as const], + }, + ]; + const updates = getExpectationUpdates(mochaResults, expectations, { + platforms: ['darwin'] as Platform[], + parameters: ['test'], + }); + assert.deepEqual(updates, [ + { + action: 'remove', + basedOn: { + expectations: ['FAIL'], + parameters: ['test'], + platforms: ['darwin'], + testIdPattern: '[page.spec] Page Page.setContent should work', + }, + expectation: { + expectations: ['FAIL'], + parameters: ['test'], + platforms: ['darwin'], + testIdPattern: '[page.spec] Page Page.setContent should work', + }, + }, + ]); + }); + + it('should not generate an update for successful retries', () => { + const mochaResults = { + stats: {tests: 1}, + pending: [], + passes: [ + { + fullTitle: 'Page Page.setContent should work', + title: 'should work', + file: 'page.spec.ts', + }, + ], + failures: [ + { + fullTitle: 'Page Page.setContent should work', + title: 'should work', + file: 'page.spec.ts', + err: {code: 'Timeout'}, + }, + ], + }; + const updates = getExpectationUpdates(mochaResults, [], { + platforms: ['darwin'], + parameters: ['test'], + }); + assert.deepEqual(updates, []); + }); +}); diff --git a/remote/test/puppeteer/tools/mocha-runner/src/types.ts b/remote/test/puppeteer/tools/mocha-runner/src/types.ts new file mode 100644 index 0000000000..01dc4d6be6 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/types.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {z} from 'zod'; + +import type {RecommendedExpectation} from './utils.js'; + +export const zPlatform = z.enum(['win32', 'linux', 'darwin']); + +export type Platform = z.infer<typeof zPlatform>; + +export const zTestSuite = z.object({ + id: z.string(), + platforms: z.array(zPlatform), + parameters: z.array(z.string()), + expectedLineCoverage: z.number(), +}); + +export type TestSuite = z.infer<typeof zTestSuite>; + +export const zTestSuiteFile = z.object({ + testSuites: z.array(zTestSuite), + parameterDefinitions: z.record(z.any()), +}); + +export type TestSuiteFile = z.infer<typeof zTestSuiteFile>; + +export type TestResult = 'PASS' | 'FAIL' | 'TIMEOUT' | 'SKIP'; + +export interface TestExpectation { + testIdPattern: string; + platforms: NodeJS.Platform[]; + parameters: string[]; + expectations: TestResult[]; +} + +export interface MochaTestResult { + fullTitle: string; + title: string; + file: string; + err?: {code: string}; +} + +export interface MochaResults { + stats: {tests: number}; + pending: MochaTestResult[]; + passes: MochaTestResult[]; + failures: MochaTestResult[]; + // Added by mocha-runner. + updates?: RecommendedExpectation[]; + parameters?: string[]; + platform?: string; + date?: string; +} diff --git a/remote/test/puppeteer/tools/mocha-runner/src/utils.ts b/remote/test/puppeteer/tools/mocha-runner/src/utils.ts new file mode 100644 index 0000000000..066c5fbe57 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/utils.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; + +import type { + MochaTestResult, + TestExpectation, + MochaResults, + TestResult, +} from './types.js'; + +export function extendProcessEnv(envs: object[]): NodeJS.ProcessEnv { + const env = envs.reduce( + (acc: object, item: object) => { + Object.assign(acc, item); + return acc; + }, + { + ...process.env, + } + ); + + if (process.env['CI']) { + const puppeteerEnv = Object.entries(env).reduce( + (acc, [key, value]) => { + if (key.startsWith('PUPPETEER_')) { + acc[key] = value; + } + + return acc; + }, + {} as Record<string, unknown> + ); + + console.log( + 'PUPPETEER env:\n', + JSON.stringify(puppeteerEnv, null, 2), + '\n' + ); + } + + return env as NodeJS.ProcessEnv; +} + +export function getFilename(file: string): string { + return path.basename(file).replace(path.extname(file), ''); +} + +export function readJSON(path: string): unknown { + return JSON.parse(fs.readFileSync(path, 'utf-8')); +} + +export function writeJSON(path: string, json: unknown): unknown { + return fs.writeFileSync(path, JSON.stringify(json, null, 2)); +} + +export function filterByPlatform<T extends {platforms: NodeJS.Platform[]}>( + items: T[], + platform: NodeJS.Platform +): T[] { + return items.filter(item => { + return item.platforms.includes(platform); + }); +} + +export function prettyPrintJSON(json: unknown): void { + console.log(JSON.stringify(json, null, 2)); +} + +export function printSuggestions( + recommendations: RecommendedExpectation[], + action: RecommendedExpectation['action'], + message: string +): void { + const toPrint = recommendations.filter(item => { + return item.action === action; + }); + if (toPrint.length) { + console.log(message); + prettyPrintJSON( + toPrint.map(item => { + return item.expectation; + }) + ); + if (action !== 'remove') { + console.log( + 'The recommendations are based on the following applied expectations:' + ); + prettyPrintJSON( + toPrint.map(item => { + return item.basedOn; + }) + ); + } + } +} + +export function filterByParameters( + expectations: TestExpectation[], + parameters: string[] +): TestExpectation[] { + const querySet = new Set(parameters); + return expectations.filter(ex => { + return ex.parameters.every(param => { + return querySet.has(param); + }); + }); +} + +/** + * The last expectation that matches an empty string as all tests pattern + * or the name of the file or the whole name of the test the filter wins. + */ +export function findEffectiveExpectationForTest( + expectations: TestExpectation[], + result: MochaTestResult +): TestExpectation | undefined { + return expectations.find(expectation => { + return testIdMatchesExpectationPattern(result, expectation.testIdPattern); + }); +} + +export interface RecommendedExpectation { + expectation: TestExpectation; + action: 'remove' | 'add' | 'update'; + basedOn?: TestExpectation; +} + +export function isWildCardPattern(testIdPattern: string): boolean { + return testIdPattern.includes('*'); +} + +export function getExpectationUpdates( + results: MochaResults, + expectations: TestExpectation[], + context: { + platforms: NodeJS.Platform[]; + parameters: string[]; + } +): RecommendedExpectation[] { + const output = new Map<string, RecommendedExpectation>(); + + const passesByKey = results.passes.reduce((acc, pass) => { + acc.add(getTestId(pass.file, pass.fullTitle)); + return acc; + }, new Set()); + + for (const pass of results.passes) { + const expectationEntry = findEffectiveExpectationForTest( + expectations, + pass + ); + if (expectationEntry && !expectationEntry.expectations.includes('PASS')) { + if (isWildCardPattern(expectationEntry.testIdPattern)) { + addEntry({ + expectation: { + testIdPattern: getTestId(pass.file, pass.fullTitle), + platforms: context.platforms, + parameters: context.parameters, + expectations: ['PASS'], + }, + action: 'add', + basedOn: expectationEntry, + }); + } else { + addEntry({ + expectation: expectationEntry, + action: 'remove', + basedOn: expectationEntry, + }); + } + } + } + + for (const failure of results.failures) { + // If an error occurs during a hook + // the error not have a file associated with it + if (!failure.file) { + console.error('Hook failed:', failure.err); + addEntry({ + expectation: { + testIdPattern: failure.fullTitle, + platforms: context.platforms, + parameters: context.parameters, + expectations: [], + }, + action: 'add', + }); + continue; + } + + if (passesByKey.has(getTestId(failure.file, failure.fullTitle))) { + continue; + } + + const expectationEntry = findEffectiveExpectationForTest( + expectations, + failure + ); + if (expectationEntry && !expectationEntry.expectations.includes('SKIP')) { + if ( + !expectationEntry.expectations.includes( + getTestResultForFailure(failure) + ) + ) { + // If the effective explanation is a wildcard, we recommend adding a new + // expectation instead of updating the wildcard that might affect multiple + // tests. + if (isWildCardPattern(expectationEntry.testIdPattern)) { + addEntry({ + expectation: { + testIdPattern: getTestId(failure.file, failure.fullTitle), + platforms: context.platforms, + parameters: context.parameters, + expectations: [getTestResultForFailure(failure)], + }, + action: 'add', + basedOn: expectationEntry, + }); + } else { + addEntry({ + expectation: { + ...expectationEntry, + expectations: [ + ...expectationEntry.expectations, + getTestResultForFailure(failure), + ], + }, + action: 'update', + basedOn: expectationEntry, + }); + } + } + } else if (!expectationEntry) { + addEntry({ + expectation: { + testIdPattern: getTestId(failure.file, failure.fullTitle), + platforms: context.platforms, + parameters: context.parameters, + expectations: [getTestResultForFailure(failure)], + }, + action: 'add', + }); + } + } + + function addEntry(value: RecommendedExpectation) { + const key = JSON.stringify(value); + if (!output.has(key)) { + output.set(key, value); + } + } + + return [...output.values()]; +} + +export function getTestResultForFailure( + test: Pick<MochaTestResult, 'err'> +): TestResult { + return test.err?.code === 'ERR_MOCHA_TIMEOUT' ? 'TIMEOUT' : 'FAIL'; +} + +export function getTestId(file: string, fullTitle?: string): string { + return fullTitle + ? `[${getFilename(file)}] ${fullTitle}` + : `[${getFilename(file)}]`; +} + +export function testIdMatchesExpectationPattern( + test: MochaTestResult | Pick<Mocha.Test, 'title' | 'file' | 'fullTitle'>, + pattern: string +): boolean { + const patternRegExString = pattern + // Replace `*` with non special character + .replace(/\*/g, '--STAR--') + // Escape special characters https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Replace placeholder with greedy match + .replace(/--STAR--/g, '(.*)?'); + // Match beginning and end explicitly + const patternRegEx = new RegExp(`^${patternRegExString}$`); + const fullTitle = + typeof test.fullTitle === 'string' ? test.fullTitle : test.fullTitle(); + + return patternRegEx.test(getTestId(test.file ?? '', fullTitle)); +} diff --git a/remote/test/puppeteer/tools/mocha-runner/tsconfig.json b/remote/test/puppeteer/tools/mocha-runner/tsconfig.json new file mode 100644 index 0000000000..73a1b17815 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./bin", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "sourceMap": true, + "declaration": false, + "declarationMap": false, + "composite": false, + }, +} diff --git a/remote/test/puppeteer/tools/mocha-runner/tsdoc.json b/remote/test/puppeteer/tools/mocha-runner/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/sort-test-expectations.mjs b/remote/test/puppeteer/tools/sort-test-expectations.mjs new file mode 100644 index 0000000000..d1c8588d8a --- /dev/null +++ b/remote/test/puppeteer/tools/sort-test-expectations.mjs @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// TODO: this could be an eslint rule probably. +import fs from 'fs'; +import path from 'path'; +import url from 'url'; + +import prettier from 'prettier'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); +const source = 'test/TestExpectations.json'; +const testExpectations = JSON.parse(fs.readFileSync(source, 'utf-8')); +const committedExpectations = structuredClone(testExpectations); + +const prettierConfig = await import( + path.join(__dirname, '..', '.prettierrc.cjs') +); + +function getSpecificity(item) { + return ( + item.parameters.length + + (item.testIdPattern.includes('*') + ? item.testIdPattern === '*' + ? 0 + : 1 + : 2) + ); +} + +testExpectations.sort((a, b) => { + const result = getSpecificity(a) - getSpecificity(b); + if (result === 0) { + return a.testIdPattern.localeCompare(b.testIdPattern); + } + return result; +}); + +testExpectations.forEach(item => { + item.parameters.sort(); + item.expectations.sort(); + item.platforms.sort(); +}); + +if (process.argv.includes('--lint')) { + if ( + JSON.stringify(committedExpectations) !== JSON.stringify(testExpectations) + ) { + console.error( + `${source} is not formatted properly. Run 'npm run format:expectations'.` + ); + process.exit(1); + } +} else { + fs.writeFileSync( + source, + await prettier.format(JSON.stringify(testExpectations), { + ...prettierConfig, + parser: 'json', + }) + ); +} diff --git a/remote/test/puppeteer/tools/third_party/validate-licenses.ts b/remote/test/puppeteer/tools/third_party/validate-licenses.ts new file mode 100644 index 0000000000..56964854bd --- /dev/null +++ b/remote/test/puppeteer/tools/third_party/validate-licenses.ts @@ -0,0 +1,154 @@ +// The MIT License + +// Copyright (c) 2010-2022 Google LLC. http://angular.io/license + +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Taken and adapted from https://github.com/angular/angular-cli/blob/173823d/scripts/validate-licenses.ts. + +import * as path from 'path'; + +import checker from 'license-checker'; +import spdxSatisfies from 'spdx-satisfies'; + +/** + * A general note on some black listed specific licenses: + * + * - CC0 This is not a valid license. It does not grant copyright of the + * code/asset, and does not resolve patents or other licensed work. The + * different claims also have no standing in court and do not provide + * protection to or from Google and/or third parties. We cannot use nor + * contribute to CC0 licenses. + * - Public Domain Same as CC0, it is not a valid license. + */ +const allowedLicenses = [ + // Regular valid open source licenses supported by Google. + 'MIT', + 'ISC', + 'Apache-2.0', + 'Python-2.0', + 'Artistic-2.0', + 'BlueOak-1.0.0', + + 'BSD-2-Clause', + 'BSD-3-Clause', + 'BSD-4-Clause', + + // All CC-BY licenses have a full copyright grant and attribution section. + 'CC-BY-3.0', + 'CC-BY-4.0', + + // Have a full copyright grant. Validated by opensource team. + 'Unlicense', + 'CC0-1.0', + '0BSD', + + // Combinations. + '(AFL-2.1 OR BSD-2-Clause)', +]; + +// Name variations of SPDX licenses that some packages have. +// Licenses not included in SPDX but accepted will be converted to MIT. +const licenseReplacements: {[key: string]: string} = { + // Just a longer string that our script catches. SPDX official name is the shorter one. + 'Apache License, Version 2.0': 'Apache-2.0', + Apache2: 'Apache-2.0', + 'Apache 2.0': 'Apache-2.0', + 'Apache v2': 'Apache-2.0', + 'AFLv2.1': 'AFL-2.1', + // BSD is BSD-2-clause by default. + BSD: 'BSD-2-Clause', +}; + +// Specific packages to ignore, add a reason in a comment. Format: package-name@version. +const ignoredPackages = [ + // * Development only + 'spdx-license-ids@3.0.5', // CC0 but it's content only (index.json, no code) and not distributed. +]; + +// Check if a license is accepted by an array of accepted licenses +function _passesSpdx(licenses: string[], accepted: string[]) { + try { + return spdxSatisfies(licenses.join(' AND '), accepted.join(' OR ')); + } catch { + return false; + } +} + +function main(): Promise<number> { + return new Promise(resolve => { + const startFolder = path.join(__dirname, '..', '..'); + checker.init( + {start: startFolder, excludePrivatePackages: true}, + (err: Error, json: object) => { + if (err) { + console.error(`Something happened:\n${err.message}`); + resolve(1); + } else { + console.info(`Testing ${Object.keys(json).length} packages.\n`); + + // Packages with bad licenses are those that neither pass SPDX nor are ignored. + const badLicensePackages = Object.keys(json) + .map(key => { + return { + id: key, + licenses: ([] as string[]) + .concat((json[key] as {licenses: string[]}).licenses) + // `*` is used when the license is guessed. + .map(x => { + return x.replace(/\*$/, ''); + }) + .map(x => { + return x in licenseReplacements + ? licenseReplacements[x] + : x; + }), + }; + }) + .filter(pkg => { + return !_passesSpdx(pkg.licenses, allowedLicenses); + }) + .filter(pkg => { + return !ignoredPackages.find(ignored => { + return ignored === pkg.id; + }); + }); + + // Report packages with bad licenses + if (badLicensePackages.length > 0) { + console.error('Invalid package licences found:'); + badLicensePackages.forEach(pkg => { + console.error(`${pkg.id}: ${JSON.stringify(pkg.licenses)}`); + }); + console.error( + `\n${badLicensePackages.length} total packages with invalid licenses.` + ); + resolve(2); + } else { + console.info('All package licenses are valid.'); + resolve(0); + } + } + } + ); + }); +} + +main().then(code => { + return process.exit(code); +}); diff --git a/remote/test/puppeteer/tools/tsconfig.json b/remote/test/puppeteer/tools/tsconfig.json new file mode 100644 index 0000000000..964d349435 --- /dev/null +++ b/remote/test/puppeteer/tools/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "files": ["../package.json"], +} diff --git a/remote/test/puppeteer/tools/tsdoc.json b/remote/test/puppeteer/tools/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/update_chrome_revision.mjs b/remote/test/puppeteer/tools/update_chrome_revision.mjs new file mode 100644 index 0000000000..64eeef74d5 --- /dev/null +++ b/remote/test/puppeteer/tools/update_chrome_revision.mjs @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {execSync, exec} from 'child_process'; +import {writeFile, readFile} from 'fs/promises'; +import {promisify} from 'util'; + +import actions from '@actions/core'; +import {SemVer} from 'semver'; + +import packageJson from '../packages/puppeteer-core/package.json' assert {type: 'json'}; +import {versionsPerRelease, lastMaintainedChromeVersion} from '../versions.js'; + +import {PUPPETEER_REVISIONS} from 'puppeteer-core/internal/revisions.js'; + +const execAsync = promisify(exec); + +const CHROME_CURRENT_VERSION = PUPPETEER_REVISIONS.chrome; +const VERSIONS_PER_RELEASE_COMMENT = + '// In Chrome roll patches, use `NEXT` for the Puppeteer version.'; + +const touchedFiles = []; + +function checkIfNeedsUpdate(oldVersion, newVersion, newRevision) { + const oldSemVer = new SemVer(oldVersion, true); + const newSemVer = new SemVer(newVersion, true); + let message = `roll to Chrome ${newVersion} (r${newRevision})`; + + if (newSemVer.compare(oldSemVer) <= 0) { + // Exit the process without setting up version + console.warn( + `Version ${newVersion} is older or the same as the current ${oldVersion}` + ); + process.exit(0); + } else if (newSemVer.compareMain(oldSemVer) === 0) { + message = `fix: ${message}`; + } else { + message = `feat: ${message}`; + } + actions.setOutput('commit', message); +} + +/** + * We cant use `npm run format` as it's too slow + * so we only scope the files we updated + */ +async function formatUpdateFiles() { + await Promise.all( + touchedFiles.map(file => { + return execAsync(`npx eslint --ext js --ext ts --fix ${file}`); + }) + ); + await Promise.all( + touchedFiles.map(file => { + return execAsync(`npx prettier --write ${file}`); + }) + ); +} + +async function replaceInFile(filePath, search, replace) { + const buffer = await readFile(filePath); + const update = buffer.toString().replaceAll(search, replace); + + await writeFile(filePath, update); + + touchedFiles.push(filePath); +} + +async function getVersionAndRevisionForStable() { + const result = await fetch( + 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json' + ).then(response => { + return response.json(); + }); + + const {version, revision} = result.channels['Stable']; + + return { + version, + revision, + }; +} + +async function updateDevToolsProtocolVersion(revision) { + const currentProtocol = packageJson.dependencies['devtools-protocol']; + const command = `npm view "devtools-protocol@<=0.0.${revision}" version | tail -1`; + + const bestNewProtocol = execSync(command, { + encoding: 'utf8', + }) + .split(' ')[1] + .replace(/'|\n/g, ''); + + await replaceInFile( + './packages/puppeteer-core/package.json', + `"devtools-protocol": "${currentProtocol}"`, + `"devtools-protocol": "${bestNewProtocol}"` + ); +} + +async function updateVersionFileLastMaintained(oldVersion, newVersion) { + const versions = [...versionsPerRelease.keys()]; + if (versions.indexOf(newVersion) !== -1) { + return; + } + + // If we have manually rolled Chrome but not yet released + // We will have NEXT as value in the Map + if (versionsPerRelease.get(oldVersion) === 'NEXT') { + await replaceInFile('./versions.js', oldVersion, newVersion); + return; + } + + await replaceInFile( + './versions.js', + VERSIONS_PER_RELEASE_COMMENT, + `${VERSIONS_PER_RELEASE_COMMENT}\n ['${version}', 'NEXT'],` + ); + + const oldSemVer = new SemVer(oldVersion, true); + const newSemVer = new SemVer(newVersion, true); + + if (newSemVer.compareMain(oldSemVer) !== 0) { + const lastMaintainedSemVer = new SemVer(lastMaintainedChromeVersion, true); + const newLastMaintainedMajor = lastMaintainedSemVer.major + 1; + + const nextMaintainedVersion = versions.find(version => { + return new SemVer(version, true).major === newLastMaintainedMajor; + }); + + await replaceInFile( + './versions.js', + `const lastMaintainedChromeVersion = '${lastMaintainedChromeVersion}';`, + `const lastMaintainedChromeVersion = '${nextMaintainedVersion}';` + ); + } +} + +const {version, revision} = await getVersionAndRevisionForStable(); + +checkIfNeedsUpdate(CHROME_CURRENT_VERSION, version, revision); + +await replaceInFile( + './packages/puppeteer-core/src/revisions.ts', + CHROME_CURRENT_VERSION, + version +); + +await updateVersionFileLastMaintained(CHROME_CURRENT_VERSION, version); +await updateDevToolsProtocolVersion(revision); + +// Create new `package-lock.json` as we update devtools-protocol +execSync('npm install --ignore-scripts'); +// Make sure we pass CI formatter check by running all the new files though it +await formatUpdateFiles(); + +// Keep this as they can be used to debug GitHub Actions if needed +actions.setOutput('version', version); +actions.setOutput('revision', revision); diff --git a/remote/test/puppeteer/tsconfig.base.json b/remote/test/puppeteer/tsconfig.base.json new file mode 100644 index 0000000000..d0382df698 --- /dev/null +++ b/remote/test/puppeteer/tsconfig.base.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "allowJs": true, + "alwaysStrict": true, + "checkJs": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "incremental": true, + "module": "ES2022", + "moduleResolution": "Bundler", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "sourceMap": true, + "strict": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "target": "ES2022", + "useUnknownInCatchVariables": true, + "skipLibCheck": true + } +} diff --git a/remote/test/puppeteer/tsdoc.json b/remote/test/puppeteer/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/versions.js b/remote/test/puppeteer/versions.js new file mode 100644 index 0000000000..cbd835efc6 --- /dev/null +++ b/remote/test/puppeteer/versions.js @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const versionsPerRelease = new Map([ + // This is a mapping from Chrome version => Puppeteer version. + // In Chrome roll patches, use `NEXT` for the Puppeteer version. + ['121.0.6167.85', 'v21.9.0'], + ['120.0.6099.109', 'v21.8.0'], + ['119.0.6045.105', 'v21.5.0'], + ['118.0.5993.70', 'v21.4.0'], + ['117.0.5938.149', 'v21.3.7'], + ['117.0.5938.92', 'v21.3.2'], + ['117.0.5938.62', 'v21.3.0'], + ['116.0.5845.96', 'v21.1.0'], + ['115.0.5790.170', 'v21.0.2'], + ['115.0.5790.102', 'v21.0.0'], + ['115.0.5790.98', 'v20.9.0'], + ['114.0.5735.133', 'v20.7.2'], + ['114.0.5735.90', 'v20.6.0'], + ['113.0.5672.63', 'v20.1.0'], + ['112.0.5615.121', 'v20.0.0'], + ['112.0.5614.0', 'v19.8.0'], + ['111.0.5556.0', 'v19.7.0'], + ['110.0.5479.0', 'v19.6.0'], + ['109.0.5412.0', 'v19.4.0'], + ['108.0.5351.0', 'v19.2.0'], + ['107.0.5296.0', 'v18.1.0'], + ['106.0.5249.0', 'v17.1.0'], + ['105.0.5173.0', 'v15.5.0'], + ['104.0.5109.0', 'v15.1.0'], + ['103.0.5059.0', 'v14.2.0'], + ['102.0.5002.0', 'v14.0.0'], + ['101.0.4950.0', 'v13.6.0'], + ['100.0.4889.0', 'v13.5.0'], + ['99.0.4844.16', 'v13.2.0'], + ['98.0.4758.0', 'v13.1.0'], + ['97.0.4692.0', 'v12.0.0'], + ['93.0.4577.0', 'v10.2.0'], + ['92.0.4512.0', 'v10.0.0'], + ['91.0.4469.0', 'v9.0.0'], + ['90.0.4427.0', 'v8.0.0'], + ['90.0.4403.0', 'v7.0.0'], + ['89.0.4389.0', 'v6.0.0'], + ['88.0.4298.0', 'v5.5.0'], + ['87.0.4272.0', 'v5.4.0'], + ['86.0.4240.0', 'v5.3.0'], + ['85.0.4182.0', 'v5.2.1'], + ['84.0.4147.0', 'v5.1.0'], + ['83.0.4103.0', 'v3.1.0'], + ['81.0.4044.0', 'v3.0.0'], + ['80.0.3987.0', 'v2.1.0'], + ['79.0.3942.0', 'v2.0.0'], + ['78.0.3882.0', 'v1.20.0'], + ['77.0.3803.0', 'v1.19.0'], + ['76.0.3803.0', 'v1.17.0'], + ['75.0.3765.0', 'v1.15.0'], + ['74.0.3723.0', 'v1.13.0'], + ['73.0.3679.0', 'v1.12.2'], +]); + +// Should not be more than 2 major versions behind Chrome Stable (https://chromestatus.com/roadmap). +const lastMaintainedChromeVersion = '119.0.6045.105'; + +if (!versionsPerRelease.has(lastMaintainedChromeVersion)) { + throw new Error( + 'lastMaintainedChromeVersion is missing from versionsPerRelease' + ); +} + +module.exports = { + versionsPerRelease, + lastMaintainedChromeVersion, +}; diff --git a/remote/webdriver-bidi/NewSessionHandler.sys.mjs b/remote/webdriver-bidi/NewSessionHandler.sys.mjs new file mode 100644 index 0000000000..342419033f --- /dev/null +++ b/remote/webdriver-bidi/NewSessionHandler.sys.mjs @@ -0,0 +1,57 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + WebDriverBiDiConnection: + "chrome://remote/content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs", + WebSocketHandshake: + "chrome://remote/content/server/WebSocketHandshake.sys.mjs", +}); + +/** + * httpd.js JSON handler for direct BiDi connections. + */ +export class WebDriverNewSessionHandler { + /** + * Construct a new JSON handler. + * + * @param {WebDriverBiDi} webDriverBiDi + * Reference to the WebDriver BiDi protocol implementation. + */ + constructor(webDriverBiDi) { + this.webDriverBiDi = webDriverBiDi; + } + + // nsIHttpRequestHandler + + /** + * Handle new direct WebSocket connection requests. + * + * WebSocket clients not using the WebDriver BiDi opt-in mechanism via the + * WebDriver HTTP implementation will attempt to directly connect at + * `/session`. Hereby a WebSocket upgrade will automatically be performed. + * + * @param {Request} request + * HTTP request (httpd.js) + * @param {Response} response + * Response to an HTTP request (httpd.js) + */ + async handle(request, response) { + const webSocket = await lazy.WebSocketHandshake.upgrade(request, response); + const conn = new lazy.WebDriverBiDiConnection( + webSocket, + response._connection + ); + + this.webDriverBiDi.addSessionlessConnection(conn); + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIHttpRequestHandler"]); + } +} diff --git a/remote/webdriver-bidi/RemoteValue.sys.mjs b/remote/webdriver-bidi/RemoteValue.sys.mjs new file mode 100644 index 0000000000..cd2f58d066 --- /dev/null +++ b/remote/webdriver-bidi/RemoteValue.sys.mjs @@ -0,0 +1,1045 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI) +); + +/** + * @typedef {object} IncludeShadowTreeMode + */ + +/** + * Enum of include shadow tree modes supported by the serialization. + * + * @readonly + * @enum {IncludeShadowTreeMode} + */ +export const IncludeShadowTreeMode = { + All: "all", + None: "none", + Open: "open", +}; + +/** + * @typedef {object} OwnershipModel + */ + +/** + * Enum of ownership models supported by the serialization. + * + * @readonly + * @enum {OwnershipModel} + */ +export const OwnershipModel = { + None: "none", + Root: "root", +}; + +/** + * Extra options for deserializing remote values. + * + * @typedef {object} ExtraDeserializationOptions + * + * @property {NodeCache=} nodeCache + * The cache containing DOM node references. + * @property {Function=} emitScriptMessage + * The function to emit "script.message" event. + */ + +/** + * Extra options for serializing remote values. + * + * @typedef {object} ExtraSerializationOptions + * + * @property {NodeCache=} nodeCache + * The cache containing DOM node references. + * @property {Map<BrowsingContext, Array<string>>} seenNodeIds + * Map of browsing contexts to their seen node ids during the current + * serialization. + */ + +/** + * An object which holds the information of how + * ECMAScript objects should be serialized. + * + * @typedef {object} SerializationOptions + * + * @property {number} [maxDomDepth=0] + * Depth of a serialization of DOM Nodes. Defaults to 0. + * @property {number} [maxObjectDepth=null] + * Depth of a serialization of objects. Defaults to null. + * @property {IncludeShadowTreeMode} [includeShadowTree=IncludeShadowTreeMode.None] + * Mode of a serialization of shadow dom. Defaults to "none". + */ + +const TYPED_ARRAY_CLASSES = [ + "Uint8Array", + "Uint8ClampedArray", + "Uint16Array", + "Uint32Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", +]; + +/** + * Build the serialized RemoteValue. + * + * @returns {object} + * An object with a mandatory `type` property, and optional `handle`, + * depending on the OwnershipModel, used for the serialization and + * on the value's type. + */ +function buildSerialized(type, handle = null) { + const serialized = { type }; + + if (handle !== null) { + serialized.handle = handle; + } + + return serialized; +} + +/** + * Helper to deserialize value list. + * + * @see https://w3c.github.io/webdriver-bidi/#deserialize-value-list + * + * @param {Array} serializedValueList + * List of serialized values. + * @param {Realm} realm + * The Realm in which the value is deserialized. + * @param {ExtraDeserializationOptions} extraOptions + * Extra Remote Value deserialization options. + * + * @returns {Array} List of deserialized values. + * + * @throws {InvalidArgumentError} + * If <var>serializedValueList</var> is not an array. + */ +function deserializeValueList(serializedValueList, realm, extraOptions) { + lazy.assert.array( + serializedValueList, + `Expected "serializedValueList" to be an array, got ${serializedValueList}` + ); + + const deserializedValues = []; + + for (const item of serializedValueList) { + deserializedValues.push(deserialize(item, realm, extraOptions)); + } + + return deserializedValues; +} + +/** + * Helper to deserialize key-value list. + * + * @see https://w3c.github.io/webdriver-bidi/#deserialize-key-value-list + * + * @param {Array} serializedKeyValueList + * List of serialized key-value. + * @param {Realm} realm + * The Realm in which the value is deserialized. + * @param {ExtraDeserializationOptions} extraOptions + * Extra Remote Value deserialization options. + * + * @returns {Array} List of deserialized key-value. + * + * @throws {InvalidArgumentError} + * If <var>serializedKeyValueList</var> is not an array or + * not an array of key-value arrays. + */ +function deserializeKeyValueList(serializedKeyValueList, realm, extraOptions) { + lazy.assert.array( + serializedKeyValueList, + `Expected "serializedKeyValueList" to be an array, got ${serializedKeyValueList}` + ); + + const deserializedKeyValueList = []; + + for (const serializedKeyValue of serializedKeyValueList) { + if (!Array.isArray(serializedKeyValue) || serializedKeyValue.length != 2) { + throw new lazy.error.InvalidArgumentError( + `Expected key-value pair to be an array with 2 elements, got ${serializedKeyValue}` + ); + } + const [serializedKey, serializedValue] = serializedKeyValue; + const deserializedKey = + typeof serializedKey == "string" + ? serializedKey + : deserialize(serializedKey, realm, extraOptions); + const deserializedValue = deserialize(serializedValue, realm, extraOptions); + + deserializedKeyValueList.push([deserializedKey, deserializedValue]); + } + + return deserializedKeyValueList; +} + +/** + * Deserialize a Node as referenced by the shared unique reference. + * + * This unique reference can be shared by WebDriver clients with the WebDriver + * classic implementation (Marionette) if the reference is for an Element or + * ShadowRoot. + * + * @param {string} sharedRef + * Shared unique reference of the Node. + * @param {Realm} realm + * The Realm in which the value is deserialized. + * @param {ExtraDeserializationOptions} extraOptions + * Extra Remote Value deserialization options. + * + * @returns {Node} The deserialized DOM node. + */ +function deserializeSharedReference(sharedRef, realm, extraOptions) { + const { nodeCache } = extraOptions; + + const browsingContext = realm.browsingContext; + if (!browsingContext) { + throw new lazy.error.NoSuchNodeError("Realm isn't a Window global"); + } + + const node = nodeCache.getNode(browsingContext, sharedRef); + + if (node === null) { + throw new lazy.error.NoSuchNodeError( + `The node with the reference ${sharedRef} is not known` + ); + } + + // Bug 1819902: Instead of a browsing context check compare the origin + const isSameBrowsingContext = sharedRef => { + const nodeDetails = nodeCache.getReferenceDetails(sharedRef); + + if (nodeDetails.isTopBrowsingContext && browsingContext.parent === null) { + // As long as Navigables are not available any cross-group navigation will + // cause a swap of the current top-level browsing context. The only unique + // identifier in such a case is the browser id the top-level browsing + // context actually lives in. + return nodeDetails.browserId === browsingContext.browserId; + } + + return nodeDetails.browsingContextId === browsingContext.id; + }; + + if (!isSameBrowsingContext(sharedRef)) { + return null; + } + + return node; +} + +/** + * Deserialize a local value. + * + * @see https://w3c.github.io/webdriver-bidi/#deserialize-local-value + * + * @param {object} serializedValue + * Value of any type to be deserialized. + * @param {Realm} realm + * The Realm in which the value is deserialized. + * @param {ExtraDeserializationOptions} extraOptions + * Extra Remote Value deserialization options. + * + * @returns {object} Deserialized representation of the value. + */ +export function deserialize(serializedValue, realm, extraOptions) { + const { handle, sharedId, type, value } = serializedValue; + + // With a shared id present deserialize as node reference. + if (sharedId !== undefined) { + lazy.assert.string( + sharedId, + `Expected "sharedId" to be a string, got ${sharedId}` + ); + + return deserializeSharedReference(sharedId, realm, extraOptions); + } + + // With a handle present deserialize as remote reference. + if (handle !== undefined) { + lazy.assert.string( + handle, + `Expected "handle" to be a string, got ${handle}` + ); + + const object = realm.getObjectForHandle(handle); + if (!object) { + throw new lazy.error.NoSuchHandleError( + `Unable to find an object reference for "handle" ${handle}` + ); + } + + return object; + } + + lazy.assert.string(type, `Expected "type" to be a string, got ${type}`); + + // Primitive protocol values + switch (type) { + case "undefined": + return undefined; + case "null": + return null; + case "string": + lazy.assert.string( + value, + `Expected "value" to be a string, got ${value}` + ); + return value; + case "number": + // If value is already a number return its value. + if (typeof value === "number") { + return value; + } + + // Otherwise it has to be one of the special strings + lazy.assert.in( + value, + ["NaN", "-0", "Infinity", "-Infinity"], + `Expected "value" to be one of "NaN", "-0", "Infinity", "-Infinity", got ${value}` + ); + return Number(value); + case "boolean": + lazy.assert.boolean( + value, + `Expected "value" to be a boolean, got ${value}` + ); + return value; + case "bigint": + lazy.assert.string( + value, + `Expected "value" to be a string, got ${value}` + ); + try { + return BigInt(value); + } catch (e) { + throw new lazy.error.InvalidArgumentError( + `Failed to deserialize value as BigInt: ${value}` + ); + } + + // Script channel + case "channel": { + const channel = message => + extraOptions.emitScriptMessage(realm, value, message); + return realm.cloneIntoRealm(channel); + } + + // Non-primitive protocol values + case "array": + const array = realm.cloneIntoRealm([]); + deserializeValueList(value, realm, extraOptions).forEach(v => + array.push(v) + ); + return array; + case "date": + // We want to support only Date Time String format, + // check if the value follows it. + if (!ChromeUtils.isISOStyleDate(value)) { + throw new lazy.error.InvalidArgumentError( + `Expected "value" for Date to be a Date Time string, got ${value}` + ); + } + + return realm.cloneIntoRealm(new Date(value)); + case "map": + const map = realm.cloneIntoRealm(new Map()); + deserializeKeyValueList(value, realm, extraOptions).forEach(([k, v]) => + map.set(k, v) + ); + + return map; + case "object": + const object = realm.cloneIntoRealm({}); + deserializeKeyValueList(value, realm, extraOptions).forEach( + ([k, v]) => (object[k] = v) + ); + return object; + case "regexp": + lazy.assert.object( + value, + `Expected "value" for RegExp to be an object, got ${value}` + ); + const { pattern, flags } = value; + lazy.assert.string( + pattern, + `Expected "pattern" for RegExp to be a string, got ${pattern}` + ); + if (flags !== undefined) { + lazy.assert.string( + flags, + `Expected "flags" for RegExp to be a string, got ${flags}` + ); + } + try { + return realm.cloneIntoRealm(new RegExp(pattern, flags)); + } catch (e) { + throw new lazy.error.InvalidArgumentError( + `Failed to deserialize value as RegExp: ${value}` + ); + } + case "set": + const set = realm.cloneIntoRealm(new Set()); + deserializeValueList(value, realm, extraOptions).forEach(v => set.add(v)); + return set; + } + + lazy.logger.warn(`Unsupported type for local value ${type}`); + return undefined; +} + +/** + * Helper to retrieve the handle id for a given object, for the provided realm + * and ownership type. + * + * See https://w3c.github.io/webdriver-bidi/#handle-for-an-object + * + * @param {Realm} realm + * The Realm from which comes the value being serialized. + * @param {OwnershipModel} ownershipType + * The ownership model to use for this serialization. + * @param {object} object + * The object being serialized. + * + * @returns {string} The unique handle id for the object. Will be null if the + * Ownership type is "none". + */ +function getHandleForObject(realm, ownershipType, object) { + if (ownershipType === OwnershipModel.None) { + return null; + } + return realm.getHandleForObject(object); +} + +/** + * Gets or creates a new shared unique reference for the DOM node. + * + * This unique reference can be shared by WebDriver clients with the WebDriver + * classic implementation (Marionette) if the reference is for an Element or + * ShadowRoot. + * + * @param {Node} node + * Node to create the unique reference for. + * @param {ExtraSerializationOptions} extraOptions + * Extra Remote Value serialization options. + * + * @returns {string} + * Shared unique reference for the Node. + */ +function getSharedIdForNode(node, extraOptions) { + const { nodeCache, seenNodeIds } = extraOptions; + + if (!Node.isInstance(node)) { + return null; + } + + const browsingContext = node.ownerGlobal.browsingContext; + if (!browsingContext) { + return null; + } + + return nodeCache.getOrCreateNodeReference(node, seenNodeIds); +} + +/** + * Helper to serialize an Array-like object. + * + * @see https://w3c.github.io/webdriver-bidi/#serialize-an-array-like + * + * @param {string} production + * Type of object + * @param {string} handleId + * The unique id of the <var>value</var>. + * @param {boolean} knownObject + * Indicates if the <var>value</var> has already been serialized. + * @param {object} value + * The Array-like object to serialize. + * @param {SerializationOptions} serializationOptions + * Options which define how ECMAScript objects should be serialized. + * @param {OwnershipModel} ownershipType + * The ownership model to use for this serialization. + * @param {Map} serializationInternalMap + * Map of internal ids. + * @param {Realm} realm + * The Realm from which comes the value being serialized. + * @param {ExtraSerializationOptions} extraOptions + * Extra Remote Value serialization options. + * + * @returns {object} Object for serialized values. + */ +function serializeArrayLike( + production, + handleId, + knownObject, + value, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions +) { + const serialized = buildSerialized(production, handleId); + setInternalIdsIfNeeded(serializationInternalMap, serialized, value); + + if (!knownObject && serializationOptions.maxObjectDepth !== 0) { + serialized.value = serializeList( + value, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + } + + return serialized; +} + +/** + * Helper to serialize as a list. + * + * @see https://w3c.github.io/webdriver-bidi/#serialize-as-a-list + * + * @param {Iterable} iterable + * List of values to be serialized. + * @param {SerializationOptions} serializationOptions + * Options which define how ECMAScript objects should be serialized. + * @param {OwnershipModel} ownershipType + * The ownership model to use for this serialization. + * @param {Map} serializationInternalMap + * Map of internal ids. + * @param {Realm} realm + * The Realm from which comes the value being serialized. + * @param {ExtraSerializationOptions} extraOptions + * Extra Remote Value serialization options. + * + * @returns {Array} List of serialized values. + */ +function serializeList( + iterable, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions +) { + const { maxObjectDepth } = serializationOptions; + const serialized = []; + const childSerializationOptions = { + ...serializationOptions, + }; + if (maxObjectDepth !== null) { + childSerializationOptions.maxObjectDepth = maxObjectDepth - 1; + } + + for (const item of iterable) { + serialized.push( + serialize( + item, + childSerializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ) + ); + } + + return serialized; +} + +/** + * Helper to serialize as a mapping. + * + * @see https://w3c.github.io/webdriver-bidi/#serialize-as-a-mapping + * + * @param {Iterable} iterable + * List of values to be serialized. + * @param {SerializationOptions} serializationOptions + * Options which define how ECMAScript objects should be serialized. + * @param {OwnershipModel} ownershipType + * The ownership model to use for this serialization. + * @param {Map} serializationInternalMap + * Map of internal ids. + * @param {Realm} realm + * The Realm from which comes the value being serialized. + * @param {ExtraSerializationOptions} extraOptions + * Extra Remote Value serialization options. + * + * @returns {Array} List of serialized values. + */ +function serializeMapping( + iterable, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions +) { + const { maxObjectDepth } = serializationOptions; + const serialized = []; + const childSerializationOptions = { + ...serializationOptions, + }; + if (maxObjectDepth !== null) { + childSerializationOptions.maxObjectDepth = maxObjectDepth - 1; + } + + for (const [key, item] of iterable) { + const serializedKey = + typeof key == "string" + ? key + : serialize( + key, + childSerializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + const serializedValue = serialize( + item, + childSerializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + + serialized.push([serializedKey, serializedValue]); + } + + return serialized; +} + +/** + * Helper to serialize as a Node. + * + * @param {Node} node + * Node to be serialized. + * @param {SerializationOptions} serializationOptions + * Options which define how ECMAScript objects should be serialized. + * @param {OwnershipModel} ownershipType + * The ownership model to use for this serialization. + * @param {Map} serializationInternalMap + * Map of internal ids. + * @param {Realm} realm + * The Realm from which comes the value being serialized. + * @param {ExtraSerializationOptions} extraOptions + * Extra Remote Value serialization options. + * + * @returns {object} Serialized value. + */ +function serializeNode( + node, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions +) { + const { includeShadowTree, maxDomDepth } = serializationOptions; + const isAttribute = Attr.isInstance(node); + const isElement = Element.isInstance(node); + + const serialized = { + nodeType: node.nodeType, + }; + + if (node.nodeValue !== null) { + serialized.nodeValue = node.nodeValue; + } + + if (isElement || isAttribute) { + serialized.localName = node.localName; + serialized.namespaceURI = node.namespaceURI; + } + + serialized.childNodeCount = node.childNodes.length; + if ( + maxDomDepth !== 0 && + (!lazy.dom.isShadowRoot(node) || + (includeShadowTree === IncludeShadowTreeMode.Open && + node.mode === "open") || + includeShadowTree === IncludeShadowTreeMode.All) + ) { + const children = []; + const childSerializationOptions = { + ...serializationOptions, + }; + if (maxDomDepth !== null) { + childSerializationOptions.maxDomDepth = maxDomDepth - 1; + } + for (const child of node.childNodes) { + children.push( + serialize( + child, + childSerializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ) + ); + } + + serialized.children = children; + } + + if (isElement) { + serialized.attributes = [...node.attributes].reduce((map, attr) => { + map[attr.name] = attr.value; + return map; + }, {}); + + const shadowRoot = node.openOrClosedShadowRoot; + serialized.shadowRoot = null; + if (shadowRoot !== null) { + serialized.shadowRoot = serialize( + shadowRoot, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + } + } + + if (lazy.dom.isShadowRoot(node)) { + serialized.mode = node.mode; + } + + return serialized; +} + +/** + * Serialize a value as a remote value. + * + * @see https://w3c.github.io/webdriver-bidi/#serialize-as-a-remote-value + * + * @param {object} value + * Value of any type to be serialized. + * @param {SerializationOptions} serializationOptions + * Options which define how ECMAScript objects should be serialized. + * @param {OwnershipModel} ownershipType + * The ownership model to use for this serialization. + * @param {Map} serializationInternalMap + * Map of internal ids. + * @param {Realm} realm + * The Realm from which comes the value being serialized. + * @param {ExtraSerializationOptions} extraOptions + * Extra Remote Value serialization options. + * + * @returns {object} Serialized representation of the value. + */ +export function serialize( + value, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions +) { + const { maxObjectDepth } = serializationOptions; + const type = typeof value; + + // Primitive protocol values + if (type == "undefined") { + return { type }; + } else if (Object.is(value, null)) { + return { type: "null" }; + } else if (Object.is(value, NaN)) { + return { type: "number", value: "NaN" }; + } else if (Object.is(value, -0)) { + return { type: "number", value: "-0" }; + } else if (Object.is(value, Infinity)) { + return { type: "number", value: "Infinity" }; + } else if (Object.is(value, -Infinity)) { + return { type: "number", value: "-Infinity" }; + } else if (type == "bigint") { + return { type, value: value.toString() }; + } else if (["boolean", "number", "string"].includes(type)) { + return { type, value }; + } + + const handleId = getHandleForObject(realm, ownershipType, value); + const knownObject = serializationInternalMap.has(value); + + // Set the OwnershipModel to use for all complex object serializations. + ownershipType = OwnershipModel.None; + + // Remote values + + // symbols are primitive JS values which can only be serialized + // as remote values. + if (type == "symbol") { + return buildSerialized("symbol", handleId); + } + + // All other remote values are non-primitives and their + // className can be extracted with ChromeUtils.getClassName + const className = ChromeUtils.getClassName(value); + if (["Array", "HTMLCollection", "NodeList"].includes(className)) { + return serializeArrayLike( + className.toLowerCase(), + handleId, + knownObject, + value, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + } else if (className == "RegExp") { + const serialized = buildSerialized("regexp", handleId); + serialized.value = { pattern: value.source, flags: value.flags }; + return serialized; + } else if (className == "Date") { + const serialized = buildSerialized("date", handleId); + serialized.value = value.toISOString(); + return serialized; + } else if (className == "Map") { + const serialized = buildSerialized("map", handleId); + setInternalIdsIfNeeded(serializationInternalMap, serialized, value); + + if (!knownObject && maxObjectDepth !== 0) { + serialized.value = serializeMapping( + value.entries(), + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + } + return serialized; + } else if (className == "Set") { + const serialized = buildSerialized("set", handleId); + setInternalIdsIfNeeded(serializationInternalMap, serialized, value); + + if (!knownObject && maxObjectDepth !== 0) { + serialized.value = serializeList( + value.values(), + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + } + return serialized; + } else if ( + ["ArrayBuffer", "Function", "Promise", "WeakMap", "WeakSet"].includes( + className + ) + ) { + return buildSerialized(className.toLowerCase(), handleId); + } else if (className.includes("Generator")) { + return buildSerialized("generator", handleId); + } else if (lazy.error.isError(value)) { + return buildSerialized("error", handleId); + } else if (Cu.isProxy(value)) { + return buildSerialized("proxy", handleId); + } else if (TYPED_ARRAY_CLASSES.includes(className)) { + return buildSerialized("typedarray", handleId); + } else if (Node.isInstance(value)) { + const serialized = buildSerialized("node", handleId); + + value = Cu.unwaiveXrays(value); + + // Get or create the shared id for WebDriver classic compat from the node. + const sharedId = getSharedIdForNode(value, extraOptions); + if (sharedId !== null) { + serialized.sharedId = sharedId; + } + + setInternalIdsIfNeeded(serializationInternalMap, serialized, value); + + if (!knownObject) { + serialized.value = serializeNode( + value, + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + } + + return serialized; + } else if (Window.isInstance(value)) { + const serialized = buildSerialized("window", handleId); + const window = Cu.unwaiveXrays(value); + + if (window.browsingContext.parent == null) { + serialized.value = { + context: window.browsingContext.browserId.toString(), + isTopBrowsingContext: true, + }; + } else { + serialized.value = { + context: window.browsingContext.id.toString(), + }; + } + + return serialized; + } else if (ChromeUtils.isDOMObject(value)) { + const serialized = buildSerialized("object", handleId); + return serialized; + } + + // Otherwise serialize the JavaScript object as generic object. + const serialized = buildSerialized("object", handleId); + setInternalIdsIfNeeded(serializationInternalMap, serialized, value); + + if (!knownObject && maxObjectDepth !== 0) { + serialized.value = serializeMapping( + Object.entries(value), + serializationOptions, + ownershipType, + serializationInternalMap, + realm, + extraOptions + ); + } + return serialized; +} + +/** + * Set default serialization options. + * + * @param {SerializationOptions} options + * Options which define how ECMAScript objects should be serialized. + * @returns {SerializationOptions} + * Serialiation options with default value added. + */ +export function setDefaultSerializationOptions(options = {}) { + const serializationOptions = { ...options }; + if (!("maxDomDepth" in serializationOptions)) { + serializationOptions.maxDomDepth = 0; + } + if (!("maxObjectDepth" in serializationOptions)) { + serializationOptions.maxObjectDepth = null; + } + if (!("includeShadowTree" in serializationOptions)) { + serializationOptions.includeShadowTree = IncludeShadowTreeMode.None; + } + + return serializationOptions; +} + +/** + * Set default values and assert if serialization options have + * expected types. + * + * @param {SerializationOptions} options + * Options which define how ECMAScript objects should be serialized. + * @returns {SerializationOptions} + * Serialiation options with default value added. + */ +export function setDefaultAndAssertSerializationOptions(options = {}) { + lazy.assert.object(options); + + const serializationOptions = setDefaultSerializationOptions(options); + + const { includeShadowTree, maxDomDepth, maxObjectDepth } = + serializationOptions; + + if (maxDomDepth !== null) { + lazy.assert.positiveInteger(maxDomDepth); + } + if (maxObjectDepth !== null) { + lazy.assert.positiveInteger(maxObjectDepth); + } + const includeShadowTreeModesValues = Object.values(IncludeShadowTreeMode); + lazy.assert.that( + includeShadowTree => + includeShadowTreeModesValues.includes(includeShadowTree), + `includeShadowTree ${includeShadowTree} doesn't match allowed values "${includeShadowTreeModesValues.join( + "/" + )}"` + )(includeShadowTree); + + return serializationOptions; +} + +/** + * Set the internalId property of a provided serialized RemoteValue, + * and potentially of a previously created serialized RemoteValue, + * corresponding to the same provided object. + * + * @see https://w3c.github.io/webdriver-bidi/#set-internal-ids-if-needed + * + * @param {Map} serializationInternalMap + * Map of objects to remote values. + * @param {object} remoteValue + * A serialized RemoteValue for the provided object. + * @param {object} object + * Object of any type to be serialized. + */ +function setInternalIdsIfNeeded(serializationInternalMap, remoteValue, object) { + if (!serializationInternalMap.has(object)) { + // If the object was not tracked yet in the current serialization, add + // a new entry in the serialization internal map. An internal id will only + // be generated if the same object is encountered again. + serializationInternalMap.set(object, remoteValue); + } else { + // This is at least the second time this object is encountered, retrieve the + // original remote value stored for this object. + const previousRemoteValue = serializationInternalMap.get(object); + + if (!previousRemoteValue.internalId) { + // If the original remote value has no internal id yet, generate a uuid + // and update the internalId of the original remote value with it. + previousRemoteValue.internalId = lazy.generateUUID(); + } + + // Copy the internalId of the original remote value to the new remote value. + remoteValue.internalId = previousRemoteValue.internalId; + } +} + +/** + * Safely stringify a value. + * + * @param {object} obj + * Value of any type to be stringified. + * + * @returns {string} String representation of the value. + */ +export function stringify(obj) { + let text; + try { + text = + obj !== null && typeof obj === "object" ? obj.toString() : String(obj); + } catch (e) { + // The error-case will also be handled in `finally {}`. + } finally { + if (typeof text != "string") { + text = Object.prototype.toString.apply(obj); + } + } + + return text; +} diff --git a/remote/webdriver-bidi/WebDriverBiDi.sys.mjs b/remote/webdriver-bidi/WebDriverBiDi.sys.mjs new file mode 100644 index 0000000000..00503ca2f6 --- /dev/null +++ b/remote/webdriver-bidi/WebDriverBiDi.sys.mjs @@ -0,0 +1,240 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + WebDriverNewSessionHandler: + "chrome://remote/content/webdriver-bidi/NewSessionHandler.sys.mjs", + WebDriverSession: "chrome://remote/content/shared/webdriver/Session.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI) +); +ChromeUtils.defineLazyGetter(lazy, "textEncoder", () => new TextEncoder()); + +/** + * Entry class for the WebDriver BiDi support. + * + * @see https://w3c.github.io/webdriver-bidi + */ +export class WebDriverBiDi { + /** + * Creates a new instance of the WebDriverBiDi class. + * + * @param {RemoteAgent} agent + * Reference to the Remote Agent instance. + */ + constructor(agent) { + this.agent = agent; + this._running = false; + + this._session = null; + this._sessionlessConnections = new Set(); + } + + get address() { + return `ws://${this.agent.host}:${this.agent.port}`; + } + + get session() { + return this._session; + } + + /** + * Add a new connection that is not yet attached to a WebDriver session. + * + * @param {WebDriverBiDiConnection} connection + * The connection without an accociated WebDriver session. + */ + addSessionlessConnection(connection) { + this._sessionlessConnections.add(connection); + } + + /** + * Create a new WebDriver session. + * + * @param {Object<string, *>=} capabilities + * JSON Object containing any of the recognised capabilities as listed + * on the `WebDriverSession` class. + * + * @param {WebDriverBiDiConnection=} sessionlessConnection + * Optional connection that is not yet accociated with a WebDriver + * session, and has to be associated with the new WebDriver session. + * + * @returns {Object<string, Capabilities>} + * Object containing the current session ID, and all its capabilities. + * + * @throws {SessionNotCreatedError} + * If, for whatever reason, a session could not be created. + */ + async createSession(capabilities, sessionlessConnection) { + if (this.session) { + throw new lazy.error.SessionNotCreatedError( + "Maximum number of active sessions" + ); + } + + const session = new lazy.WebDriverSession( + capabilities, + sessionlessConnection + ); + + // When the Remote Agent is listening, and a BiDi WebSocket connection + // has been requested, register a path handler for the session. + let webSocketUrl = null; + if ( + this.agent.running && + (session.capabilities.get("webSocketUrl") || sessionlessConnection) + ) { + // Creating a WebDriver BiDi session too early can cause issues with + // clients in not being able to find any available browsing context. + // Also when closing the application while it's still starting up can + // cause shutdown hangs. As such WebDriver BiDi will return a new session + // once the initial application window has finished initializing. + lazy.logger.debug(`Waiting for initial application window`); + 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 { + sessionId: this.session.id, + capabilities: this.session.capabilities, + }; + } + + /** + * Delete the current WebDriver session. + */ + deleteSession() { + if (!this.session) { + return; + } + + // When the Remote Agent is listening, and a BiDi WebSocket is active, + // unregister the path handler for the session. + if (this.agent.running && this.session.capabilities.get("webSocketUrl")) { + this.agent.server.registerPathHandler(this.session.path, null); + lazy.logger.debug(`Unregistered session handler: ${this.session.path}`); + } + + this.session.destroy(); + this._session = null; + } + + /** + * Retrieve the readiness state of the remote end, regarding the creation of + * new WebDriverBiDi sessions. + * + * See https://w3c.github.io/webdriver-bidi/#command-session-status + * + * @returns {object} + * The readiness state. + */ + getSessionReadinessStatus() { + if (this.session) { + // We currently only support one session, see Bug 1720707. + return { + ready: false, + message: "Session already started", + }; + } + + return { + ready: true, + message: "", + }; + } + + /** + * Starts the WebDriver BiDi support. + */ + async start() { + if (this._running) { + return; + } + + this._running = true; + + // Install a HTTP handler for direct WebDriver BiDi connection requests. + this.agent.server.registerPathHandler( + "/session", + new lazy.WebDriverNewSessionHandler(this) + ); + + Cu.printStderr(`WebDriver BiDi listening on ${this.address}\n`); + + // Write WebSocket connection details to the WebDriverBiDiServer.json file + // located within the application's profile. + this._bidiServerPath = PathUtils.join( + PathUtils.profileDir, + "WebDriverBiDiServer.json" + ); + + const data = { + ws_host: this.agent.host, + ws_port: this.agent.port, + }; + + try { + await IOUtils.write( + this._bidiServerPath, + lazy.textEncoder.encode(JSON.stringify(data, undefined, " ")) + ); + } catch (e) { + lazy.logger.warn( + `Failed to create ${this._bidiServerPath} (${e.message})` + ); + } + } + + /** + * Stops the WebDriver BiDi support. + */ + async stop() { + if (!this._running) { + return; + } + + try { + await IOUtils.remove(this._bidiServerPath); + } catch (e) { + lazy.logger.warn( + `Failed to remove ${this._bidiServerPath} (${e.message})` + ); + } + + try { + // Close open session + this.deleteSession(); + this.agent.server.registerPathHandler("/session", null); + + // Close all open session-less connections + this._sessionlessConnections.forEach(connection => connection.close()); + this._sessionlessConnections.clear(); + } catch (e) { + lazy.logger.error("Failed to stop protocol", e); + } finally { + this._running = false; + } + } +} diff --git a/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs b/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs new file mode 100644 index 0000000000..5ec7ff9a06 --- /dev/null +++ b/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs @@ -0,0 +1,268 @@ +/* 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/. */ + +import { WebSocketConnection } from "chrome://remote/content/shared/WebSocketConnection.sys.mjs"; + +const lazy = {}; + +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", + quit: "chrome://remote/content/shared/Browser.sys.mjs", + RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", + WEBDRIVER_CLASSIC_CAPABILITIES: + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI) +); + +export class WebDriverBiDiConnection extends WebSocketConnection { + /** + * @param {WebSocket} webSocket + * The WebSocket server connection to wrap. + * @param {Connection} httpdConnection + * Reference to the httpd.js's connection needed for clean-up. + */ + constructor(webSocket, httpdConnection) { + super(webSocket, httpdConnection); + + // Each connection has only a single associated WebDriver session. + this.session = null; + } + + /** + * Perform required steps to end the session. + */ + endSession() { + // TODO Bug 1838269. Implement session ending logic + // for the case of classic + bidi session. + // We currently only support one session, see Bug 1720707. + lazy.RemoteAgent.webDriverBiDi.deleteSession(); + } + + /** + * Register a new WebDriver Session to forward the messages to. + * + * @param {Session} session + * The WebDriverSession to register. + */ + registerSession(session) { + if (this.session) { + throw new lazy.error.UnknownError( + "A WebDriver session has already been set" + ); + } + + this.session = session; + lazy.logger.debug( + `Connection ${this.id} attached to session ${session.id}` + ); + } + + /** + * Unregister the already set WebDriver session. + */ + unregisterSession() { + if (!this.session) { + return; + } + + this.session.removeConnection(this); + this.session = null; + } + + /** + * Send an error back to the WebDriver BiDi client. + * + * @param {number} id + * Id of the packet which lead to an error. + * @param {Error} err + * Error object with `status`, `message` and `stack` attributes. + */ + sendError(id, err) { + const webDriverError = lazy.error.wrap(err); + + this.send({ + type: "error", + id, + error: webDriverError.status, + message: webDriverError.message, + stacktrace: webDriverError.stack, + }); + } + + /** + * Send an event coming from a module to the WebDriver BiDi client. + * + * @param {string} method + * The event name. This is composed by a module name, a dot character + * followed by the event name, e.g. `log.entryAdded`. + * @param {object} params + * A JSON-serializable object, which is the payload of this event. + */ + sendEvent(method, params) { + this.send({ type: "event", method, params }); + + if (Services.profiler?.IsActive()) { + ChromeUtils.addProfilerMarker( + "BiDi: Event", + { category: "Remote-Protocol" }, + method + ); + } + } + + /** + * Send the result of a call to a module's method back to the + * WebDriver BiDi client. + * + * @param {number} id + * The request id being sent by the client to call the module's method. + * @param {object} result + * A JSON-serializable object, which is the actual result. + */ + sendResult(id, result) { + result = typeof result !== "undefined" ? result : {}; + this.send({ type: "success", id, result }); + } + + observe(subject, topic) { + switch (topic) { + case "quit-application-requested": + this.endSession(); + break; + } + } + + // Transport hooks + + /** + * Called by the `transport` when the connection is closed. + */ + onConnectionClose() { + this.unregisterSession(); + + super.onConnectionClose(); + } + + /** + * Receive a packet from the WebSocket layer. + * + * This packet is sent by a WebDriver BiDi client and is meant to execute + * a particular method on a given module. + * + * @param {object} packet + * JSON-serializable object sent by the client + */ + async onPacket(packet) { + super.onPacket(packet); + + const { id, method, params } = packet; + const startTime = Cu.now(); + + try { + // First check for mandatory field in the command packet + lazy.assert.positiveInteger(id, "id: unsigned integer value expected"); + lazy.assert.string(method, "method: string value expected"); + lazy.assert.object(params, "params: object value expected"); + + // Extract the module and the command name out of `method` attribute + const { module, command } = splitMethod(method); + let result; + + // Handle static commands first + if (module === "session" && command === "new") { + const processedCapabilities = lazy.processCapabilities(params); + + result = await lazy.RemoteAgent.webDriverBiDi.createSession( + 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 { + lazy.assert.session(this.session); + + // Bug 1741854 - Workaround to deny internal methods to be called + if (command.startsWith("_")) { + throw new lazy.error.UnknownCommandError(method); + } + + // Finally, instruct the session to execute the command + result = await this.session.execute(module, command, params); + } + + this.sendResult(id, result); + + // Session clean up. + if (module === "session" && command === "end") { + this.endSession(); + } + // Close the browser. + // TODO Bug 1842018. Refactor this part to return the response + // when the quitting of the browser is finished. + else if (module === "browser" && command === "close") { + // Register handler to run WebDriver BiDi specific shutdown code. + Services.obs.addObserver(this, "quit-application-requested"); + + // TODO Bug 1836282. Add as the third argument "moz:windowless" capability + // from the session, when this capability is supported by Webdriver BiDi. + await lazy.quit(["eForceQuit"], false); + + Services.obs.removeObserver(this, "quit-application-requested"); + } + } catch (e) { + this.sendError(id, e); + } + + if (Services.profiler?.IsActive()) { + ChromeUtils.addProfilerMarker( + "BiDi: Command", + { startTime, category: "Remote-Protocol" }, + `${method} (${id})` + ); + } + } +} + +/** + * Splits a WebDriver BiDi method into module and command components. + * + * @param {string} method + * Name of the method to split, e.g. "session.subscribe". + * + * @returns {Object<string, string>} + * Object with the module ("session") and command ("subscribe") + * as properties. + */ +export function splitMethod(method) { + const parts = method.split("."); + + if (parts.length != 2 || !parts[0].length || !parts[1].length) { + throw new TypeError(`Invalid method format: '${method}'`); + } + + return { + module: parts[0], + command: parts[1], + }; +} diff --git a/remote/webdriver-bidi/jar.mn b/remote/webdriver-bidi/jar.mn new file mode 100644 index 0000000000..6f0b2493d8 --- /dev/null +++ b/remote/webdriver-bidi/jar.mn @@ -0,0 +1,37 @@ +# 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/. + +remote.jar: +% content remote %content/ + + content/webdriver-bidi/NewSessionHandler.sys.mjs (NewSessionHandler.sys.mjs) + content/webdriver-bidi/RemoteValue.sys.mjs (RemoteValue.sys.mjs) + content/webdriver-bidi/WebDriverBiDi.sys.mjs (WebDriverBiDi.sys.mjs) + content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs (WebDriverBiDiConnection.sys.mjs) + + # WebDriver BiDi modules + content/webdriver-bidi/modules/Intercept.sys.mjs (modules/Intercept.sys.mjs) + content/webdriver-bidi/modules/ModuleRegistry.sys.mjs (modules/ModuleRegistry.sys.mjs) + content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs (modules/WindowGlobalBiDiModule.sys.mjs) + + # WebDriver BiDi root modules + content/webdriver-bidi/modules/root/browser.sys.mjs (modules/root/browser.sys.mjs) + content/webdriver-bidi/modules/root/browsingContext.sys.mjs (modules/root/browsingContext.sys.mjs) + content/webdriver-bidi/modules/root/input.sys.mjs (modules/root/input.sys.mjs) + content/webdriver-bidi/modules/root/log.sys.mjs (modules/root/log.sys.mjs) + content/webdriver-bidi/modules/root/network.sys.mjs (modules/root/network.sys.mjs) + content/webdriver-bidi/modules/root/script.sys.mjs (modules/root/script.sys.mjs) + content/webdriver-bidi/modules/root/session.sys.mjs (modules/root/session.sys.mjs) + content/webdriver-bidi/modules/root/storage.sys.mjs (modules/root/storage.sys.mjs) + + # WebDriver BiDi windowglobal modules + content/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs (modules/windowglobal/browsingContext.sys.mjs) + content/webdriver-bidi/modules/windowglobal/input.sys.mjs (modules/windowglobal/input.sys.mjs) + content/webdriver-bidi/modules/windowglobal/log.sys.mjs (modules/windowglobal/log.sys.mjs) + content/webdriver-bidi/modules/windowglobal/script.sys.mjs (modules/windowglobal/script.sys.mjs) + + # WebDriver BiDi windowglobal-in-root modules + content/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs (modules/windowglobal-in-root/browsingContext.sys.mjs) + content/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs (modules/windowglobal-in-root/log.sys.mjs) + content/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs (modules/windowglobal-in-root/script.sys.mjs) diff --git a/remote/webdriver-bidi/modules/Intercept.sys.mjs b/remote/webdriver-bidi/modules/Intercept.sys.mjs new file mode 100644 index 0000000000..4e3a9bb9e7 --- /dev/null +++ b/remote/webdriver-bidi/modules/Intercept.sys.mjs @@ -0,0 +1,101 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + getSeenNodesForBrowsingContext: + "chrome://remote/content/shared/webdriver/Session.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +/** + * The serialization of JavaScript objects in the content process might produce + * extra data that needs to be transfered and then processed by the parent + * process. This extra data is part of the payload as returned by commands + * and events and can contain the following: + * + * - {Map<BrowsingContext, Array<string>>} seenNodeIds + * DOM nodes that need to be added to the navigable seen nodes map. + * + * @param {string} sessionId + * Id of the WebDriver session + * @param {object} payload + * Payload of the response for the command and event that might contain + * a `_extraData` field. + * + * @returns {object} + * The payload with the extra data removed if it was present. + */ +export function processExtraData(sessionId, payload) { + // Process extra data if present and delete it from the payload + if ("_extraData" in payload) { + const { seenNodeIds } = payload._extraData; + + // Updates the seen nodes for the current session and browsing context. + seenNodeIds?.forEach((nodeIds, browsingContext) => { + const seenNodes = lazy.getSeenNodesForBrowsingContext( + sessionId, + browsingContext + ); + + nodeIds.forEach(nodeId => seenNodes.add(nodeId)); + }); + + delete payload._extraData; + } + + // Find serialized WindowProxy and resolve browsing context to a navigable id. + if (payload?.result) { + payload.result = addContextIdToSerializedWindow(payload.result); + } else if (payload.exceptionDetails) { + payload.exceptionDetails = addContextIdToSerializedWindow( + payload.exceptionDetails + ); + } + + return payload; +} + +function addContextIdToSerializedWindow(serialized) { + if (serialized.value) { + switch (serialized.type) { + case "array": + case "htmlcollection": + case "nodelist": + case "set": { + serialized.value = serialized.value.map(value => + addContextIdToSerializedWindow(value) + ); + break; + } + + case "map": + case "object": { + serialized.value = serialized.value.map(([key, value]) => [ + key, + addContextIdToSerializedWindow(value), + ]); + break; + } + + case "window": { + if (serialized.value.isTopBrowsingContext) { + const browsingContext = BrowsingContext.getCurrentTopByBrowserId( + serialized.value.context + ); + + serialized.value = { + context: lazy.TabManager.getIdForBrowsingContext(browsingContext), + }; + } + break; + } + } + } else if (serialized.exception) { + serialized.exception = addContextIdToSerializedWindow(serialized.exception); + } + + return serialized; +} diff --git a/remote/webdriver-bidi/modules/ModuleRegistry.sys.mjs b/remote/webdriver-bidi/modules/ModuleRegistry.sys.mjs new file mode 100644 index 0000000000..63713f1f02 --- /dev/null +++ b/remote/webdriver-bidi/modules/ModuleRegistry.sys.mjs @@ -0,0 +1,46 @@ +/* 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/. */ + +export const modules = { + root: {}, + "windowglobal-in-root": {}, + windowglobal: {}, +}; + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules.root, { + browser: + "chrome://remote/content/webdriver-bidi/modules/root/browser.sys.mjs", + browsingContext: + "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs", + input: "chrome://remote/content/webdriver-bidi/modules/root/input.sys.mjs", + log: "chrome://remote/content/webdriver-bidi/modules/root/log.sys.mjs", + network: + "chrome://remote/content/webdriver-bidi/modules/root/network.sys.mjs", + script: "chrome://remote/content/webdriver-bidi/modules/root/script.sys.mjs", + session: + "chrome://remote/content/webdriver-bidi/modules/root/session.sys.mjs", + storage: + "chrome://remote/content/webdriver-bidi/modules/root/storage.sys.mjs", +}); + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules["windowglobal-in-root"], { + browsingContext: + "chrome://remote/content/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs", + log: "chrome://remote/content/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs", + script: + "chrome://remote/content/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs", +}); + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules.windowglobal, { + browsingContext: + "chrome://remote/content/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs", + input: + "chrome://remote/content/webdriver-bidi/modules/windowglobal/input.sys.mjs", + log: "chrome://remote/content/webdriver-bidi/modules/windowglobal/log.sys.mjs", + script: + "chrome://remote/content/webdriver-bidi/modules/windowglobal/script.sys.mjs", +}); diff --git a/remote/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs b/remote/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs new file mode 100644 index 0000000000..025ce5f4ab --- /dev/null +++ b/remote/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs @@ -0,0 +1,83 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + deserialize: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", + serialize: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", +}); + +/** + * Base class for all WindowGlobal BiDi MessageHandler modules. + */ +export class WindowGlobalBiDiModule extends Module { + get #nodeCache() { + return this.#processActor.getNodeCache(); + } + + get #processActor() { + return ChromeUtils.domProcessChild.getActor("WebDriverProcessData"); + } + + /** + * Wrapper to deserialize a local / remote value. + * + * @param {object} serializedValue + * Value of any type to be deserialized. + * @param {Realm} realm + * The Realm in which the value is deserialized. + * @param {ExtraSerializationOptions=} extraOptions + * Extra Remote Value deserialization options. + * + * @returns {object} + * Deserialized representation of the value. + */ + deserialize(serializedValue, realm, extraOptions = {}) { + extraOptions.nodeCache = this.#nodeCache; + + return lazy.deserialize(serializedValue, realm, extraOptions); + } + + /** + * Wrapper to serialize a value as a remote value. + * + * @param {object} value + * Value of any type to be serialized. + * @param {SerializationOptions} serializationOptions + * Options which define how ECMAScript objects should be serialized. + * @param {OwnershipModel} ownershipType + * The ownership model to use for this serialization. + * @param {Realm} realm + * The Realm from which comes the value being serialized. + * @param {ExtraSerializationOptions} extraOptions + * Extra Remote Value serialization options. + * + * @returns {object} + * Promise that resolves to the serialized representation of the value. + */ + serialize( + value, + serializationOptions, + ownershipType, + realm, + extraOptions = {} + ) { + const { nodeCache = this.#nodeCache, seenNodeIds = new Map() } = + extraOptions; + + const serializedValue = lazy.serialize( + value, + serializationOptions, + ownershipType, + new Map(), + realm, + { nodeCache, seenNodeIds } + ); + + return serializedValue; + } +} diff --git a/remote/webdriver-bidi/modules/root/browser.sys.mjs b/remote/webdriver-bidi/modules/root/browser.sys.mjs new file mode 100644 index 0000000000..57d40e74e9 --- /dev/null +++ b/remote/webdriver-bidi/modules/root/browser.sys.mjs @@ -0,0 +1,128 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Marionette: "chrome://remote/content/components/Marionette.sys.mjs", + UserContextManager: + "chrome://remote/content/shared/UserContextManager.sys.mjs", +}); + +/** + * An object that holds information about a user context. + * + * @typedef UserContextInfo + * + * @property {string} userContext + * The id of the user context. + */ + +/** + * Return value for the getUserContexts command. + * + * @typedef GetUserContextsResult + * + * @property {Array<UserContextInfo>} userContexts + * Array of UserContextInfo for the current user contexts. + */ + +class BrowserModule extends Module { + constructor(messageHandler) { + super(messageHandler); + } + + destroy() {} + + /** + * Commands + */ + + /** + * Terminate all WebDriver sessions and clean up automation state in the remote browser instance. + * + * Session clean up and actual broser closure will happen later in WebDriverBiDiConnection class. + */ + async close() { + // TODO Bug 1838269. Enable browser.close command for the case of classic + bidi session, when + // session ending for this type of session is supported. + if (lazy.Marionette.running) { + throw new lazy.error.UnsupportedOperationError( + "Closing browser with the session which was started with Webdriver classic is not supported," + + "you can use Webdriver classic session delete command which will also close the browser." + ); + } + } + + /** + * Creates a user context. + * + * @returns {UserContextInfo} + * UserContextInfo object for the created user context. + */ + async createUserContext() { + const userContextId = lazy.UserContextManager.createContext("webdriver"); + return { userContext: userContextId }; + } + + /** + * Returns the list of available user contexts. + * + * @returns {GetUserContextsResult} + * Object containing an array of UserContextInfo. + */ + async getUserContexts() { + const userContexts = lazy.UserContextManager.getUserContextIds().map( + userContextId => ({ + userContext: userContextId, + }) + ); + + return { userContexts }; + } + + /** + * Closes a user context and all browsing contexts in it without running + * beforeunload handlers. + * + * @param {object=} options + * @param {string} options.userContext + * Id of the user context to close. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchUserContextError} + * Raised if the user context id could not be found. + */ + async removeUserContext(options = {}) { + const { userContext: userContextId } = options; + + lazy.assert.string( + userContextId, + `Expected "userContext" to be a string, got ${userContextId}` + ); + + if (userContextId === lazy.UserContextManager.defaultUserContextId) { + throw new lazy.error.InvalidArgumentError( + `Default user context cannot be removed` + ); + } + + if (!lazy.UserContextManager.hasUserContextId(userContextId)) { + throw new lazy.error.NoSuchUserContextError( + `User Context with id ${userContextId} was not found` + ); + } + lazy.UserContextManager.removeUserContext(userContextId, { + closeContextTabs: true, + }); + } +} + +// To export the class as lower-case +export const browser = BrowserModule; diff --git a/remote/webdriver-bidi/modules/root/browsingContext.sys.mjs b/remote/webdriver-bidi/modules/root/browsingContext.sys.mjs new file mode 100644 index 0000000000..bc600e89cd --- /dev/null +++ b/remote/webdriver-bidi/modules/root/browsingContext.sys.mjs @@ -0,0 +1,1964 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + BrowsingContextListener: + "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs", + capture: "chrome://remote/content/shared/Capture.sys.mjs", + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", + getTimeoutMultiplier: "chrome://remote/content/shared/AppInfo.sys.mjs", + modal: "chrome://remote/content/shared/Prompt.sys.mjs", + registerNavigationId: + "chrome://remote/content/shared/NavigationManager.sys.mjs", + NavigationListener: + "chrome://remote/content/shared/listeners/NavigationListener.sys.mjs", + OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", + PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + print: "chrome://remote/content/shared/PDF.sys.mjs", + ProgressListener: "chrome://remote/content/shared/Navigate.sys.mjs", + PromptListener: + "chrome://remote/content/shared/listeners/PromptListener.sys.mjs", + setDefaultAndAssertSerializationOptions: + "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + UserContextManager: + "chrome://remote/content/shared/UserContextManager.sys.mjs", + waitForInitialNavigationCompleted: + "chrome://remote/content/shared/Navigate.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +// Maximal window dimension allowed when emulating a viewport. +const MAX_WINDOW_SIZE = 10000000; + +/** + * @typedef {string} ClipRectangleType + */ + +/** + * Enum of possible clip rectangle types supported by the + * browsingContext.captureScreenshot command. + * + * @readonly + * @enum {ClipRectangleType} + */ +export const ClipRectangleType = { + Box: "box", + Element: "element", +}; + +/** + * @typedef {object} CreateType + */ + +/** + * Enum of types supported by the browsingContext.create command. + * + * @readonly + * @enum {CreateType} + */ +const CreateType = { + tab: "tab", + window: "window", +}; + +/** + * @typedef {string} LocatorType + */ + +/** + * Enum of types supported by the browsingContext.locateNodes command. + * + * @readonly + * @enum {LocatorType} + */ +export const LocatorType = { + css: "css", + innerText: "innerText", + xpath: "xpath", +}; + +/** + * @typedef {string} OriginType + */ + +/** + * Enum of origin type supported by the + * browsingContext.captureScreenshot command. + * + * @readonly + * @enum {OriginType} + */ +export const OriginType = { + document: "document", + viewport: "viewport", +}; + +const TIMEOUT_SET_HISTORY_INDEX = 1000; + +/** + * Enum of user prompt types supported by the browsingContext.handleUserPrompt + * command, these types can be retrieved from `dialog.args.promptType`. + * + * @readonly + * @enum {UserPromptType} + */ +const UserPromptType = { + alert: "alert", + confirm: "confirm", + prompt: "prompt", + beforeunload: "beforeunload", +}; + +/** + * An object that contains details of a viewport. + * + * @typedef {object} Viewport + * + * @property {number} height + * The height of the viewport. + * @property {number} width + * The width of the viewport. + */ + +/** + * @typedef {string} WaitCondition + */ + +/** + * Wait conditions supported by WebDriver BiDi for navigation. + * + * @enum {WaitCondition} + */ +const WaitCondition = { + None: "none", + Interactive: "interactive", + Complete: "complete", +}; + +class BrowsingContextModule extends Module { + #contextListener; + #navigationListener; + #promptListener; + #subscribedEvents; + + /** + * Create a new module instance. + * + * @param {MessageHandler} messageHandler + * The MessageHandler instance which owns this Module instance. + */ + constructor(messageHandler) { + super(messageHandler); + + this.#contextListener = new lazy.BrowsingContextListener(); + this.#contextListener.on("attached", this.#onContextAttached); + this.#contextListener.on("discarded", this.#onContextDiscarded); + + // Create the navigation listener and listen to "navigation-started" and + // "location-changed" events. + this.#navigationListener = new lazy.NavigationListener( + this.messageHandler.navigationManager + ); + this.#navigationListener.on("location-changed", this.#onLocationChanged); + this.#navigationListener.on( + "navigation-started", + this.#onNavigationStarted + ); + + // Create the prompt listener and listen to "closed" and "opened" events. + this.#promptListener = new lazy.PromptListener(); + this.#promptListener.on("closed", this.#onPromptClosed); + this.#promptListener.on("opened", this.#onPromptOpened); + + // Set of event names which have active subscriptions. + this.#subscribedEvents = new Set(); + + // Treat the event of moving a page to BFCache as context discarded event for iframes. + this.messageHandler.on("windowglobal-pagehide", this.#onPageHideEvent); + } + + destroy() { + this.#contextListener.off("attached", this.#onContextAttached); + this.#contextListener.off("discarded", this.#onContextDiscarded); + this.#contextListener.destroy(); + + this.#promptListener.off("closed", this.#onPromptClosed); + this.#promptListener.off("opened", this.#onPromptOpened); + this.#promptListener.destroy(); + + this.#subscribedEvents = null; + + this.messageHandler.off("windowglobal-pagehide", this.#onPageHideEvent); + } + + /** + * Activates and focuses the given top-level browsing context. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + */ + async activate(options = {}) { + const { context: contextId } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + const context = this.#getBrowsingContext(contextId); + + if (context.parent) { + throw new lazy.error.InvalidArgumentError( + `Browsing Context with id ${contextId} is not top-level` + ); + } + + const tab = lazy.TabManager.getTabForBrowsingContext(context); + const window = lazy.TabManager.getWindowForTab(tab); + + await lazy.windowManager.focusWindow(window); + await lazy.TabManager.selectTab(tab); + } + + /** + * Used as an argument for browsingContext.captureScreenshot command, as one of the available variants + * {BoxClipRectangle} or {ElementClipRectangle}, to represent a target of the command. + * + * @typedef ClipRectangle + */ + + /** + * Used as an argument for browsingContext.captureScreenshot command + * to represent a box which is going to be a target of the command. + * + * @typedef BoxClipRectangle + * + * @property {ClipRectangleType} [type=ClipRectangleType.Box] + * @property {number} x + * @property {number} y + * @property {number} width + * @property {number} height + */ + + /** + * Used as an argument for browsingContext.captureScreenshot command + * to represent an element which is going to be a target of the command. + * + * @typedef ElementClipRectangle + * + * @property {ClipRectangleType} [type=ClipRectangleType.Element] + * @property {SharedReference} element + */ + + /** + * Capture a base64-encoded screenshot of the provided browsing context. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context to screenshot. + * @param {ClipRectangle=} options.clip + * A box or an element of which a screenshot should be taken. + * If not present, take a screenshot of the whole viewport. + * @param {OriginType=} options.origin + * + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + */ + async captureScreenshot(options = {}) { + const { + clip = null, + context: contextId, + origin = OriginType.viewport, + } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + const context = this.#getBrowsingContext(contextId); + + const originTypeValues = Object.values(OriginType); + lazy.assert.that( + value => originTypeValues.includes(value), + `Expected "origin" to be one of ${originTypeValues}, got ${origin}` + )(origin); + + if (clip !== null) { + lazy.assert.object(clip, `Expected "clip" to be a object, got ${clip}`); + + const { type } = clip; + switch (type) { + case ClipRectangleType.Box: { + const { x, y, width, height } = clip; + + lazy.assert.number(x, `Expected "x" to be a number, got ${x}`); + lazy.assert.number(y, `Expected "y" to be a number, got ${y}`); + lazy.assert.number( + width, + `Expected "width" to be a number, got ${width}` + ); + lazy.assert.number( + height, + `Expected "height" to be a number, got ${height}` + ); + + break; + } + + case ClipRectangleType.Element: { + const { element } = clip; + + lazy.assert.object( + element, + `Expected "element" to be an object, got ${element}` + ); + + break; + } + + default: + throw new lazy.error.InvalidArgumentError( + `Expected "type" to be one of ${Object.values( + ClipRectangleType + )}, got ${type}` + ); + } + } + + const rect = await this.messageHandler.handleCommand({ + moduleName: "browsingContext", + commandName: "_getScreenshotRect", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + params: { + clip, + origin, + }, + retryOnAbort: true, + }); + + if (rect.width === 0 || rect.height === 0) { + throw new lazy.error.UnableToCaptureScreen( + `The dimensions of requested screenshot are incorrect, got width: ${rect.width} and height: ${rect.height}.` + ); + } + + const canvas = await lazy.capture.canvas( + context.topChromeWindow, + context, + rect.x, + rect.y, + rect.width, + rect.height + ); + + return { + data: lazy.capture.toBase64(canvas), + }; + } + + /** + * Close the provided browsing context. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context to close. + * + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + * @throws {InvalidArgumentError} + * If the browsing context is not a top-level one. + */ + async close(options = {}) { + const { context: contextId } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (!context) { + throw new lazy.error.NoSuchFrameError( + `Browsing Context with id ${contextId} not found` + ); + } + + if (context.parent) { + throw new lazy.error.InvalidArgumentError( + `Browsing Context with id ${contextId} is not top-level` + ); + } + + if (lazy.TabManager.getTabCount() === 1) { + // The behavior when closing the very last tab is currently unspecified. + // As such behave like Marionette and don't allow closing it. + // See: https://github.com/w3c/webdriver-bidi/issues/187 + return; + } + + const tab = lazy.TabManager.getTabForBrowsingContext(context); + await lazy.TabManager.removeTab(tab); + } + + /** + * Create a new browsing context using the provided type "tab" or "window". + * + * @param {object=} options + * @param {boolean=} options.background + * Whether the tab/window should be open in the background. Defaults to false, + * which means that the tab/window will be open in the foreground. + * @param {string=} options.referenceContext + * Id of the top-level browsing context to use as reference. + * If options.type is "tab", the new tab will open in the same window as + * the reference context, and will be added next to the reference context. + * If options.type is "window", the reference context is ignored. + * @param {CreateType} options.type + * Type of browsing context to create. + * @param {string=} options.userContext + * The id of the user context which should own the browsing context. + * Defaults to the default user context. + * + * @throws {InvalidArgumentError} + * If the browsing context is not a top-level one. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + */ + async create(options = {}) { + const { + background = false, + referenceContext: referenceContextId = null, + type: typeHint, + userContext: userContextId = null, + } = options; + + if (![CreateType.tab, CreateType.window].includes(typeHint)) { + throw new lazy.error.InvalidArgumentError( + `Expected "type" to be one of ${Object.values( + CreateType + )}, got ${typeHint}` + ); + } + + lazy.assert.boolean( + background, + lazy.pprint`Expected "background" to be a boolean, got ${background}` + ); + + let referenceContext = null; + if (referenceContextId !== null) { + lazy.assert.string( + referenceContextId, + lazy.pprint`Expected "referenceContext" to be a string, got ${referenceContextId}` + ); + + referenceContext = + lazy.TabManager.getBrowsingContextById(referenceContextId); + if (!referenceContext) { + throw new lazy.error.NoSuchFrameError( + `Browsing Context with id ${referenceContextId} not found` + ); + } + + if (referenceContext.parent) { + throw new lazy.error.InvalidArgumentError( + `referenceContext with id ${referenceContextId} is not a top-level browsing context` + ); + } + } + + let userContext = lazy.UserContextManager.defaultUserContextId; + if (referenceContext !== null) { + userContext = + lazy.UserContextManager.getIdByBrowsingContext(referenceContext); + } + + if (userContextId !== null) { + lazy.assert.string( + userContextId, + lazy.pprint`Expected "userContext" to be a string, got ${userContextId}` + ); + + if (!lazy.UserContextManager.hasUserContextId(userContextId)) { + throw new lazy.error.NoSuchUserContextError( + `User Context with id ${userContextId} was not found` + ); + } + + userContext = userContextId; + + if ( + lazy.AppInfo.isAndroid && + userContext != lazy.UserContextManager.defaultUserContextId + ) { + throw new lazy.error.UnsupportedOperationError( + `browsingContext.create with non-default "userContext" not supported for ${lazy.AppInfo.name}` + ); + } + } + + let browser; + + // Since each tab in GeckoView has its own Gecko instance running, + // which means also its own window object, for Android we will need to focus + // a previously focused window in case of opening the tab in the background. + const previousWindow = Services.wm.getMostRecentBrowserWindow(); + const previousTab = + lazy.TabManager.getTabBrowser(previousWindow).selectedTab; + + // On Android there is only a single window allowed. As such fallback to + // open a new tab instead. + const type = lazy.AppInfo.isAndroid ? "tab" : typeHint; + + switch (type) { + case "window": + const newWindow = await lazy.windowManager.openBrowserWindow({ + focus: !background, + userContextId: userContext, + }); + browser = lazy.TabManager.getTabBrowser(newWindow).selectedBrowser; + break; + + case "tab": + if (!lazy.TabManager.supportsTabs()) { + throw new lazy.error.UnsupportedOperationError( + `browsingContext.create with type "tab" not supported in ${lazy.AppInfo.name}` + ); + } + + let referenceTab; + if (referenceContext !== null) { + referenceTab = + lazy.TabManager.getTabForBrowsingContext(referenceContext); + } + + const tab = await lazy.TabManager.addTab({ + focus: !background, + referenceTab, + userContextId: userContext, + }); + browser = lazy.TabManager.getBrowserForTab(tab); + } + + await lazy.waitForInitialNavigationCompleted( + browser.browsingContext.webProgress, + { + unloadTimeout: 5000, + } + ); + + // The tab on Android is always opened in the foreground, + // so we need to select the previous tab, + // and we have to wait until is fully loaded. + // TODO: Bug 1845559. This workaround can be removed, + // when the API to create a tab for Android supports the background option. + if (lazy.AppInfo.isAndroid && background) { + await lazy.windowManager.focusWindow(previousWindow); + await lazy.TabManager.selectTab(previousTab); + } + + // Force a reflow by accessing `clientHeight` (see Bug 1847044). + browser.parentElement.clientHeight; + + return { + context: lazy.TabManager.getIdForBrowser(browser), + }; + } + + /** + * An object that holds the WebDriver Bidi browsing context information. + * + * @typedef BrowsingContextInfo + * + * @property {string} context + * The id of the browsing context. + * @property {string=} parent + * The parent of the browsing context if it's the root browsing context + * of the to be processed browsing context tree. + * @property {string} url + * The current documents location. + * @property {string} userContext + * The id of the user context owning this browsing context. + * @property {Array<BrowsingContextInfo>=} children + * List of child browsing contexts. Only set if maxDepth hasn't been + * reached yet. + */ + + /** + * An object that holds the WebDriver Bidi browsing context tree information. + * + * @typedef BrowsingContextGetTreeResult + * + * @property {Array<BrowsingContextInfo>} contexts + * List of child browsing contexts. + */ + + /** + * Returns a tree of all browsing contexts that are descendents of the + * given context, or all top-level contexts when no root is provided. + * + * @param {object=} options + * @param {number=} options.maxDepth + * Depth of the browsing context tree to traverse. If not specified + * the whole tree is returned. + * @param {string=} options.root + * Id of the root browsing context. + * + * @returns {BrowsingContextGetTreeResult} + * Tree of browsing context information. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + */ + getTree(options = {}) { + const { maxDepth = null, root: rootId = null } = options; + + if (maxDepth !== null) { + lazy.assert.positiveInteger( + maxDepth, + `Expected "maxDepth" to be a positive integer, got ${maxDepth}` + ); + } + + let contexts; + if (rootId !== null) { + // With a root id specified return the context info for itself + // and the full tree. + lazy.assert.string( + rootId, + `Expected "root" to be a string, got ${rootId}` + ); + contexts = [this.#getBrowsingContext(rootId)]; + } else { + // Return all top-level browsing contexts. + contexts = lazy.TabManager.browsers.map( + browser => browser.browsingContext + ); + } + + const contextsInfo = contexts.map(context => { + return this.#getBrowsingContextInfo(context, { maxDepth }); + }); + + return { contexts: contextsInfo }; + } + + /** + * Closes an open prompt. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context. + * @param {boolean=} options.accept + * Whether user prompt should be accepted or dismissed. + * Defaults to true. + * @param {string=} options.userText + * Input to the user prompt's value field. + * Defaults to an empty string. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchAlertError} + * If there is no current user prompt. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + * @throws {UnsupportedOperationError} + * Raised when the command is called for "beforeunload" prompt. + */ + async handleUserPrompt(options = {}) { + const { accept = true, context: contextId, userText = "" } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + const context = this.#getBrowsingContext(contextId); + + lazy.assert.boolean( + accept, + `Expected "accept" to be a boolean, got ${accept}` + ); + + lazy.assert.string( + userText, + `Expected "userText" to be a string, got ${userText}` + ); + + const tab = lazy.TabManager.getTabForBrowsingContext(context); + const browser = lazy.TabManager.getBrowserForTab(tab); + const window = lazy.TabManager.getWindowForTab(tab); + const dialog = lazy.modal.findPrompt({ + window, + contentBrowser: browser, + }); + + const closePrompt = async callback => { + const dialogClosed = new lazy.EventPromise( + window, + "DOMModalDialogClosed" + ); + callback(); + await dialogClosed; + }; + + if (dialog && dialog.isOpen) { + switch (dialog.promptType) { + case UserPromptType.alert: { + await closePrompt(() => dialog.accept()); + return; + } + case UserPromptType.confirm: { + await closePrompt(() => { + if (accept) { + dialog.accept(); + } else { + dialog.dismiss(); + } + }); + + return; + } + case UserPromptType.prompt: { + await closePrompt(() => { + if (accept) { + dialog.text = userText; + dialog.accept(); + } else { + dialog.dismiss(); + } + }); + + return; + } + case UserPromptType.beforeunload: { + // TODO: Bug 1824220. Implement support for "beforeunload" prompts. + throw new lazy.error.UnsupportedOperationError( + '"beforeunload" prompts are not supported yet.' + ); + } + } + } + + throw new lazy.error.NoSuchAlertError(); + } + + /** + * Used as an argument for browsingContext.locateNodes command, as one of the available variants + * {CssLocator}, {InnerTextLocator} or {XPathLocator}, to represent a way of how lookup of nodes + * is going to be performed. + * + * @typedef Locator + */ + + /** + * Used as an argument for browsingContext.locateNodes command + * to represent a lookup by css selector. + * + * @typedef CssLocator + * + * @property {LocatorType} [type=LocatorType.css] + * @property {string} value + */ + + /** + * Used as an argument for browsingContext.locateNodes command + * to represent a lookup by inner text. + * + * @typedef InnerTextLocator + * + * @property {LocatorType} [type=LocatorType.innerText] + * @property {string} value + * @property {boolean=} ignoreCase + * @property {("full"|"partial")=} matchType + * @property {number=} maxDepth + */ + + /** + * Used as an argument for browsingContext.locateNodes command + * to represent a lookup by xpath. + * + * @typedef XPathLocator + * + * @property {LocatorType} [type=LocatorType.xpath] + * @property {string} value + */ + + /** + * Returns a list of all nodes matching + * the specified locator. + * + * @param {object} options + * @param {string} options.context + * Id of the browsing context. + * @param {Locator} options.locator + * The type of lookup which is going to be used. + * @param {number=} options.maxNodeCount + * The maximum amount of nodes which is going to be returned. + * Defaults to return all the found nodes. + * @param {OwnershipModel=} options.ownership + * The ownership model to use for the serialization + * of the DOM nodes. Defaults to `OwnershipModel.None`. + * @property {string=} sandbox + * The name of the sandbox. If the value is null or empty + * string, the default realm will be used. + * @property {SerializationOptions=} serializationOptions + * An object which holds the information of how the DOM nodes + * should be serialized. + * @property {Array<SharedReference>=} startNodes + * A list of references to nodes, which are used as + * starting points for lookup. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {InvalidSelectorError} + * Raised if a locator value is invalid. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + * @throws {UnsupportedOperationError} + * Raised when unsupported lookup types are used. + */ + async locateNodes(options = {}) { + const { + context: contextId, + locator, + maxNodeCount = null, + ownership = lazy.OwnershipModel.None, + sandbox = null, + serializationOptions, + startNodes = null, + } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + const context = this.#getBrowsingContext(contextId); + + lazy.assert.object( + locator, + `Expected "locator" to be an object, got ${locator}` + ); + + const locatorTypes = Object.values(LocatorType); + + lazy.assert.that( + locatorType => locatorTypes.includes(locatorType), + `Expected "locator.type" to be one of ${locatorTypes}, got ${locator.type}` + )(locator.type); + + if (![LocatorType.css, LocatorType.xpath].includes(locator.type)) { + throw new lazy.error.UnsupportedOperationError( + `"locator.type" argument with value: ${locator.type} is not supported yet.` + ); + } + + if (maxNodeCount != null) { + const maxNodeCountErrorMsg = `Expected "maxNodeCount" to be an integer and greater than 0, got ${maxNodeCount}`; + lazy.assert.that(maxNodeCount => { + lazy.assert.integer(maxNodeCount, maxNodeCountErrorMsg); + return maxNodeCount > 0; + }, maxNodeCountErrorMsg)(maxNodeCount); + } + + const ownershipTypes = Object.values(lazy.OwnershipModel); + lazy.assert.that( + ownership => ownershipTypes.includes(ownership), + `Expected "ownership" to be one of ${ownershipTypes}, got ${ownership}` + )(ownership); + + if (sandbox != null) { + lazy.assert.string( + sandbox, + `Expected "sandbox" to be a string, got ${sandbox}` + ); + } + + const serializationOptionsWithDefaults = + lazy.setDefaultAndAssertSerializationOptions(serializationOptions); + + if (startNodes != null) { + lazy.assert.that(startNodes => { + lazy.assert.array( + startNodes, + `Expected "startNodes" to be an array, got ${startNodes}` + ); + return !!startNodes.length; + }, `Expected "startNodes" to have at least one element, got ${startNodes}`)( + startNodes + ); + } + + const result = await this.messageHandler.forwardCommand({ + moduleName: "browsingContext", + commandName: "_locateNodes", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + params: { + locator, + maxNodeCount, + resultOwnership: ownership, + sandbox, + serializationOptions: serializationOptionsWithDefaults, + startNodes, + }, + }); + + return { + nodes: result.serializedNodes, + }; + } + + /** + * An object that holds the WebDriver Bidi navigation information. + * + * @typedef BrowsingContextNavigateResult + * + * @property {string} navigation + * Unique id for this navigation. + * @property {string} url + * The requested or reached URL. + */ + + /** + * Navigate the given context to the provided url, with the provided wait condition. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context to navigate. + * @param {string} options.url + * Url for the navigation. + * @param {WaitCondition=} options.wait + * Wait condition for the navigation, one of "none", "interactive", "complete". + * + * @returns {BrowsingContextNavigateResult} + * Navigation result. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchFrameError} + * If the browsing context for context cannot be found. + */ + async navigate(options = {}) { + const { context: contextId, url, wait = WaitCondition.None } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + lazy.assert.string(url, `Expected "url" to be string, got ${url}`); + + const waitConditions = Object.values(WaitCondition); + if (!waitConditions.includes(wait)) { + throw new lazy.error.InvalidArgumentError( + `Expected "wait" to be one of ${waitConditions}, got ${wait}` + ); + } + + const context = this.#getBrowsingContext(contextId); + + // webProgress will be stable even if the context navigates, retrieve it + // immediately before doing any asynchronous call. + const webProgress = context.webProgress; + + const base = await this.messageHandler.handleCommand({ + moduleName: "browsingContext", + commandName: "_getBaseURL", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + retryOnAbort: true, + }); + + let targetURI; + try { + const baseURI = Services.io.newURI(base); + targetURI = Services.io.newURI(url, null, baseURI); + } catch (e) { + throw new lazy.error.InvalidArgumentError( + `Expected "url" to be a valid URL (${e.message})` + ); + } + + return this.#awaitNavigation( + webProgress, + () => { + context.loadURI(targetURI, { + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + hasValidUserGestureActivation: true, + }); + }, + { + wait, + } + ); + } + + /** + * An object that holds the information about margins + * for Webdriver BiDi browsingContext.print command. + * + * @typedef BrowsingContextPrintMarginParameters + * + * @property {number=} bottom + * Bottom margin in cm. Defaults to 1cm (~0.4 inches). + * @property {number=} left + * Left margin in cm. Defaults to 1cm (~0.4 inches). + * @property {number=} right + * Right margin in cm. Defaults to 1cm (~0.4 inches). + * @property {number=} top + * Top margin in cm. Defaults to 1cm (~0.4 inches). + */ + + /** + * An object that holds the information about paper size + * for Webdriver BiDi browsingContext.print command. + * + * @typedef BrowsingContextPrintPageParameters + * + * @property {number=} height + * Paper height in cm. Defaults to US letter height (27.94cm / 11 inches). + * @property {number=} width + * Paper width in cm. Defaults to US letter width (21.59cm / 8.5 inches). + */ + + /** + * Used as return value for Webdriver BiDi browsingContext.print command. + * + * @typedef BrowsingContextPrintResult + * + * @property {string} data + * Base64 encoded PDF representing printed document. + */ + + /** + * Creates a paginated PDF representation of a document + * of the provided browsing context, and returns it + * as a Base64-encoded string. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context. + * @param {boolean=} options.background + * Whether or not to print background colors and images. + * Defaults to false, which prints without background graphics. + * @param {BrowsingContextPrintMarginParameters=} options.margin + * Paper margins. + * @param {('landscape'|'portrait')=} options.orientation + * Paper orientation. Defaults to 'portrait'. + * @param {BrowsingContextPrintPageParameters=} options.page + * Paper size. + * @param {Array<number|string>=} options.pageRanges + * Paper ranges to print, e.g., ['1-5', 8, '11-13']. + * Defaults to the empty array, which means print all pages. + * @param {number=} options.scale + * Scale of the webpage rendering. Defaults to 1.0. + * @param {boolean=} options.shrinkToFit + * Whether or not to override page size as defined by CSS. + * Defaults to true, in which case the content will be scaled + * to fit the paper size. + * + * @returns {BrowsingContextPrintResult} + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + */ + async print(options = {}) { + const { + context: contextId, + background, + margin, + orientation, + page, + pageRanges, + scale, + shrinkToFit, + } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + const context = this.#getBrowsingContext(contextId); + + const settings = lazy.print.addDefaultSettings({ + background, + margin, + orientation, + page, + pageRanges, + scale, + shrinkToFit, + }); + + for (const prop of ["top", "bottom", "left", "right"]) { + lazy.assert.positiveNumber( + settings.margin[prop], + lazy.pprint`margin.${prop} is not a positive number` + ); + } + for (const prop of ["width", "height"]) { + lazy.assert.positiveNumber( + settings.page[prop], + lazy.pprint`page.${prop} is not a positive number` + ); + } + lazy.assert.positiveNumber( + settings.scale, + `scale ${settings.scale} is not a positive number` + ); + lazy.assert.that( + scale => + scale >= lazy.print.minScaleValue && scale <= lazy.print.maxScaleValue, + `scale ${settings.scale} is outside the range ${lazy.print.minScaleValue}-${lazy.print.maxScaleValue}` + )(settings.scale); + lazy.assert.boolean(settings.shrinkToFit); + lazy.assert.that( + orientation => lazy.print.defaults.orientationValue.includes(orientation), + `orientation ${ + settings.orientation + } doesn't match allowed values "${lazy.print.defaults.orientationValue.join( + "/" + )}"` + )(settings.orientation); + lazy.assert.boolean( + settings.background, + `background ${settings.background} is not boolean` + ); + lazy.assert.array(settings.pageRanges); + + const printSettings = await lazy.print.getPrintSettings(settings); + const binaryString = await lazy.print.printToBinaryString( + context, + printSettings + ); + + return { + data: btoa(binaryString), + }; + } + + /** + * Reload the given context's document, with the provided wait condition. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context to navigate. + * @param {bool=} options.ignoreCache + * If true ignore the browser cache. [Not yet supported] + * @param {WaitCondition=} options.wait + * Wait condition for the navigation, one of "none", "interactive", "complete". + * + * @returns {BrowsingContextNavigateResult} + * Navigation result. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchFrameError} + * If the browsing context for context cannot be found. + */ + async reload(options = {}) { + const { + context: contextId, + ignoreCache, + wait = WaitCondition.None, + } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + if (typeof ignoreCache != "undefined") { + throw new lazy.error.UnsupportedOperationError( + `Argument "ignoreCache" is not supported yet.` + ); + } + + const waitConditions = Object.values(WaitCondition); + if (!waitConditions.includes(wait)) { + throw new lazy.error.InvalidArgumentError( + `Expected "wait" to be one of ${waitConditions}, got ${wait}` + ); + } + + const context = this.#getBrowsingContext(contextId); + + // webProgress will be stable even if the context navigates, retrieve it + // immediately before doing any asynchronous call. + const webProgress = context.webProgress; + + return this.#awaitNavigation( + webProgress, + () => { + context.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE); + }, + { wait } + ); + } + + /** + * Set the top-level browsing context's viewport to a given dimension. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context. + * @param {Viewport|null} options.viewport + * Dimensions to set the viewport to, or `null` to reset it + * to the original dimensions. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {UnsupportedOperationError} + * Raised when the command is called on Android. + */ + async setViewport(options = {}) { + const { context: contextId, viewport } = options; + + if (lazy.AppInfo.isAndroid) { + // Bug 1840084: Add Android support for modifying the viewport. + throw new lazy.error.UnsupportedOperationError( + `Command not yet supported for ${lazy.AppInfo.name}` + ); + } + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + const context = this.#getBrowsingContext(contextId); + if (context.parent) { + throw new lazy.error.InvalidArgumentError( + `Browsing Context with id ${contextId} is not top-level` + ); + } + + const browser = context.embedderElement; + const currentHeight = browser.clientHeight; + const currentWidth = browser.clientWidth; + + let targetHeight, targetWidth; + if (viewport === undefined) { + // Don't modify the viewport's size. + targetHeight = currentHeight; + targetWidth = currentWidth; + } else if (viewport === null) { + // Reset viewport to the original dimensions. + targetHeight = browser.parentElement.clientHeight; + targetWidth = browser.parentElement.clientWidth; + + browser.style.removeProperty("height"); + browser.style.removeProperty("width"); + } else { + lazy.assert.object( + viewport, + `Expected "viewport" to be an object, got ${viewport}` + ); + + const { height, width } = viewport; + targetHeight = lazy.assert.positiveInteger( + height, + `Expected viewport's "height" to be a positive integer, got ${height}` + ); + targetWidth = lazy.assert.positiveInteger( + width, + `Expected viewport's "width" to be a positive integer, got ${width}` + ); + + if (targetHeight > MAX_WINDOW_SIZE || targetWidth > MAX_WINDOW_SIZE) { + throw new lazy.error.UnsupportedOperationError( + `"width" or "height" cannot be larger than ${MAX_WINDOW_SIZE} px` + ); + } + + browser.style.setProperty("height", targetHeight + "px"); + browser.style.setProperty("width", targetWidth + "px"); + } + + if (targetHeight !== currentHeight || targetWidth !== currentWidth) { + // Wait until the viewport has been resized + await this.messageHandler.forwardCommand({ + moduleName: "browsingContext", + commandName: "_awaitViewportDimensions", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + params: { + height: targetHeight, + width: targetWidth, + }, + }); + } + } + + /** + * Traverses the history of a given context by a given delta. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context. + * @param {number} options.delta + * The number of steps we have to traverse. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchFrameException} + * When a context is not available. + * @throws {NoSuchHistoryEntryError} + * When a requested history entry does not exist. + */ + async traverseHistory(options = {}) { + const { context: contextId, delta } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + const context = this.#getBrowsingContext(contextId); + + lazy.assert.integer( + delta, + `Expected "delta" to be an integer, got ${delta}` + ); + + const sessionHistory = context.sessionHistory; + const allSteps = sessionHistory.count; + const currentIndex = sessionHistory.index; + const targetIndex = currentIndex + delta; + const validEntry = targetIndex >= 0 && targetIndex < allSteps; + + if (!validEntry) { + throw new lazy.error.NoSuchHistoryEntryError( + `History entry with delta ${delta} not found` + ); + } + + context.goToIndex(targetIndex); + + // On some platforms the requested index isn't set immediately. + await lazy.PollPromise( + (resolve, reject) => { + if (sessionHistory.index == targetIndex) { + resolve(); + } else { + reject(); + } + }, + { + errorMessage: `History was not updated for index "${targetIndex}"`, + timeout: TIMEOUT_SET_HISTORY_INDEX * lazy.getTimeoutMultiplier(), + } + ); + } + + /** + * Start and await a navigation on the provided BrowsingContext. Returns a + * promise which resolves when the navigation is done according to the provided + * navigation strategy. + * + * @param {WebProgress} webProgress + * The WebProgress instance to observe for this navigation. + * @param {Function} startNavigationFn + * A callback that starts a navigation. + * @param {object} options + * @param {WaitCondition} options.wait + * The WaitCondition to use to wait for the navigation. + * + * @returns {Promise<BrowsingContextNavigateResult>} + * A Promise that resolves to navigate results when the navigation is done. + */ + async #awaitNavigation(webProgress, startNavigationFn, options) { + const { wait } = options; + + const context = webProgress.browsingContext; + const browserId = context.browserId; + + const resolveWhenStarted = wait === WaitCondition.None; + const listener = new lazy.ProgressListener(webProgress, { + expectNavigation: true, + resolveWhenStarted, + // In case the webprogress is already navigating, always wait for an + // explicit start flag. + waitForExplicitStart: true, + }); + + const onDocumentInteractive = (evtName, wrappedEvt) => { + if (webProgress.browsingContext.id !== wrappedEvt.contextId) { + // Ignore load events for unrelated browsing contexts. + return; + } + + if (wrappedEvt.readyState === "interactive") { + listener.stopIfStarted(); + } + }; + + const contextDescriptor = { + type: lazy.ContextDescriptorType.TopBrowsingContext, + id: browserId, + }; + + // For the Interactive wait condition, resolve as soon as + // the document becomes interactive. + if (wait === WaitCondition.Interactive) { + await this.messageHandler.eventsDispatcher.on( + "browsingContext._documentInteractive", + contextDescriptor, + onDocumentInteractive + ); + } + + // If WaitCondition is Complete, we should try to wait for the corresponding + // responseCompleted event to be received. + let onNavigationRequestCompleted; + + // However, a navigation will not necessarily have network events. + // For instance: same document navigation, or when using file or data + // protocols (for which we don't have network events yet). + // Therefore we will not unconditionally wait for a navigation request and + // this flag should only be set when a responseCompleted event should be + // expected. + let shouldWaitForNavigationRequest = false; + + // Cleaning up the listeners will be done at the end of this method. + let unsubscribeNavigationListeners; + + if (wait === WaitCondition.Complete) { + let resolveOnNetworkEvent; + onNavigationRequestCompleted = new Promise( + r => (resolveOnNetworkEvent = r) + ); + const onBeforeRequestSent = (name, data) => { + if (data.navigation) { + shouldWaitForNavigationRequest = true; + } + }; + const onNetworkRequestCompleted = (name, data) => { + if (data.navigation) { + resolveOnNetworkEvent(); + } + }; + + // The network request can either end with _responseCompleted or _fetchError + await this.messageHandler.eventsDispatcher.on( + "network._beforeRequestSent", + contextDescriptor, + onBeforeRequestSent + ); + await this.messageHandler.eventsDispatcher.on( + "network._responseCompleted", + contextDescriptor, + onNetworkRequestCompleted + ); + await this.messageHandler.eventsDispatcher.on( + "network._fetchError", + contextDescriptor, + onNetworkRequestCompleted + ); + + unsubscribeNavigationListeners = async () => { + await this.messageHandler.eventsDispatcher.off( + "network._beforeRequestSent", + contextDescriptor, + onBeforeRequestSent + ); + await this.messageHandler.eventsDispatcher.off( + "network._responseCompleted", + contextDescriptor, + onNetworkRequestCompleted + ); + await this.messageHandler.eventsDispatcher.off( + "network._fetchError", + contextDescriptor, + onNetworkRequestCompleted + ); + }; + } + + const navigated = listener.start(); + + try { + const navigationId = lazy.registerNavigationId({ + contextDetails: { context: webProgress.browsingContext }, + }); + + await startNavigationFn(); + await navigated; + + if (shouldWaitForNavigationRequest) { + await onNavigationRequestCompleted; + } + + let url; + if (wait === WaitCondition.None) { + // If wait condition is None, the navigation resolved before the current + // context has navigated. + url = listener.targetURI.spec; + } else { + url = listener.currentURI.spec; + } + + return { + navigation: navigationId, + url, + }; + } finally { + if (listener.isStarted) { + listener.stop(); + } + + if (wait === WaitCondition.Interactive) { + await this.messageHandler.eventsDispatcher.off( + "browsingContext._documentInteractive", + contextDescriptor, + onDocumentInteractive + ); + } else if ( + wait === WaitCondition.Complete && + shouldWaitForNavigationRequest + ) { + await unsubscribeNavigationListeners(); + } + } + } + + /** + * Retrieves a browsing context based on its id. + * + * @param {number} contextId + * Id of the browsing context. + * @returns {BrowsingContext=} + * The browsing context or null if <var>contextId</var> is null. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + */ + #getBrowsingContext(contextId) { + // The WebDriver BiDi specification expects null to be + // returned if no browsing context id has been specified. + if (contextId === null) { + return null; + } + + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (context === null) { + throw new lazy.error.NoSuchFrameError( + `Browsing Context with id ${contextId} not found` + ); + } + + return context; + } + + /** + * Get the WebDriver BiDi browsing context information. + * + * @param {BrowsingContext} context + * The browsing context to get the information from. + * @param {object=} options + * @param {boolean=} options.isRoot + * Flag that indicates if this browsing context is the root of all the + * browsing contexts to be returned. Defaults to true. + * @param {number=} options.maxDepth + * Depth of the browsing context tree to traverse. If not specified + * the whole tree is returned. + * @returns {BrowsingContextInfo} + * The information about the browsing context. + */ + #getBrowsingContextInfo(context, options = {}) { + const { isRoot = true, maxDepth = null } = options; + + let children = null; + if (maxDepth === null || maxDepth > 0) { + children = context.children.map(context => + this.#getBrowsingContextInfo(context, { + maxDepth: maxDepth === null ? maxDepth : maxDepth - 1, + isRoot: false, + }) + ); + } + + const userContext = lazy.UserContextManager.getIdByBrowsingContext(context); + const contextInfo = { + children, + context: lazy.TabManager.getIdForBrowsingContext(context), + url: context.currentURI.spec, + userContext, + }; + + if (isRoot) { + // Only emit the parent id for the top-most browsing context. + const parentId = lazy.TabManager.getIdForBrowsingContext(context.parent); + contextInfo.parent = parentId; + } + + return contextInfo; + } + + #onContextAttached = async (eventName, data = {}) => { + if (this.#subscribedEvents.has("browsingContext.contextCreated")) { + const { browsingContext, why } = data; + + // Filter out top-level browsing contexts that are created because of a + // cross-group navigation. + if (why === "replace") { + return; + } + + // TODO: Bug 1852941. We should also filter out events which are emitted + // for DevTools frames. + + // Filter out notifications for chrome context until support gets + // added (bug 1722679). + if (!browsingContext.webProgress) { + return; + } + + const browsingContextInfo = this.#getBrowsingContextInfo( + browsingContext, + { + maxDepth: 0, + } + ); + + // This event is emitted from the parent process but for a given browsing + // context. Set the event's contextInfo to the message handler corresponding + // to this browsing context. + const contextInfo = { + contextId: browsingContext.id, + type: lazy.WindowGlobalMessageHandler.type, + }; + this.emitEvent( + "browsingContext.contextCreated", + browsingContextInfo, + contextInfo + ); + } + }; + + #onContextDiscarded = async (eventName, data = {}) => { + if (this.#subscribedEvents.has("browsingContext.contextDestroyed")) { + const { browsingContext, why } = data; + + // Filter out top-level browsing contexts that are destroyed because of a + // cross-group navigation. + if (why === "replace") { + return; + } + + // TODO: Bug 1852941. We should also filter out events which are emitted + // for DevTools frames. + + // Filter out notifications for chrome context until support gets + // added (bug 1722679). + if (!browsingContext.webProgress) { + return; + } + + // If this event is for a child context whose top or parent context is also destroyed, + // we don't need to send it, in this case the event for the top/parent context is enough. + if ( + browsingContext.parent && + (browsingContext.top.isDiscarded || browsingContext.parent.isDiscarded) + ) { + return; + } + + const browsingContextInfo = this.#getBrowsingContextInfo( + browsingContext, + { + maxDepth: 0, + } + ); + + // This event is emitted from the parent process but for a given browsing + // context. Set the event's contextInfo to the message handler corresponding + // to this browsing context. + const contextInfo = { + contextId: browsingContext.id, + type: lazy.WindowGlobalMessageHandler.type, + }; + this.emitEvent( + "browsingContext.contextDestroyed", + browsingContextInfo, + contextInfo + ); + } + }; + + #onLocationChanged = async (eventName, data) => { + const { navigationId, navigableId, url } = data; + const context = this.#getBrowsingContext(navigableId); + + if (this.#subscribedEvents.has("browsingContext.fragmentNavigated")) { + const contextInfo = { + contextId: context.id, + type: lazy.WindowGlobalMessageHandler.type, + }; + this.emitEvent( + "browsingContext.fragmentNavigated", + { + context: navigableId, + navigation: navigationId, + timestamp: Date.now(), + url, + }, + contextInfo + ); + } + }; + + #onPromptClosed = async (eventName, data) => { + if (this.#subscribedEvents.has("browsingContext.userPromptClosed")) { + const { contentBrowser, detail } = data; + const contextId = lazy.TabManager.getIdForBrowser(contentBrowser); + + if (contextId === null) { + return; + } + + // This event is emitted from the parent process but for a given browsing + // context. Set the event's contextInfo to the message handler corresponding + // to this browsing context. + const contextInfo = { + contextId, + type: lazy.WindowGlobalMessageHandler.type, + }; + + const params = { + context: contextId, + ...detail, + }; + + this.emitEvent("browsingContext.userPromptClosed", params, contextInfo); + } + }; + + #onPromptOpened = async (eventName, data) => { + if (this.#subscribedEvents.has("browsingContext.userPromptOpened")) { + const { contentBrowser, prompt } = data; + + // Do not send opened event for unsupported prompt types. + if (!(prompt.promptType in UserPromptType)) { + return; + } + + const contextId = lazy.TabManager.getIdForBrowser(contentBrowser); + // This event is emitted from the parent process but for a given browsing + // context. Set the event's contextInfo to the message handler corresponding + // to this browsing context. + const contextInfo = { + contextId, + type: lazy.WindowGlobalMessageHandler.type, + }; + + const eventPayload = { + context: contextId, + type: prompt.promptType, + message: await prompt.getText(), + }; + + // Bug 1859814: Since the platform doesn't provide the access to the `defaultValue` of the prompt, + // we use prompt the `value` instead. The `value` is set to `defaultValue` when `defaultValue` is provided. + // This approach doesn't allow us to distinguish between the `defaultValue` being set to an empty string and + // `defaultValue` not set, because `value` is always defaulted to an empty string. + // We should switch to using the actual `defaultValue` when it's available and check for the `null` here. + const defaultValue = await prompt.getInputText(); + if (defaultValue) { + eventPayload.defaultValue = defaultValue; + } + + this.emitEvent( + "browsingContext.userPromptOpened", + eventPayload, + contextInfo + ); + } + }; + + #onNavigationStarted = async (eventName, data) => { + const { navigableId, navigationId, url } = data; + const context = this.#getBrowsingContext(navigableId); + + if (this.#subscribedEvents.has("browsingContext.navigationStarted")) { + const contextInfo = { + contextId: context.id, + type: lazy.WindowGlobalMessageHandler.type, + }; + + this.emitEvent( + "browsingContext.navigationStarted", + { + context: navigableId, + navigation: navigationId, + timestamp: Date.now(), + url, + }, + contextInfo + ); + } + }; + + #onPageHideEvent = (name, eventPayload) => { + const { context } = eventPayload; + if (context.parent) { + this.#onContextDiscarded("windowglobal-pagehide", { + browsingContext: context, + }); + } + }; + + #stopListeningToContextEvent(event) { + this.#subscribedEvents.delete(event); + + const hasContextEvent = + this.#subscribedEvents.has("browsingContext.contextCreated") || + this.#subscribedEvents.has("browsingContext.contextDestroyed"); + + if (!hasContextEvent) { + this.#contextListener.stopListening(); + } + } + + #stopListeningToNavigationEvent(event) { + this.#subscribedEvents.delete(event); + + const hasNavigationEvent = + this.#subscribedEvents.has("browsingContext.fragmentNavigated") || + this.#subscribedEvents.has("browsingContext.navigationStarted"); + + if (!hasNavigationEvent) { + this.#navigationListener.stopListening(); + } + } + + #stopListeningToPromptEvent(event) { + this.#subscribedEvents.delete(event); + + const hasPromptEvent = + this.#subscribedEvents.has("browsingContext.userPromptClosed") || + this.#subscribedEvents.has("browsingContext.userPromptOpened"); + + if (!hasPromptEvent) { + this.#promptListener.stopListening(); + } + } + + #subscribeEvent(event) { + switch (event) { + case "browsingContext.contextCreated": + case "browsingContext.contextDestroyed": { + this.#contextListener.startListening(); + this.#subscribedEvents.add(event); + break; + } + case "browsingContext.fragmentNavigated": + case "browsingContext.navigationStarted": { + this.#navigationListener.startListening(); + this.#subscribedEvents.add(event); + break; + } + case "browsingContext.userPromptClosed": + case "browsingContext.userPromptOpened": { + this.#promptListener.startListening(); + this.#subscribedEvents.add(event); + break; + } + } + } + + #unsubscribeEvent(event) { + switch (event) { + case "browsingContext.contextCreated": + case "browsingContext.contextDestroyed": { + this.#stopListeningToContextEvent(event); + break; + } + case "browsingContext.fragmentNavigated": + case "browsingContext.navigationStarted": { + this.#stopListeningToNavigationEvent(event); + break; + } + case "browsingContext.userPromptClosed": + case "browsingContext.userPromptOpened": { + this.#stopListeningToPromptEvent(event); + break; + } + } + } + + /** + * Internal commands + */ + + _applySessionData(params) { + // TODO: Bug 1775231. Move this logic to a shared module or an abstract + // class. + const { category } = params; + if (category === "event") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#unsubscribeEvent(event); + } + } + + // Subscribe to all events, which have an item in SessionData. + for (const { value } of filteredSessionData) { + this.#subscribeEvent(value); + } + } + } + + static get supportedEvents() { + return [ + "browsingContext.contextCreated", + "browsingContext.contextDestroyed", + "browsingContext.domContentLoaded", + "browsingContext.fragmentNavigated", + "browsingContext.load", + "browsingContext.navigationStarted", + "browsingContext.userPromptClosed", + "browsingContext.userPromptOpened", + ]; + } +} + +export const browsingContext = BrowsingContextModule; diff --git a/remote/webdriver-bidi/modules/root/input.sys.mjs b/remote/webdriver-bidi/modules/root/input.sys.mjs new file mode 100644 index 0000000000..8edd8299b7 --- /dev/null +++ b/remote/webdriver-bidi/modules/root/input.sys.mjs @@ -0,0 +1,99 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +class InputModule extends Module { + destroy() {} + + async performActions(options = {}) { + const { actions, context: contextId } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (!context) { + throw new lazy.error.NoSuchFrameError( + `Browsing context with id ${contextId} not found` + ); + } + + // Bug 1821460: Fetch top-level browsing context. + + await this.messageHandler.forwardCommand({ + moduleName: "input", + commandName: "performActions", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + params: { + actions, + }, + }); + + return {}; + } + + /** + * Reset the input state in the provided browsing context. + * + * @param {object=} options + * @param {string} options.context + * Id of the browsing context to reset the input state. + * + * @throws {InvalidArgumentError} + * If <var>context</var> is not valid type. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + */ + async releaseActions(options = {}) { + const { context: contextId } = options; + + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (!context) { + throw new lazy.error.NoSuchFrameError( + `Browsing context with id ${contextId} not found` + ); + } + + // Bug 1821460: Fetch top-level browsing context. + + await this.messageHandler.forwardCommand({ + moduleName: "input", + commandName: "releaseActions", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + params: {}, + }); + + return {}; + } + + static get supportedEvents() { + return []; + } +} + +export const input = InputModule; diff --git a/remote/webdriver-bidi/modules/root/log.sys.mjs b/remote/webdriver-bidi/modules/root/log.sys.mjs new file mode 100644 index 0000000000..db2390d3ba --- /dev/null +++ b/remote/webdriver-bidi/modules/root/log.sys.mjs @@ -0,0 +1,15 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class LogModule extends Module { + destroy() {} + + static get supportedEvents() { + return ["log.entryAdded"]; + } +} + +export const log = LogModule; diff --git a/remote/webdriver-bidi/modules/root/network.sys.mjs b/remote/webdriver-bidi/modules/root/network.sys.mjs new file mode 100644 index 0000000000..238b9f3640 --- /dev/null +++ b/remote/webdriver-bidi/modules/root/network.sys.mjs @@ -0,0 +1,1730 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + matchURLPattern: + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs", + notifyNavigationStarted: + "chrome://remote/content/shared/NavigationManager.sys.mjs", + NetworkListener: + "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs", + parseChallengeHeader: + "chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs", + parseURLPattern: + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +/** + * @typedef {object} AuthChallenge + * @property {string} scheme + * @property {string} realm + */ + +/** + * @typedef {object} AuthCredentials + * @property {'password'} type + * @property {string} username + * @property {string} password + */ + +/** + * @typedef {object} BaseParameters + * @property {string=} context + * @property {Array<string>?} intercepts + * @property {boolean} isBlocked + * @property {Navigation=} navigation + * @property {number} redirectCount + * @property {RequestData} request + * @property {number} timestamp + */ + +/** + * @typedef {object} BlockedRequest + * @property {NetworkEventRecord} networkEventRecord + * @property {InterceptPhase} phase + */ + +/** + * Enum of possible BytesValue types. + * + * @readonly + * @enum {BytesValueType} + */ +export const BytesValueType = { + Base64: "base64", + String: "string", +}; + +/** + * @typedef {object} BytesValue + * @property {BytesValueType} type + * @property {string} value + */ + +/** + * Enum of possible continueWithAuth actions. + * + * @readonly + * @enum {ContinueWithAuthAction} + */ +const ContinueWithAuthAction = { + Cancel: "cancel", + Default: "default", + ProvideCredentials: "provideCredentials", +}; + +/** + * @typedef {object} Cookie + * @property {string} domain + * @property {number=} expires + * @property {boolean} httpOnly + * @property {string} name + * @property {string} path + * @property {SameSite} sameSite + * @property {boolean} secure + * @property {number} size + * @property {BytesValue} value + */ + +/** + * @typedef {object} CookieHeader + * @property {string} name + * @property {BytesValue} value + */ + +/** + * @typedef {object} FetchTimingInfo + * @property {number} timeOrigin + * @property {number} requestTime + * @property {number} redirectStart + * @property {number} redirectEnd + * @property {number} fetchStart + * @property {number} dnsStart + * @property {number} dnsEnd + * @property {number} connectStart + * @property {number} connectEnd + * @property {number} tlsStart + * @property {number} requestStart + * @property {number} responseStart + * @property {number} responseEnd + */ + +/** + * @typedef {object} Header + * @property {string} name + * @property {BytesValue} value + */ + +/** + * @typedef {string} InitiatorType + */ + +/** + * Enum of possible initiator types. + * + * @readonly + * @enum {InitiatorType} + */ +const InitiatorType = { + Other: "other", + Parser: "parser", + Preflight: "preflight", + Script: "script", +}; + +/** + * @typedef {object} Initiator + * @property {InitiatorType} type + * @property {number=} columnNumber + * @property {number=} lineNumber + * @property {string=} request + * @property {StackTrace=} stackTrace + */ + +/** + * Enum of intercept phases. + * + * @readonly + * @enum {InterceptPhase} + */ +const InterceptPhase = { + AuthRequired: "authRequired", + BeforeRequestSent: "beforeRequestSent", + ResponseStarted: "responseStarted", +}; + +/** + * @typedef {object} InterceptProperties + * @property {Array<InterceptPhase>} phases + * @property {Array<URLPattern>} urlPatterns + */ + +/** + * @typedef {object} RequestData + * @property {number|null} bodySize + * Defaults to null. + * @property {Array<Cookie>} cookies + * @property {Array<Header>} headers + * @property {number} headersSize + * @property {string} method + * @property {string} request + * @property {FetchTimingInfo} timings + * @property {string} url + */ + +/** + * @typedef {object} BeforeRequestSentParametersProperties + * @property {Initiator} initiator + */ + +/* eslint-disable jsdoc/valid-types */ +/** + * Parameters for the BeforeRequestSent event + * + * @typedef {BaseParameters & BeforeRequestSentParametersProperties} BeforeRequestSentParameters + */ +/* eslint-enable jsdoc/valid-types */ + +/** + * @typedef {object} ResponseContent + * @property {number|null} size + * Defaults to null. + */ + +/** + * @typedef {object} ResponseData + * @property {string} url + * @property {string} protocol + * @property {number} status + * @property {string} statusText + * @property {boolean} fromCache + * @property {Array<Header>} headers + * @property {string} mimeType + * @property {number} bytesReceived + * @property {number|null} headersSize + * Defaults to null. + * @property {number|null} bodySize + * Defaults to null. + * @property {ResponseContent} content + * @property {Array<AuthChallenge>=} authChallenges + */ + +/** + * @typedef {object} ResponseStartedParametersProperties + * @property {ResponseData} response + */ + +/* eslint-disable jsdoc/valid-types */ +/** + * Parameters for the ResponseStarted event + * + * @typedef {BaseParameters & ResponseStartedParametersProperties} ResponseStartedParameters + */ +/* eslint-enable jsdoc/valid-types */ + +/** + * @typedef {object} ResponseCompletedParametersProperties + * @property {ResponseData} response + */ + +/** + * Enum of possible sameSite values. + * + * @readonly + * @enum {SameSite} + */ +const SameSite = { + Lax: "lax", + None: "none", + Script: "script", +}; + +/** + * @typedef {object} SetCookieHeader + * @property {string} name + * @property {BytesValue} value + * @property {string=} domain + * @property {boolean=} httpOnly + * @property {string=} expiry + * @property {number=} maxAge + * @property {string=} path + * @property {SameSite=} sameSite + * @property {boolean=} secure + */ + +/** + * @typedef {object} URLPatternPattern + * @property {'pattern'} type + * @property {string=} protocol + * @property {string=} hostname + * @property {string=} port + * @property {string=} pathname + * @property {string=} search + */ + +/** + * @typedef {object} URLPatternString + * @property {'string'} type + * @property {string} pattern + */ + +/** + * @typedef {(URLPatternPattern|URLPatternString)} URLPattern + */ + +/* eslint-disable jsdoc/valid-types */ +/** + * Parameters for the ResponseCompleted event + * + * @typedef {BaseParameters & ResponseCompletedParametersProperties} ResponseCompletedParameters + */ +/* eslint-enable jsdoc/valid-types */ + +class NetworkModule extends Module { + #blockedRequests; + #interceptMap; + #networkListener; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + + // Map of request id to BlockedRequest + this.#blockedRequests = new Map(); + + // Map of intercept id to InterceptProperties + this.#interceptMap = new Map(); + + // Set of event names which have active subscriptions + this.#subscribedEvents = new Set(); + + this.#networkListener = new lazy.NetworkListener(); + this.#networkListener.on("auth-required", this.#onAuthRequired); + this.#networkListener.on("before-request-sent", this.#onBeforeRequestSent); + this.#networkListener.on("fetch-error", this.#onFetchError); + this.#networkListener.on("response-completed", this.#onResponseEvent); + this.#networkListener.on("response-started", this.#onResponseEvent); + } + + destroy() { + this.#networkListener.off("auth-required", this.#onAuthRequired); + this.#networkListener.off("before-request-sent", this.#onBeforeRequestSent); + this.#networkListener.off("fetch-error", this.#onFetchError); + this.#networkListener.off("response-completed", this.#onResponseEvent); + this.#networkListener.off("response-started", this.#onResponseEvent); + this.#networkListener.destroy(); + + this.#blockedRequests = null; + this.#interceptMap = null; + this.#subscribedEvents = null; + } + + /** + * Adds a network intercept, which allows to intercept and modify network + * requests and responses. + * + * The network intercept will be created for the provided phases + * (InterceptPhase) and for specific url patterns. When a network event + * corresponding to an intercept phase has a URL which matches any url pattern + * of any intercept, the request will be suspended. + * + * @param {object=} options + * @param {Array<InterceptPhase>} options.phases + * The phases where this intercept should be checked. + * @param {Array<URLPattern>=} options.urlPatterns + * The URL patterns for this intercept. Optional, defaults to empty array. + * + * @returns {object} + * An object with the following property: + * - intercept {string} The unique id of the network intercept. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ + addIntercept(options = {}) { + const { phases, urlPatterns = [] } = options; + + lazy.assert.array( + phases, + `Expected "phases" to be an array, got ${phases}` + ); + + if (!options.phases.length) { + throw new lazy.error.InvalidArgumentError( + `Expected "phases" to contain at least one phase, got an empty array` + ); + } + + const supportedInterceptPhases = Object.values(InterceptPhase); + for (const phase of phases) { + if (!supportedInterceptPhases.includes(phase)) { + throw new lazy.error.InvalidArgumentError( + `Expected "phases" values to be one of ${supportedInterceptPhases}, got ${phase}` + ); + } + } + + lazy.assert.array( + urlPatterns, + `Expected "urlPatterns" to be an array, got ${urlPatterns}` + ); + + const parsedPatterns = urlPatterns.map(urlPattern => + lazy.parseURLPattern(urlPattern) + ); + + const interceptId = lazy.generateUUID(); + this.#interceptMap.set(interceptId, { + phases, + urlPatterns: parsedPatterns, + }); + + return { + intercept: interceptId, + }; + } + + /** + * Continues a request that is blocked by a network intercept at the + * beforeRequestSent phase. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request that should be continued. + * @param {BytesValue=} options.body [unsupported] + * Optional BytesValue to replace the body of the request. + * @param {Array<CookieHeader>=} options.cookies [unsupported] + * Optional array of cookie header values to replace the cookie header of + * the request. + * @param {Array<Header>=} options.headers [unsupported] + * Optional array of headers to replace the headers of the request. + * request. + * @param {string=} options.method [unsupported] + * Optional string to replace the method of the request. + * @param {string=} options.url [unsupported] + * Optional string to replace the url of the request. If the provided url + * is not a valid URL, an InvalidArgumentError will be thrown. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async continueRequest(options = {}) { + const { + body = null, + cookies = null, + headers = null, + method = null, + url = null, + request: requestId, + } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (body !== null) { + this.#assertBytesValue( + body, + `Expected "body" to be a network.BytesValue, got ${body}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"body" not supported yet in network.continueRequest` + ); + } + + if (cookies !== null) { + lazy.assert.array( + cookies, + `Expected "cookies" to be an array got ${cookies}` + ); + + for (const cookie of cookies) { + this.#assertHeader( + cookie, + `Expected values in "cookies" to be network.CookieHeader, got ${cookie}` + ); + } + + throw new lazy.error.UnsupportedOperationError( + `"cookies" not supported yet in network.continueRequest` + ); + } + + if (headers !== null) { + lazy.assert.array( + headers, + `Expected "headers" to be an array got ${headers}` + ); + + for (const header of headers) { + this.#assertHeader( + header, + `Expected values in "headers" to be network.Header, got ${header}` + ); + } + + throw new lazy.error.UnsupportedOperationError( + `"headers" not supported yet in network.continueRequest` + ); + } + + if (method !== null) { + lazy.assert.string( + method, + `Expected "method" to be a string, got ${method}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"method" not supported yet in network.continueRequest` + ); + } + + if (url !== null) { + lazy.assert.string(url, `Expected "url" to be a string, got ${url}`); + + throw new lazy.error.UnsupportedOperationError( + `"url" not supported yet in network.continueRequest` + ); + } + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { phase, request, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if (phase !== InterceptPhase.BeforeRequestSent) { + throw new lazy.error.InvalidArgumentError( + `Expected blocked request to be in "beforeRequestSent" phase, got ${phase}` + ); + } + + const wrapper = ChannelWrapper.get(request); + wrapper.resume(); + + resolveBlockedEvent(); + } + + /** + * Continues a response that is blocked by a network intercept at the + * responseStarted or authRequired phase. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request that should be continued. + * @param {Array<SetCookieHeader>=} options.cookies [unsupported] + * Optional array of set-cookie header values to replace the set-cookie + * headers of the response. + * @param {AuthCredentials=} options.credentials + * Optional AuthCredentials to use. + * @param {Array<Header>=} options.headers [unsupported] + * Optional array of header values to replace the headers of the response. + * @param {string=} options.reasonPhrase [unsupported] + * Optional string to replace the status message of the response. + * @param {number=} options.statusCode [unsupported] + * Optional number to replace the status code of the response. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async continueResponse(options = {}) { + const { + cookies = null, + credentials = null, + headers = null, + reasonPhrase = null, + request: requestId, + statusCode = null, + } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (cookies !== null) { + lazy.assert.array( + cookies, + `Expected "cookies" to be an array got ${cookies}` + ); + + for (const cookie of cookies) { + this.#assertSetCookieHeader(cookie); + } + + throw new lazy.error.UnsupportedOperationError( + `"cookies" not supported yet in network.continueResponse` + ); + } + + if (credentials !== null) { + this.#assertAuthCredentials(credentials); + } + + if (headers !== null) { + lazy.assert.array( + headers, + `Expected "headers" to be an array got ${headers}` + ); + + for (const header of headers) { + this.#assertHeader( + header, + `Expected values in "headers" to be network.Header, got ${header}` + ); + } + + throw new lazy.error.UnsupportedOperationError( + `"headers" not supported yet in network.continueResponse` + ); + } + + if (reasonPhrase !== null) { + lazy.assert.string( + reasonPhrase, + `Expected "reasonPhrase" to be a string, got ${reasonPhrase}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"reasonPhrase" not supported yet in network.continueResponse` + ); + } + + if (statusCode !== null) { + lazy.assert.positiveInteger( + statusCode, + `Expected "statusCode" to be a positive integer, got ${statusCode}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"statusCode" not supported yet in network.continueResponse` + ); + } + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { authCallbacks, phase, request, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if ( + phase !== InterceptPhase.ResponseStarted && + phase !== InterceptPhase.AuthRequired + ) { + throw new lazy.error.InvalidArgumentError( + `Expected blocked request to be in "responseStarted" or "authRequired" phase, got ${phase}` + ); + } + + if (phase === InterceptPhase.AuthRequired) { + // Requests blocked in the AuthRequired phase should be resumed using + // authCallbacks. + if (credentials !== null) { + await authCallbacks.provideAuthCredentials( + credentials.username, + credentials.password + ); + } else { + await authCallbacks.provideAuthCredentials(); + } + } else { + const wrapper = ChannelWrapper.get(request); + wrapper.resume(); + } + + resolveBlockedEvent(); + } + + /** + * Continues a response that is blocked by a network intercept at the + * authRequired phase. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request that should be continued. + * @param {string} options.action + * The continueWithAuth action, one of ContinueWithAuthAction. + * @param {AuthCredentials=} options.credentials + * The credentials to use for the ContinueWithAuthAction.ProvideCredentials + * action. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async continueWithAuth(options = {}) { + const { action, credentials, request: requestId } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (!Object.values(ContinueWithAuthAction).includes(action)) { + throw new lazy.error.InvalidArgumentError( + `Expected "action" to be one of ${Object.values( + ContinueWithAuthAction + )} got ${action}` + ); + } + + if (action == ContinueWithAuthAction.ProvideCredentials) { + this.#assertAuthCredentials(credentials); + } + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { authCallbacks, phase, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if (phase !== InterceptPhase.AuthRequired) { + throw new lazy.error.InvalidArgumentError( + `Expected blocked request to be in "authRequired" phase, got ${phase}` + ); + } + + switch (action) { + case ContinueWithAuthAction.Cancel: { + authCallbacks.cancelAuthPrompt(); + break; + } + case ContinueWithAuthAction.Default: { + authCallbacks.forwardAuthPrompt(); + break; + } + case ContinueWithAuthAction.ProvideCredentials: { + await authCallbacks.provideAuthCredentials( + credentials.username, + credentials.password + ); + + break; + } + } + + resolveBlockedEvent(); + } + + /** + * Fails a request that is blocked by a network intercept. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request that should be continued. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async failRequest(options = {}) { + const { request: requestId } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { phase, request, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if (phase === InterceptPhase.AuthRequired) { + throw new lazy.error.InvalidArgumentError( + `Expected blocked request not to be in "authRequired" phase` + ); + } + + const wrapper = ChannelWrapper.get(request); + wrapper.resume(); + wrapper.cancel( + Cr.NS_ERROR_ABORT, + Ci.nsILoadInfo.BLOCKING_REASON_WEBDRIVER_BIDI + ); + + resolveBlockedEvent(); + } + + /** + * Continues a request that’s blocked by a network intercept, by providing a + * complete response. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request for which the response should be + * provided. + * @param {BytesValue=} options.body [unsupported] + * Optional BytesValue to replace the body of the response. + * @param {Array<SetCookieHeader>=} options.cookies [unsupported] + * Optional array of set-cookie header values to use for the provided + * response. + * @param {Array<Header>=} options.headers [unsupported] + * Optional array of header values to use for the provided + * response. + * @param {string=} options.reasonPhrase [unsupported] + * Optional string to use as the status message for the provided response. + * @param {number=} options.statusCode [unsupported] + * Optional number to use as the status code for the provided response. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async provideResponse(options = {}) { + const { + body = null, + cookies = null, + headers = null, + reasonPhrase = null, + request: requestId, + statusCode = null, + } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (body !== null) { + this.#assertBytesValue( + body, + `Expected "body" to be a network.BytesValue, got ${body}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"body" not supported yet in network.provideResponse` + ); + } + + if (cookies !== null) { + lazy.assert.array( + cookies, + `Expected "cookies" to be an array got ${cookies}` + ); + + for (const cookie of cookies) { + this.#assertSetCookieHeader(cookie); + } + + throw new lazy.error.UnsupportedOperationError( + `"cookies" not supported yet in network.provideResponse` + ); + } + + if (headers !== null) { + lazy.assert.array( + headers, + `Expected "headers" to be an array got ${headers}` + ); + + for (const header of headers) { + this.#assertHeader( + header, + `Expected values in "headers" to be network.Header, got ${header}` + ); + } + + throw new lazy.error.UnsupportedOperationError( + `"headers" not supported yet in network.provideResponse` + ); + } + + if (reasonPhrase !== null) { + lazy.assert.string( + reasonPhrase, + `Expected "reasonPhrase" to be a string, got ${reasonPhrase}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"reasonPhrase" not supported yet in network.provideResponse` + ); + } + + if (statusCode !== null) { + lazy.assert.positiveInteger( + statusCode, + `Expected "statusCode" to be a positive integer, got ${statusCode}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"statusCode" not supported yet in network.provideResponse` + ); + } + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { authCallbacks, phase, request, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if (phase === InterceptPhase.AuthRequired) { + await authCallbacks.provideAuthCredentials(); + } else { + const wrapper = ChannelWrapper.get(request); + wrapper.resume(); + } + + resolveBlockedEvent(); + } + + /** + * Removes an existing network intercept. + * + * @param {object=} options + * @param {string} options.intercept + * The id of the intercept to remove. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchInterceptError} + * Raised if the intercept id could not be found in the internal intercept + * map. + */ + removeIntercept(options = {}) { + const { intercept } = options; + + lazy.assert.string( + intercept, + `Expected "intercept" to be a string, got ${intercept}` + ); + + if (!this.#interceptMap.has(intercept)) { + throw new lazy.error.NoSuchInterceptError( + `Network intercept with id ${intercept} not found` + ); + } + + this.#interceptMap.delete(intercept); + } + + /** + * Add a new request in the blockedRequests map. + * + * @param {string} requestId + * The request id. + * @param {InterceptPhase} phase + * The phase where the request is blocked. + * @param {object=} options + * @param {object=} options.authCallbacks + * Only defined for requests blocked in the authRequired phase. + * Provides callbacks to handle the authentication. + * @param {nsIChannel=} options.requestChannel + * The request channel. + * @param {nsIChannel=} options.responseChannel + * The response channel. + */ + #addBlockedRequest(requestId, phase, options = {}) { + const { + authCallbacks, + requestChannel: request, + responseChannel: response, + } = options; + const { promise: blockedEventPromise, resolve: resolveBlockedEvent } = + Promise.withResolvers(); + + this.#blockedRequests.set(requestId, { + authCallbacks, + request, + response, + resolveBlockedEvent, + phase, + }); + + blockedEventPromise.finally(() => { + this.#blockedRequests.delete(requestId); + }); + } + + #assertAuthCredentials(credentials) { + lazy.assert.object( + credentials, + `Expected "credentials" to be an object, got ${credentials}` + ); + + if (credentials.type !== "password") { + throw new lazy.error.InvalidArgumentError( + `Expected credentials "type" to be "password" got ${credentials.type}` + ); + } + + lazy.assert.string( + credentials.username, + `Expected credentials "username" to be a string, got ${credentials.username}` + ); + lazy.assert.string( + credentials.password, + `Expected credentials "password" to be a string, got ${credentials.password}` + ); + } + + #assertBytesValue(obj, msg) { + lazy.assert.object(obj, msg); + lazy.assert.string(obj.value, msg); + lazy.assert.in(obj.type, Object.values(BytesValueType), msg); + } + + #assertHeader(value, msg) { + lazy.assert.object(value, msg); + lazy.assert.string(value.name, msg); + this.#assertBytesValue(value.value, msg); + } + + #assertSetCookieHeader(setCookieHeader) { + lazy.assert.object( + setCookieHeader, + `Expected set-cookie header to be an object, got ${setCookieHeader}` + ); + + const { + name, + value, + domain = null, + httpOnly = null, + expiry = null, + maxAge = null, + path = null, + sameSite = null, + secure = null, + } = setCookieHeader; + + lazy.assert.string( + name, + `Expected set-cookie header "name" to be a string, got ${name}` + ); + + this.#assertBytesValue( + value, + `Expected set-cookie header "value" to be a BytesValue, got ${name}` + ); + + if (domain !== null) { + lazy.assert.string( + domain, + `Expected set-cookie header "domain" to be a string, got ${domain}` + ); + } + if (httpOnly !== null) { + lazy.assert.boolean( + httpOnly, + `Expected set-cookie header "httpOnly" to be a boolean, got ${httpOnly}` + ); + } + if (expiry !== null) { + lazy.assert.string( + expiry, + `Expected set-cookie header "expiry" to be a string, got ${expiry}` + ); + } + if (maxAge !== null) { + lazy.assert.integer( + maxAge, + `Expected set-cookie header "maxAge" to be an integer, got ${maxAge}` + ); + } + if (path !== null) { + lazy.assert.string( + path, + `Expected set-cookie header "path" to be a string, got ${path}` + ); + } + if (sameSite !== null) { + lazy.assert.in( + sameSite, + Object.values(SameSite), + `Expected set-cookie header "sameSite" to be one of ${Object.values( + SameSite + )}, got ${sameSite}` + ); + } + if (secure !== null) { + lazy.assert.boolean( + secure, + `Expected set-cookie header "secure" to be a boolean, got ${secure}` + ); + } + } + + #extractChallenges(responseData) { + let headerName; + + // Using case-insensitive match for header names, so we use the lowercase + // version of the "WWW-Authenticate" / "Proxy-Authenticate" strings. + if (responseData.status === 401) { + headerName = "www-authenticate"; + } else if (responseData.status === 407) { + headerName = "proxy-authenticate"; + } else { + return null; + } + + const challenges = []; + + for (const header of responseData.headers) { + if (header.name.toLowerCase() === headerName) { + // A single header can contain several challenges. + const headerChallenges = lazy.parseChallengeHeader(header.value); + for (const headerChallenge of headerChallenges) { + const realmParam = headerChallenge.params.find( + param => param.name == "realm" + ); + const realm = realmParam ? realmParam.value : undefined; + const challenge = { + scheme: headerChallenge.scheme, + realm, + }; + challenges.push(challenge); + } + } + } + + return challenges; + } + + #getContextInfo(browsingContext) { + return { + contextId: browsingContext.id, + type: lazy.WindowGlobalMessageHandler.type, + }; + } + + #getSuspendMarkerText(requestData, phase) { + return `Request (id: ${requestData.request}) suspended by WebDriver BiDi in ${phase} phase`; + } + + #getNetworkIntercepts(event, requestData) { + const intercepts = []; + + let phase; + switch (event) { + case "network.beforeRequestSent": + phase = InterceptPhase.BeforeRequestSent; + break; + case "network.responseStarted": + phase = InterceptPhase.ResponseStarted; + break; + case "network.authRequired": + phase = InterceptPhase.AuthRequired; + break; + case "network.responseCompleted": + // The network.responseCompleted event does not match any interception + // phase. Return immediately. + return intercepts; + } + + const url = requestData.url; + for (const [interceptId, intercept] of this.#interceptMap) { + if (intercept.phases.includes(phase)) { + const urlPatterns = intercept.urlPatterns; + if ( + !urlPatterns.length || + urlPatterns.some(pattern => lazy.matchURLPattern(pattern, url)) + ) { + intercepts.push(interceptId); + } + } + } + + return intercepts; + } + + #getNavigationId(eventName, isNavigationRequest, browsingContext, url) { + if (!isNavigationRequest) { + // Not a navigation request return null. + return null; + } + + let navigation = + this.messageHandler.navigationManager.getNavigationForBrowsingContext( + browsingContext + ); + + // `onBeforeRequestSent` might be too early for the NavigationManager. + // If there is no ongoing navigation, create one ourselves. + // TODO: Bug 1835704 to detect navigations earlier and avoid this. + if ( + eventName === "network.beforeRequestSent" && + (!navigation || navigation.finished) + ) { + navigation = lazy.notifyNavigationStarted({ + contextDetails: { context: browsingContext }, + url, + }); + } + + return navigation ? navigation.navigationId : null; + } + + #onAuthRequired = (name, data) => { + const { + authCallbacks, + contextId, + isNavigationRequest, + redirectCount, + requestChannel, + requestData, + responseChannel, + responseData, + timestamp, + } = data; + + let isBlocked = false; + try { + const browsingContext = lazy.TabManager.getBrowsingContextById(contextId); + if (!browsingContext) { + // Do not emit events if the context id does not match any existing + // browsing context. + return; + } + + const protocolEventName = "network.authRequired"; + + // Process the navigation to create potentially missing navigation ids + // before the early return below. + const navigation = this.#getNavigationId( + protocolEventName, + isNavigationRequest, + browsingContext, + requestData.url + ); + + const isListening = this.messageHandler.eventsDispatcher.hasListener( + protocolEventName, + { contextId } + ); + if (!isListening) { + // If there are no listeners subscribed to this event and this context, + // bail out. + return; + } + + const baseParameters = this.#processNetworkEvent(protocolEventName, { + contextId, + navigation, + redirectCount, + requestData, + timestamp, + }); + + const authRequiredEvent = this.#serializeNetworkEvent({ + ...baseParameters, + response: responseData, + }); + + const authChallenges = this.#extractChallenges(responseData); + // authChallenges should never be null for a request which triggered an + // authRequired event. + authRequiredEvent.response.authChallenges = authChallenges; + + this.emitEvent( + protocolEventName, + authRequiredEvent, + this.#getContextInfo(browsingContext) + ); + + if (authRequiredEvent.isBlocked) { + isBlocked = true; + + // requestChannel.suspend() is not needed here because the request is + // already blocked on the authentication prompt notification until + // one of the authCallbacks is called. + this.#addBlockedRequest( + authRequiredEvent.request.request, + InterceptPhase.AuthRequired, + { + authCallbacks, + requestChannel, + responseChannel, + } + ); + } + } finally { + if (!isBlocked) { + // If the request was not blocked, forward the auth prompt notification + // to the next consumer. + authCallbacks.forwardAuthPrompt(); + } + } + }; + + #onBeforeRequestSent = (name, data) => { + const { + contextId, + isNavigationRequest, + redirectCount, + requestChannel, + requestData, + timestamp, + } = data; + + const browsingContext = lazy.TabManager.getBrowsingContextById(contextId); + if (!browsingContext) { + // Do not emit events if the context id does not match any existing + // browsing context. + return; + } + + const internalEventName = "network._beforeRequestSent"; + const protocolEventName = "network.beforeRequestSent"; + + // Process the navigation to create potentially missing navigation ids + // before the early return below. + const navigation = this.#getNavigationId( + protocolEventName, + isNavigationRequest, + browsingContext, + requestData.url + ); + + // Always emit internal events, they are used to support the browsingContext + // navigate command. + // Bug 1861922: Replace internal events with a Network listener helper + // directly using the NetworkObserver. + this.emitEvent( + internalEventName, + { + navigation, + url: requestData.url, + }, + this.#getContextInfo(browsingContext) + ); + + const isListening = this.messageHandler.eventsDispatcher.hasListener( + protocolEventName, + { contextId } + ); + if (!isListening) { + // If there are no listeners subscribed to this event and this context, + // bail out. + return; + } + + const baseParameters = this.#processNetworkEvent(protocolEventName, { + contextId, + navigation, + redirectCount, + requestData, + timestamp, + }); + + // Bug 1805479: Handle the initiator, including stacktrace details. + const initiator = { + type: InitiatorType.Other, + }; + + const beforeRequestSentEvent = this.#serializeNetworkEvent({ + ...baseParameters, + initiator, + }); + + this.emitEvent( + protocolEventName, + beforeRequestSentEvent, + this.#getContextInfo(browsingContext) + ); + + if (beforeRequestSentEvent.isBlocked) { + // TODO: Requests suspended in beforeRequestSent still reach the server at + // the moment. https://bugzilla.mozilla.org/show_bug.cgi?id=1849686 + const wrapper = ChannelWrapper.get(requestChannel); + wrapper.suspend( + this.#getSuspendMarkerText(requestData, "beforeRequestSent") + ); + + this.#addBlockedRequest( + beforeRequestSentEvent.request.request, + InterceptPhase.BeforeRequestSent, + { + requestChannel, + } + ); + } + }; + + #onFetchError = (name, data) => { + const { + contextId, + errorText, + isNavigationRequest, + redirectCount, + requestData, + timestamp, + } = data; + + const browsingContext = lazy.TabManager.getBrowsingContextById(contextId); + if (!browsingContext) { + // Do not emit events if the context id does not match any existing + // browsing context. + return; + } + + const internalEventName = "network._fetchError"; + const protocolEventName = "network.fetchError"; + + // Process the navigation to create potentially missing navigation ids + // before the early return below. + const navigation = this.#getNavigationId( + protocolEventName, + isNavigationRequest, + browsingContext, + requestData.url + ); + + // Always emit internal events, they are used to support the browsingContext + // navigate command. + // Bug 1861922: Replace internal events with a Network listener helper + // directly using the NetworkObserver. + this.emitEvent( + internalEventName, + { + navigation, + url: requestData.url, + }, + this.#getContextInfo(browsingContext) + ); + + const isListening = this.messageHandler.eventsDispatcher.hasListener( + protocolEventName, + { contextId } + ); + if (!isListening) { + // If there are no listeners subscribed to this event and this context, + // bail out. + return; + } + + const baseParameters = this.#processNetworkEvent(protocolEventName, { + contextId, + navigation, + redirectCount, + requestData, + timestamp, + }); + + const fetchErrorEvent = this.#serializeNetworkEvent({ + ...baseParameters, + errorText, + }); + + this.emitEvent( + protocolEventName, + fetchErrorEvent, + this.#getContextInfo(browsingContext) + ); + }; + + #onResponseEvent = (name, data) => { + const { + contextId, + isNavigationRequest, + redirectCount, + requestChannel, + requestData, + responseChannel, + responseData, + timestamp, + } = data; + + const browsingContext = lazy.TabManager.getBrowsingContextById(contextId); + if (!browsingContext) { + // Do not emit events if the context id does not match any existing + // browsing context. + return; + } + + const protocolEventName = + name === "response-started" + ? "network.responseStarted" + : "network.responseCompleted"; + + const internalEventName = + name === "response-started" + ? "network._responseStarted" + : "network._responseCompleted"; + + // Process the navigation to create potentially missing navigation ids + // before the early return below. + const navigation = this.#getNavigationId( + protocolEventName, + isNavigationRequest, + browsingContext, + requestData.url + ); + + // Always emit internal events, they are used to support the browsingContext + // navigate command. + // Bug 1861922: Replace internal events with a Network listener helper + // directly using the NetworkObserver. + this.emitEvent( + internalEventName, + { + navigation, + url: requestData.url, + }, + this.#getContextInfo(browsingContext) + ); + + const isListening = this.messageHandler.eventsDispatcher.hasListener( + protocolEventName, + { contextId } + ); + if (!isListening) { + // If there are no listeners subscribed to this event and this context, + // bail out. + return; + } + + const baseParameters = this.#processNetworkEvent(protocolEventName, { + contextId, + navigation, + redirectCount, + requestData, + timestamp, + }); + + const responseEvent = this.#serializeNetworkEvent({ + ...baseParameters, + response: responseData, + }); + + const authChallenges = this.#extractChallenges(responseData); + if (authChallenges !== null) { + responseEvent.response.authChallenges = authChallenges; + } + + this.emitEvent( + protocolEventName, + responseEvent, + this.#getContextInfo(browsingContext) + ); + + if ( + protocolEventName === "network.responseStarted" && + responseEvent.isBlocked + ) { + const wrapper = ChannelWrapper.get(requestChannel); + wrapper.suspend( + this.#getSuspendMarkerText(requestData, "responseStarted") + ); + + this.#addBlockedRequest( + responseEvent.request.request, + InterceptPhase.ResponseStarted, + { + requestChannel, + responseChannel, + } + ); + } + }; + + /** + * Process the network event data for a given network event name and create + * the corresponding base parameters. + * + * @param {string} eventName + * One of the supported network event names. + * @param {object} data + * @param {string} data.contextId + * The browsing context id for the network event. + * @param {string|null} data.navigation + * The navigation id if this is a network event for a navigation request. + * @param {number} data.redirectCount + * The redirect count for the network event. + * @param {RequestData} data.requestData + * The network.RequestData information for the network event. + * @param {number} data.timestamp + * The timestamp when the network event was created. + */ + #processNetworkEvent(eventName, data) { + const { contextId, navigation, redirectCount, requestData, timestamp } = + data; + const intercepts = this.#getNetworkIntercepts(eventName, requestData); + const isBlocked = !!intercepts.length; + + const baseParameters = { + context: contextId, + isBlocked, + navigation, + redirectCount, + request: requestData, + timestamp, + }; + + if (isBlocked) { + baseParameters.intercepts = intercepts; + } + + return baseParameters; + } + + #serializeHeadersOrCookies(headersOrCookies) { + return headersOrCookies.map(item => ({ + name: item.name, + value: this.#serializeStringAsBytesValue(item.value), + })); + } + + /** + * Serialize in-place all cookies and headers arrays found in a given network + * event payload. + * + * @param {object} networkEvent + * The network event parameters object to serialize. + * @returns {object} + * The serialized network event parameters. + */ + #serializeNetworkEvent(networkEvent) { + // Make a shallow copy of networkEvent before serializing the headers and + // cookies arrays in request/response. + const serialized = { ...networkEvent }; + + // Make a shallow copy of the request data. + serialized.request = { ...networkEvent.request }; + serialized.request.cookies = this.#serializeHeadersOrCookies( + networkEvent.request.cookies + ); + serialized.request.headers = this.#serializeHeadersOrCookies( + networkEvent.request.headers + ); + + if (networkEvent.response?.headers) { + // Make a shallow copy of the response data. + serialized.response = { ...networkEvent.response }; + serialized.response.headers = this.#serializeHeadersOrCookies( + networkEvent.response.headers + ); + } + + return serialized; + } + + /** + * Serialize a string value as BytesValue. + * + * Note: This does not attempt to fully implement serialize protocol bytes + * (https://w3c.github.io/webdriver-bidi/#serialize-protocol-bytes) as the + * header values read from the Channel are already serialized as strings at + * the moment. + * + * @param {string} value + * The value to serialize. + */ + #serializeStringAsBytesValue(value) { + // TODO: For now, we handle all headers and cookies with the "string" type. + // See Bug 1835216 to add support for "base64" type and handle non-utf8 + // values. + return { + type: BytesValueType.String, + value, + }; + } + + #startListening(event) { + if (this.#subscribedEvents.size == 0) { + this.#networkListener.startListening(); + } + this.#subscribedEvents.add(event); + } + + #stopListening(event) { + this.#subscribedEvents.delete(event); + if (this.#subscribedEvents.size == 0) { + this.#networkListener.stopListening(); + } + } + + #subscribeEvent(event) { + if (this.constructor.supportedEvents.includes(event)) { + this.#startListening(event); + } + } + + #unsubscribeEvent(event) { + if (this.constructor.supportedEvents.includes(event)) { + this.#stopListening(event); + } + } + + /** + * Internal commands + */ + + _applySessionData(params) { + // TODO: Bug 1775231. Move this logic to a shared module or an abstract + // class. + const { category } = params; + if (category === "event") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#unsubscribeEvent(event); + } + } + + // Subscribe to all events, which have an item in SessionData. + for (const { value } of filteredSessionData) { + this.#subscribeEvent(value); + } + } + } + + static get supportedEvents() { + return [ + "network.authRequired", + "network.beforeRequestSent", + "network.fetchError", + "network.responseCompleted", + "network.responseStarted", + ]; + } +} + +export const network = NetworkModule; diff --git a/remote/webdriver-bidi/modules/root/script.sys.mjs b/remote/webdriver-bidi/modules/root/script.sys.mjs new file mode 100644 index 0000000000..80fa4d76d0 --- /dev/null +++ b/remote/webdriver-bidi/modules/root/script.sys.mjs @@ -0,0 +1,959 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", + processExtraData: + "chrome://remote/content/webdriver-bidi/modules/Intercept.sys.mjs", + RealmType: "chrome://remote/content/shared/Realm.sys.mjs", + SessionDataMethod: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + setDefaultAndAssertSerializationOptions: + "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +/** + * @typedef {string} ScriptEvaluateResultType + */ + +/** + * Enum of possible evaluation result types. + * + * @readonly + * @enum {ScriptEvaluateResultType} + */ +const ScriptEvaluateResultType = { + Exception: "exception", + Success: "success", +}; + +class ScriptModule extends Module { + #preloadScriptMap; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + + // Map in which the keys are UUIDs, and the values are structs + // with an item named expression, which is a string, + // and an item named sandbox which is a string or null. + this.#preloadScriptMap = new Map(); + + // Set of event names which have active subscriptions. + this.#subscribedEvents = new Set(); + } + + destroy() { + this.#preloadScriptMap = null; + this.#subscribedEvents = null; + } + + /** + * Used as return value for script.addPreloadScript command. + * + * @typedef AddPreloadScriptResult + * + * @property {string} script + * The unique id associated with added preload script. + */ + + /** + * @typedef ChannelProperties + * + * @property {string} channel + * The channel id. + * @property {SerializationOptions=} serializationOptions + * An object which holds the information of how the result of evaluation + * in case of ECMAScript objects should be serialized. + * @property {OwnershipModel=} ownership + * The ownership model to use for the results of this evaluation. Defaults + * to `OwnershipModel.None`. + */ + + /** + * Represents a channel used to send custom messages from preload script + * to clients. + * + * @typedef ChannelValue + * + * @property {'channel'} type + * @property {ChannelProperties} value + */ + + /** + * Adds a preload script, which runs on creation of a new Window, + * before any author-defined script have run. + * + * @param {object=} options + * @param {Array<ChannelValue>=} options.arguments + * The arguments to pass to the function call. + * @param {Array<string>=} options.contexts + * The list of the browsing context ids. + * @param {string} options.functionDeclaration + * The expression to evaluate. + * @param {string=} options.sandbox + * The name of the sandbox. If the value is null or empty + * string, the default realm will be used. + * + * @returns {AddPreloadScriptResult} + * + * @throws {InvalidArgumentError} + * If any of the arguments does not have the expected type. + */ + async addPreloadScript(options = {}) { + const { + arguments: commandArguments = [], + contexts: contextIds = null, + functionDeclaration, + sandbox = null, + } = options; + let contexts = null; + + if (contextIds != null) { + lazy.assert.array( + contextIds, + `Expected "contexts" to be an array, got ${contextIds}` + ); + lazy.assert.that( + contexts => !!contexts.length, + `Expected "contexts" array to have at least one item, got ${contextIds}` + )(contextIds); + + contexts = new Set(); + for (const contextId of contextIds) { + lazy.assert.string( + contextId, + `Expected elements of "contexts" to be a string, got ${contextId}` + ); + const context = this.#getBrowsingContext(contextId); + + if (context.parent) { + throw new lazy.error.InvalidArgumentError( + `Context with id ${contextId} is not a top-level browsing context` + ); + } + + contexts.add(context.browserId); + } + } + + lazy.assert.string( + functionDeclaration, + `Expected "functionDeclaration" to be a string, got ${functionDeclaration}` + ); + + if (sandbox != null) { + lazy.assert.string( + sandbox, + `Expected "sandbox" to be a string, got ${sandbox}` + ); + } + + lazy.assert.array( + commandArguments, + `Expected "arguments" to be an array, got ${commandArguments}` + ); + lazy.assert.that( + commandArguments => + commandArguments.every(({ type, value }) => { + if (type === "channel") { + this.#assertChannelArgument(value); + return true; + } + return false; + }), + `One of the arguments has an unsupported type, only type "channel" is supported` + )(commandArguments); + + const script = lazy.generateUUID(); + const preloadScript = { + arguments: commandArguments, + contexts, + functionDeclaration, + sandbox, + }; + + this.#preloadScriptMap.set(script, preloadScript); + + const preloadScriptDataItem = { + category: "preload-script", + moduleName: "script", + values: [ + { + ...preloadScript, + script, + }, + ], + }; + + if (contexts === null) { + await this.messageHandler.addSessionDataItem({ + ...preloadScriptDataItem, + contextDescriptor: { + type: lazy.ContextDescriptorType.All, + }, + }); + } else { + const preloadScriptDataItems = []; + for (const id of contexts) { + preloadScriptDataItems.push({ + ...preloadScriptDataItem, + contextDescriptor: { + type: lazy.ContextDescriptorType.TopBrowsingContext, + id, + }, + method: lazy.SessionDataMethod.Add, + }); + } + + await this.messageHandler.updateSessionData(preloadScriptDataItems); + } + + return { script }; + } + + /** + * Used to represent a frame of a JavaScript stack trace. + * + * @typedef StackFrame + * + * @property {number} columnNumber + * @property {string} functionName + * @property {number} lineNumber + * @property {string} url + */ + + /** + * Used to represent a JavaScript stack at a point in script execution. + * + * @typedef StackTrace + * + * @property {Array<StackFrame>} callFrames + */ + + /** + * Used to represent a JavaScript exception. + * + * @typedef ExceptionDetails + * + * @property {number} columnNumber + * @property {RemoteValue} exception + * @property {number} lineNumber + * @property {StackTrace} stackTrace + * @property {string} text + */ + + /** + * Used as return value for script.evaluate, as one of the available variants + * {ScriptEvaluateResultException} or {ScriptEvaluateResultSuccess}. + * + * @typedef ScriptEvaluateResult + */ + + /** + * Used as return value for script.evaluate when the script completes with a + * thrown exception. + * + * @typedef ScriptEvaluateResultException + * + * @property {ExceptionDetails} exceptionDetails + * @property {string} realm + * @property {ScriptEvaluateResultType} [type=ScriptEvaluateResultType.Exception] + */ + + /** + * Used as return value for script.evaluate when the script completes + * normally. + * + * @typedef ScriptEvaluateResultSuccess + * + * @property {string} realm + * @property {RemoteValue} result + * @property {ScriptEvaluateResultType} [type=ScriptEvaluateResultType.Success] + */ + + /** + * Calls a provided function with given arguments and scope in the provided + * target, which is either a realm or a browsing context. + * + * @param {object=} options + * @param {Array<RemoteValue>=} options.arguments + * The arguments to pass to the function call. + * @param {boolean} options.awaitPromise + * Determines if the command should wait for the return value of the + * expression to resolve, if this return value is a Promise. + * @param {string} options.functionDeclaration + * The expression to evaluate. + * @param {OwnershipModel=} options.resultOwnership + * The ownership model to use for the results of this evaluation. Defaults + * to `OwnershipModel.None`. + * @param {SerializationOptions=} options.serializationOptions + * An object which holds the information of how the result of evaluation + * in case of ECMAScript objects should be serialized. + * @param {object} options.target + * The target for the evaluation, which either matches the definition for + * a RealmTarget or for ContextTarget. + * @param {RemoteValue=} options.this + * The value of the this keyword for the function call. + * @param {boolean=} options.userActivation + * Determines whether execution should be treated as initiated by user. + * Defaults to `false`. + * + * @returns {ScriptEvaluateResult} + * + * @throws {InvalidArgumentError} + * If any of the arguments does not have the expected type. + * @throws {NoSuchFrameError} + * If the target cannot be found. + */ + async callFunction(options = {}) { + const { + arguments: commandArguments = null, + awaitPromise, + functionDeclaration, + resultOwnership = lazy.OwnershipModel.None, + serializationOptions, + target = {}, + this: thisParameter = null, + userActivation = false, + } = options; + + lazy.assert.string( + functionDeclaration, + `Expected "functionDeclaration" to be a string, got ${functionDeclaration}` + ); + + lazy.assert.boolean( + awaitPromise, + `Expected "awaitPromise" to be a boolean, got ${awaitPromise}` + ); + + lazy.assert.boolean( + userActivation, + `Expected "userActivation" to be a boolean, got ${userActivation}` + ); + + this.#assertResultOwnership(resultOwnership); + + if (commandArguments != null) { + lazy.assert.array( + commandArguments, + `Expected "arguments" to be an array, got ${commandArguments}` + ); + commandArguments.forEach(({ type, value }) => { + if (type === "channel") { + this.#assertChannelArgument(value); + } + }); + } + + const { contextId, realmId, sandbox } = this.#assertTarget(target); + const context = await this.#getContextFromTarget({ contextId, realmId }); + const serializationOptionsWithDefaults = + lazy.setDefaultAndAssertSerializationOptions(serializationOptions); + const evaluationResult = await this.messageHandler.forwardCommand({ + moduleName: "script", + commandName: "callFunctionDeclaration", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + params: { + awaitPromise, + commandArguments, + functionDeclaration, + realmId, + resultOwnership, + sandbox, + serializationOptions: serializationOptionsWithDefaults, + thisParameter, + userActivation, + }, + }); + + return this.#buildReturnValue(evaluationResult); + } + + /** + * The script.disown command disowns the given handles. This does not + * guarantee the handled object will be garbage collected, as there can be + * other handles or strong ECMAScript references. + * + * @param {object=} options + * @param {Array<string>} options.handles + * Array of handle ids to disown. + * @param {object} options.target + * The target owning the handles, which either matches the definition for + * a RealmTarget or for ContextTarget. + */ + async disown(options = {}) { + const { handles, target = {} } = options; + + lazy.assert.array( + handles, + `Expected "handles" to be an array, got ${handles}` + ); + handles.forEach(handle => { + lazy.assert.string( + handle, + `Expected "handles" to be an array of strings, got ${handle}` + ); + }); + + const { contextId, realmId, sandbox } = this.#assertTarget(target); + const context = await this.#getContextFromTarget({ contextId, realmId }); + await this.messageHandler.forwardCommand({ + moduleName: "script", + commandName: "disownHandles", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + params: { + handles, + realmId, + sandbox, + }, + }); + } + + /** + * Evaluate a provided expression in the provided target, which is either a + * realm or a browsing context. + * + * @param {object=} options + * @param {boolean} options.awaitPromise + * Determines if the command should wait for the return value of the + * expression to resolve, if this return value is a Promise. + * @param {string} options.expression + * The expression to evaluate. + * @param {OwnershipModel=} options.resultOwnership + * The ownership model to use for the results of this evaluation. Defaults + * to `OwnershipModel.None`. + * @param {SerializationOptions=} options.serializationOptions + * An object which holds the information of how the result of evaluation + * in case of ECMAScript objects should be serialized. + * @param {object} options.target + * The target for the evaluation, which either matches the definition for + * a RealmTarget or for ContextTarget. + * @param {boolean=} options.userActivation + * Determines whether execution should be treated as initiated by user. + * Defaults to `false`. + * + * @returns {ScriptEvaluateResult} + * + * @throws {InvalidArgumentError} + * If any of the arguments does not have the expected type. + * @throws {NoSuchFrameError} + * If the target cannot be found. + */ + async evaluate(options = {}) { + const { + awaitPromise, + expression: source, + resultOwnership = lazy.OwnershipModel.None, + serializationOptions, + target = {}, + userActivation = false, + } = options; + + lazy.assert.string( + source, + `Expected "expression" to be a string, got ${source}` + ); + + lazy.assert.boolean( + awaitPromise, + `Expected "awaitPromise" to be a boolean, got ${awaitPromise}` + ); + + lazy.assert.boolean( + userActivation, + `Expected "userActivation" to be a boolean, got ${userActivation}` + ); + + this.#assertResultOwnership(resultOwnership); + + const { contextId, realmId, sandbox } = this.#assertTarget(target); + const context = await this.#getContextFromTarget({ contextId, realmId }); + const serializationOptionsWithDefaults = + lazy.setDefaultAndAssertSerializationOptions(serializationOptions); + const evaluationResult = await this.messageHandler.forwardCommand({ + moduleName: "script", + commandName: "evaluateExpression", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + id: context.id, + }, + params: { + awaitPromise, + expression: source, + realmId, + resultOwnership, + sandbox, + serializationOptions: serializationOptionsWithDefaults, + userActivation, + }, + }); + + return this.#buildReturnValue(evaluationResult); + } + + /** + * An object that holds basic information about a realm. + * + * @typedef BaseRealmInfo + * + * @property {string} id + * The realm unique identifier. + * @property {string} origin + * The serialization of an origin. + */ + + /** + * + * @typedef WindowRealmInfoProperties + * + * @property {string} context + * The browsing context id, associated with the realm. + * @property {string=} sandbox + * The name of the sandbox. If the value is null or empty + * string, the default realm will be returned. + * @property {RealmType.Window} type + * The window realm type. + */ + + /* eslint-disable jsdoc/valid-types */ + /** + * An object that holds information about a window realm. + * + * @typedef {BaseRealmInfo & WindowRealmInfoProperties} WindowRealmInfo + */ + /* eslint-enable jsdoc/valid-types */ + + /** + * An object that holds information about a realm. + * + * @typedef {WindowRealmInfo} RealmInfo + */ + + /** + * An object that holds a list of realms. + * + * @typedef ScriptGetRealmsResult + * + * @property {Array<RealmInfo>} realms + * List of realms. + */ + + /** + * Returns a list of all realms, optionally filtered to realms + * of a specific type, or to the realms associated with + * a specified browsing context. + * + * @param {object=} options + * @param {string=} options.context + * The id of the browsing context to filter + * only realms associated with it. If not provided, return realms + * associated with all browsing contexts. + * @param {RealmType=} options.type + * Type of realm to filter. + * If not provided, return realms of all types. + * + * @returns {ScriptGetRealmsResult} + * + * @throws {InvalidArgumentError} + * If any of the arguments does not have the expected type. + * @throws {NoSuchFrameError} + * If the context cannot be found. + */ + async getRealms(options = {}) { + const { context: contextId = null, type = null } = options; + const destination = {}; + + if (contextId !== null) { + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + destination.id = this.#getBrowsingContext(contextId).id; + } else { + destination.contextDescriptor = { + type: lazy.ContextDescriptorType.All, + }; + } + + if (type !== null) { + const supportedRealmTypes = Object.values(lazy.RealmType); + if (!supportedRealmTypes.includes(type)) { + throw new lazy.error.InvalidArgumentError( + `Expected "type" to be one of ${supportedRealmTypes}, got ${type}` + ); + } + + // Remove this check when other realm types are supported + if (type !== lazy.RealmType.Window) { + throw new lazy.error.UnsupportedOperationError( + `Unsupported "type": ${type}. Only "type" ${lazy.RealmType.Window} is currently supported.` + ); + } + } + + return { realms: await this.#getRealmInfos(destination) }; + } + + /** + * Removes a preload script. + * + * @param {object=} options + * @param {string} options.script + * The unique id associated with a preload script. + * + * @throws {InvalidArgumentError} + * If any of the arguments does not have the expected type. + * @throws {NoSuchScriptError} + * If the script cannot be found. + */ + async removePreloadScript(options = {}) { + const { script } = options; + + lazy.assert.string( + script, + `Expected "script" to be a string, got ${script}` + ); + + if (!this.#preloadScriptMap.has(script)) { + throw new lazy.error.NoSuchScriptError( + `Preload script with id ${script} not found` + ); + } + + const preloadScript = this.#preloadScriptMap.get(script); + const sessionDataItem = { + category: "preload-script", + moduleName: "script", + values: [ + { + ...preloadScript, + script, + }, + ], + }; + + if (preloadScript.contexts === null) { + await this.messageHandler.removeSessionDataItem({ + ...sessionDataItem, + contextDescriptor: { + type: lazy.ContextDescriptorType.All, + }, + }); + } else { + const sessionDataItemToUpdate = []; + for (const id of preloadScript.contexts) { + sessionDataItemToUpdate.push({ + ...sessionDataItem, + contextDescriptor: { + type: lazy.ContextDescriptorType.TopBrowsingContext, + id, + }, + method: lazy.SessionDataMethod.Remove, + }); + } + + await this.messageHandler.updateSessionData(sessionDataItemToUpdate); + } + + this.#preloadScriptMap.delete(script); + } + + #assertChannelArgument(value) { + lazy.assert.object(value); + const { + channel, + ownership = lazy.OwnershipModel.None, + serializationOptions, + } = value; + lazy.assert.string(channel); + lazy.setDefaultAndAssertSerializationOptions(serializationOptions); + lazy.assert.that( + ownership => + [lazy.OwnershipModel.None, lazy.OwnershipModel.Root].includes( + ownership + ), + `Expected "ownership" to be one of ${Object.values( + lazy.OwnershipModel + )}, got ${ownership}` + )(ownership); + + return true; + } + + #assertResultOwnership(resultOwnership) { + if ( + ![lazy.OwnershipModel.None, lazy.OwnershipModel.Root].includes( + resultOwnership + ) + ) { + throw new lazy.error.InvalidArgumentError( + `Expected "resultOwnership" to be one of ${Object.values( + lazy.OwnershipModel + )}, got ${resultOwnership}` + ); + } + } + + #assertTarget(target) { + lazy.assert.object( + target, + `Expected "target" to be an object, got ${target}` + ); + + const { context: contextId = null, sandbox = null } = target; + let { realm: realmId = null } = target; + + if (contextId != null) { + lazy.assert.string( + contextId, + `Expected "context" to be a string, got ${contextId}` + ); + + if (sandbox != null) { + lazy.assert.string( + sandbox, + `Expected "sandbox" to be a string, got ${sandbox}` + ); + } + + // Ignore realm if context is provided. + realmId = null; + } else if (realmId != null) { + lazy.assert.string( + realmId, + `Expected "realm" to be a string, got ${realmId}` + ); + } else { + throw new lazy.error.InvalidArgumentError(`No context or realm provided`); + } + + return { contextId, realmId, sandbox }; + } + + #buildReturnValue(evaluationResult) { + evaluationResult = lazy.processExtraData( + this.messageHandler.sessionId, + evaluationResult + ); + + const rv = { realm: evaluationResult.realmId }; + switch (evaluationResult.evaluationStatus) { + // TODO: Compare with EvaluationStatus.Normal after Bug 1774444 is fixed. + case "normal": + rv.type = ScriptEvaluateResultType.Success; + rv.result = evaluationResult.result; + break; + // TODO: Compare with EvaluationStatus.Throw after Bug 1774444 is fixed. + case "throw": + rv.type = ScriptEvaluateResultType.Exception; + rv.exceptionDetails = evaluationResult.exceptionDetails; + break; + default: + throw new lazy.error.UnsupportedOperationError( + `Unsupported evaluation status ${evaluationResult.evaluationStatus}` + ); + } + return rv; + } + + #getBrowsingContext(contextId) { + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (context === null) { + throw new lazy.error.NoSuchFrameError( + `Browsing Context with id ${contextId} not found` + ); + } + + if (!context.currentWindowGlobal) { + throw new lazy.error.NoSuchFrameError( + `No window found for BrowsingContext with id ${contextId}` + ); + } + + return context; + } + + async #getContextFromTarget({ contextId, realmId }) { + if (contextId !== null) { + return this.#getBrowsingContext(contextId); + } + + const destination = { + contextDescriptor: { + type: lazy.ContextDescriptorType.All, + }, + }; + const realms = await this.#getRealmInfos(destination); + const realm = realms.find(realm => realm.realm == realmId); + + if (realm && realm.context !== null) { + return this.#getBrowsingContext(realm.context); + } + + throw new lazy.error.NoSuchFrameError(`Realm with id ${realmId} not found`); + } + + async #getRealmInfos(destination) { + let realms = await this.messageHandler.forwardCommand({ + moduleName: "script", + commandName: "getWindowRealms", + destination: { + type: lazy.WindowGlobalMessageHandler.type, + ...destination, + }, + }); + + const isBroadcast = !!destination.contextDescriptor; + if (!isBroadcast) { + realms = [realms]; + } + + return realms + .flat() + .map(realm => { + // Resolve browsing context to a TabManager id. + realm.context = lazy.TabManager.getIdForBrowsingContext(realm.context); + return realm; + }) + .filter(realm => realm.context !== null); + } + + #onRealmCreated = (eventName, { realmInfo }) => { + // This event is emitted from the parent process but for a given browsing + // context. Set the event's contextInfo to the message handler corresponding + // to this browsing context. + const contextInfo = { + contextId: realmInfo.context.id, + type: lazy.WindowGlobalMessageHandler.type, + }; + + // Resolve browsing context to a TabManager id. + const context = lazy.TabManager.getIdForBrowsingContext(realmInfo.context); + + // Don not emit the event, if the browsing context is gone. + if (context === null) { + return; + } + + realmInfo.context = context; + this.emitEvent("script.realmCreated", realmInfo, contextInfo); + }; + + #onRealmDestroyed = (eventName, { realm, context }) => { + // This event is emitted from the parent process but for a given browsing + // context. Set the event's contextInfo to the message handler corresponding + // to this browsing context. + const contextInfo = { + contextId: context.id, + type: lazy.WindowGlobalMessageHandler.type, + }; + + this.emitEvent("script.realmDestroyed", { realm }, contextInfo); + }; + + #startListingOnRealmCreated() { + if (!this.#subscribedEvents.has("script.realmCreated")) { + this.messageHandler.on("realm-created", this.#onRealmCreated); + } + } + + #stopListingOnRealmCreated() { + if (this.#subscribedEvents.has("script.realmCreated")) { + this.messageHandler.off("realm-created", this.#onRealmCreated); + } + } + + #startListingOnRealmDestroyed() { + if (!this.#subscribedEvents.has("script.realmDestroyed")) { + this.messageHandler.on("realm-destroyed", this.#onRealmDestroyed); + } + } + + #stopListingOnRealmDestroyed() { + if (this.#subscribedEvents.has("script.realmDestroyed")) { + this.messageHandler.off("realm-destroyed", this.#onRealmDestroyed); + } + } + + #subscribeEvent(event) { + switch (event) { + case "script.realmCreated": { + this.#startListingOnRealmCreated(); + this.#subscribedEvents.add(event); + break; + } + case "script.realmDestroyed": { + this.#startListingOnRealmDestroyed(); + this.#subscribedEvents.add(event); + break; + } + } + } + + #unsubscribeEvent(event) { + switch (event) { + case "script.realmCreated": { + this.#stopListingOnRealmCreated(); + this.#subscribedEvents.delete(event); + break; + } + case "script.realmDestroyed": { + this.#stopListingOnRealmDestroyed(); + this.#subscribedEvents.delete(event); + break; + } + } + } + + _applySessionData(params) { + // TODO: Bug 1775231. Move this logic to a shared module or an abstract + // class. + const { category } = params; + if (category === "event") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#unsubscribeEvent(event); + } + } + + // Subscribe to all events, which have an item in SessionData. + for (const { value } of filteredSessionData) { + this.#subscribeEvent(value); + } + } + } + + static get supportedEvents() { + return ["script.message", "script.realmCreated", "script.realmDestroyed"]; + } +} + +export const script = ScriptModule; diff --git a/remote/webdriver-bidi/modules/root/session.sys.mjs b/remote/webdriver-bidi/modules/root/session.sys.mjs new file mode 100644 index 0000000000..a34ca514e3 --- /dev/null +++ b/remote/webdriver-bidi/modules/root/session.sys.mjs @@ -0,0 +1,419 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Marionette: "chrome://remote/content/components/Marionette.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +class SessionModule extends Module { + #browsingContextIdEventMap; + #globalEventSet; + + constructor(messageHandler) { + super(messageHandler); + + // Map with top-level browsing context id keys and values + // that are a set of event names for events + // that are enabled in the given browsing context. + // TODO: Bug 1804417. Use navigable instead of browsing context id. + this.#browsingContextIdEventMap = new Map(); + + // Set of event names which are strings of the form [moduleName].[eventName] + // for events that are enabled for all browsing contexts. + // We should only add an actual event listener on the MessageHandler the + // first time an event is subscribed to. + this.#globalEventSet = new Set(); + } + + destroy() { + this.#browsingContextIdEventMap = null; + this.#globalEventSet = null; + } + + /** + * Commands + */ + + /** + * End the current session. + * + * Session clean up will happen later in WebDriverBiDiConnection class. + */ + async end() { + if (lazy.Marionette.running) { + throw new lazy.error.UnsupportedOperationError( + "Ending session which was started with Webdriver classic is not supported, use Webdriver classic delete command instead." + ); + } + } + + /** + * Enable certain events either globally, or for a list of browsing contexts. + * + * @param {object=} params + * @param {Array<string>} params.events + * List of events to subscribe to. + * @param {Array<string>=} params.contexts + * Optional list of top-level browsing context ids + * to subscribe the events for. + * + * @throws {InvalidArgumentError} + * If <var>events</var> or <var>contexts</var> are not valid types. + */ + async subscribe(params = {}) { + const { events, contexts: contextIds = null } = params; + + // Check input types until we run schema validation. + lazy.assert.array(events, "events: array value expected"); + events.forEach(name => { + lazy.assert.string(name, `${name}: string value expected`); + }); + + if (contextIds !== null) { + lazy.assert.array(contextIds, "contexts: array value expected"); + contextIds.forEach(contextId => { + lazy.assert.string(contextId, `${contextId}: string value expected`); + }); + } + + const listeners = this.#updateEventMap(events, contextIds, true); + + // TODO: Bug 1801284. Add subscribe priority sorting of subscribeStepEvents (step 4 to 6, and 8). + + // Subscribe to the relevant engine-internal events. + await this.messageHandler.eventsDispatcher.update(listeners); + } + + /** + * Disable certain events either globally, or for a list of browsing contexts. + * + * @param {object=} params + * @param {Array<string>} params.events + * List of events to unsubscribe from. + * @param {Array<string>=} params.contexts + * Optional list of top-level browsing context ids + * to unsubscribe the events from. + * + * @throws {InvalidArgumentError} + * If <var>events</var> or <var>contexts</var> are not valid types. + */ + async unsubscribe(params = {}) { + const { events, contexts: contextIds = null } = params; + + // Check input types until we run schema validation. + lazy.assert.array(events, "events: array value expected"); + events.forEach(name => { + lazy.assert.string(name, `${name}: string value expected`); + }); + if (contextIds !== null) { + lazy.assert.array(contextIds, "contexts: array value expected"); + contextIds.forEach(contextId => { + lazy.assert.string(contextId, `${contextId}: string value expected`); + }); + } + + const listeners = this.#updateEventMap(events, contextIds, false); + + // Unsubscribe from the relevant engine-internal events. + await this.messageHandler.eventsDispatcher.update(listeners); + } + + #assertModuleSupportsEvent(moduleName, event) { + const rootModuleClass = this.#getRootModuleClass(moduleName); + if (!rootModuleClass?.supportsEvent(event)) { + throw new lazy.error.InvalidArgumentError( + `${event} is not a valid event name` + ); + } + } + + #getBrowserIdForContextId(contextId) { + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (!context) { + throw new lazy.error.NoSuchFrameError( + `Browsing context with id ${contextId} not found` + ); + } + + return context.browserId; + } + + #getRootModuleClass(moduleName) { + // Modules which support event subscriptions should have a root module + // defining supported events. + const rootDestination = { type: lazy.RootMessageHandler.type }; + const moduleClasses = this.messageHandler.getAllModuleClasses( + moduleName, + rootDestination + ); + + if (!moduleClasses.length) { + throw new lazy.error.InvalidArgumentError( + `Module ${moduleName} does not exist` + ); + } + + return moduleClasses[0]; + } + + #getTopBrowsingContextId(contextId) { + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (!context) { + throw new lazy.error.NoSuchFrameError( + `Browsing context with id ${contextId} not found` + ); + } + const topContext = context.top; + return lazy.TabManager.getIdForBrowsingContext(topContext); + } + + /** + * Obtain a set of events based on the given event name. + * + * Could contain a period for a specific event, + * or just the module name for all events. + * + * @param {string} event + * Name of the event to process. + * + * @returns {Set<string>} + * A Set with the expanded events in the form of `<module>.<event>`. + * + * @throws {InvalidArgumentError} + * If <var>event</var> does not reference a valid event. + */ + #obtainEvents(event) { + const events = new Set(); + + // Check if a period is present that splits the event name into the module, + // and the actual event. Hereby only care about the first found instance. + const index = event.indexOf("."); + if (index >= 0) { + const [moduleName] = event.split("."); + this.#assertModuleSupportsEvent(moduleName, event); + events.add(event); + } else { + // Interpret the name as module, and register all its available events + const rootModuleClass = this.#getRootModuleClass(event); + const supportedEvents = rootModuleClass?.supportedEvents; + + for (const eventName of supportedEvents) { + events.add(eventName); + } + } + + return events; + } + + /** + * Obtain a list of event enabled browsing context ids. + * + * @see https://w3c.github.io/webdriver-bidi/#event-enabled-browsing-contexts + * + * @param {string} eventName + * The name of the event. + * + * @returns {Set<string>} The set of browsing context. + */ + #obtainEventEnabledBrowsingContextIds(eventName) { + const contextIds = new Set(); + for (const [ + contextId, + events, + ] of this.#browsingContextIdEventMap.entries()) { + if (events.has(eventName)) { + // Check that a browsing context still exists for a given id + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (context) { + contextIds.add(contextId); + } + } + } + + return contextIds; + } + + #onMessageHandlerEvent = (name, event) => { + this.messageHandler.emitProtocolEvent(name, event); + }; + + /** + * Update global event state for top-level browsing contexts. + * + * @see https://w3c.github.io/webdriver-bidi/#update-the-event-map + * + * @param {Array<string>} requestedEventNames + * The list of the event names to run the update for. + * @param {Array<string>|null} browsingContextIds + * The list of the browsing context ids to update or null. + * @param {boolean} enabled + * True, if events have to be enabled. Otherwise false. + * + * @returns {Array<Subscription>} subscriptions + * The list of information to subscribe/unsubscribe to. + * + * @throws {InvalidArgumentError} + * If failed unsubscribe from event from <var>requestedEventNames</var> for + * browsing context id from <var>browsingContextIds</var>, if present. + */ + #updateEventMap(requestedEventNames, browsingContextIds, enabled) { + const globalEventSet = new Set(this.#globalEventSet); + const eventMap = structuredClone(this.#browsingContextIdEventMap); + + const eventNames = new Set(); + + requestedEventNames.forEach(name => { + this.#obtainEvents(name).forEach(event => eventNames.add(event)); + }); + const enabledEvents = new Map(); + const subscriptions = []; + + if (browsingContextIds === null) { + // Subscribe or unsubscribe events for all browsing contexts. + if (enabled) { + // Subscribe to each event. + + // Get the list of all top level browsing context ids. + const allTopBrowsingContextIds = lazy.TabManager.allBrowserUniqueIds; + + for (const eventName of eventNames) { + if (!globalEventSet.has(eventName)) { + const alreadyEnabledContextIds = + this.#obtainEventEnabledBrowsingContextIds(eventName); + globalEventSet.add(eventName); + for (const contextId of alreadyEnabledContextIds) { + eventMap.get(contextId).delete(eventName); + + // Since we're going to subscribe to all top-level + // browsing context ids to not have duplicate subscriptions, + // we have to unsubscribe from already subscribed. + subscriptions.push({ + event: eventName, + contextDescriptor: { + type: lazy.ContextDescriptorType.TopBrowsingContext, + id: this.#getBrowserIdForContextId(contextId), + }, + callback: this.#onMessageHandlerEvent, + enable: false, + }); + } + + // Get a list of all top-level browsing context ids + // that are not contained in alreadyEnabledContextIds. + const newlyEnabledContextIds = allTopBrowsingContextIds.filter( + contextId => !alreadyEnabledContextIds.has(contextId) + ); + + enabledEvents.set(eventName, newlyEnabledContextIds); + + subscriptions.push({ + event: eventName, + contextDescriptor: { + type: lazy.ContextDescriptorType.All, + }, + callback: this.#onMessageHandlerEvent, + enable: true, + }); + } + } + } else { + // Unsubscribe each event which has a global subscription. + for (const eventName of eventNames) { + if (globalEventSet.has(eventName)) { + globalEventSet.delete(eventName); + + subscriptions.push({ + event: eventName, + contextDescriptor: { + type: lazy.ContextDescriptorType.All, + }, + callback: this.#onMessageHandlerEvent, + enable: false, + }); + } else { + throw new lazy.error.InvalidArgumentError( + `Failed to unsubscribe from event ${eventName}` + ); + } + } + } + } else { + // Subscribe or unsubscribe events for given list of browsing context ids. + const targets = new Map(); + for (const contextId of browsingContextIds) { + const topLevelContextId = this.#getTopBrowsingContextId(contextId); + if (!eventMap.has(topLevelContextId)) { + eventMap.set(topLevelContextId, new Set()); + } + targets.set(topLevelContextId, eventMap.get(topLevelContextId)); + } + + for (const eventName of eventNames) { + // Do nothing if we want to subscribe, + // but the event has already a global subscription. + if (enabled && this.#globalEventSet.has(eventName)) { + continue; + } + for (const [contextId, target] of targets.entries()) { + // Subscribe if an event doesn't have a subscription for a specific context id. + if (enabled && !target.has(eventName)) { + target.add(eventName); + if (!enabledEvents.has(eventName)) { + enabledEvents.set(eventName, new Set()); + } + enabledEvents.get(eventName).add(contextId); + + subscriptions.push({ + event: eventName, + contextDescriptor: { + type: lazy.ContextDescriptorType.TopBrowsingContext, + id: this.#getBrowserIdForContextId(contextId), + }, + callback: this.#onMessageHandlerEvent, + enable: true, + }); + } else if (!enabled) { + // Unsubscribe from each event for a specific context id if the event has a subscription. + if (target.has(eventName)) { + target.delete(eventName); + + subscriptions.push({ + event: eventName, + contextDescriptor: { + type: lazy.ContextDescriptorType.TopBrowsingContext, + id: this.#getBrowserIdForContextId(contextId), + }, + callback: this.#onMessageHandlerEvent, + enable: false, + }); + } else { + throw new lazy.error.InvalidArgumentError( + `Failed to unsubscribe from event ${eventName} for context ${contextId}` + ); + } + } + } + } + } + + this.#globalEventSet = globalEventSet; + this.#browsingContextIdEventMap = eventMap; + + return subscriptions; + } +} + +// To export the class as lower-case +export const session = SessionModule; diff --git a/remote/webdriver-bidi/modules/root/storage.sys.mjs b/remote/webdriver-bidi/modules/root/storage.sys.mjs new file mode 100644 index 0000000000..50fbd8ecd6 --- /dev/null +++ b/remote/webdriver-bidi/modules/root/storage.sys.mjs @@ -0,0 +1,770 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + BytesValueType: + "chrome://remote/content/webdriver-bidi/modules/root/network.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +const CookieFieldsMapping = { + domain: "host", + expiry: "expiry", + httpOnly: "isHttpOnly", + name: "name", + path: "path", + sameSite: "sameSite", + secure: "isSecure", + size: "size", + value: "value", +}; + +const MAX_COOKIE_EXPIRY = Number.MAX_SAFE_INTEGER; + +/** + * Enum of possible partition types supported by the + * storage.getCookies command. + * + * @readonly + * @enum {PartitionType} + */ +const PartitionType = { + Context: "context", + StorageKey: "storageKey", +}; + +const PartitionKeyAttributes = ["sourceOrigin", "userContext"]; + +/** + * Enum of possible SameSite types supported by the + * storage.getCookies command. + * + * @readonly + * @enum {SameSiteType} + */ +const SameSiteType = { + [Ci.nsICookie.SAMESITE_NONE]: "none", + [Ci.nsICookie.SAMESITE_LAX]: "lax", + [Ci.nsICookie.SAMESITE_STRICT]: "strict", +}; + +class StorageModule extends Module { + destroy() {} + + /** + * Used as an argument for storage.getCookies command + * to represent fields which should be used to filter the output + * of the command. + * + * @typedef CookieFilter + * + * @property {string=} domain + * @property {number=} expiry + * @property {boolean=} httpOnly + * @property {string=} name + * @property {string=} path + * @property {SameSiteType=} sameSite + * @property {boolean=} secure + * @property {number=} size + * @property {Network.BytesValueType=} value + */ + + /** + * Used as an argument for storage.getCookies command as one of the available variants + * {BrowsingContextPartitionDescriptor} or {StorageKeyPartitionDescriptor}, to represent + * fields should be used to build a partition key. + * + * @typedef PartitionDescriptor + */ + + /** + * @typedef BrowsingContextPartitionDescriptor + * + * @property {PartitionType} [type=PartitionType.context] + * @property {string} context + */ + + /** + * @typedef StorageKeyPartitionDescriptor + * + * @property {PartitionType} [type=PartitionType.storageKey] + * @property {string=} sourceOrigin + * @property {string=} userContext (not supported) + */ + + /** + * @typedef PartitionKey + * + * @property {string=} sourceOrigin + * @property {string=} userContext (not supported) + */ + + /** + * An object that holds the result of storage.getCookies command. + * + * @typedef GetCookiesResult + * + * @property {Array<Cookie>} cookies + * List of cookies. + * @property {PartitionKey} partitionKey + * An object which represent the partition key which was used + * to retrieve the cookies. + */ + + /** + * Retrieve zero or more cookies which match a set of provided parameters. + * + * @param {object=} options + * @param {CookieFilter=} options.filter + * An object which holds field names and values, which + * should be used to filter the output of the command. + * @param {PartitionDescriptor=} options.partition + * An object which holds the information which + * should be used to build a partition key. + * + * @returns {GetCookiesResult} + * An object which holds a list of retrieved cookies and + * the partition key which was used. + * @throws {InvalidArgumentError} + * If the provided arguments are not valid. + * @throws {NoSuchFrameError} + * If the provided browsing context cannot be found. + * @throws {UnsupportedOperationError} + * Raised when the command is called with `userContext` as + * in `partition` argument. + */ + async getCookies(options = {}) { + let { filter = {} } = options; + const { partition: partitionSpec = null } = options; + + this.#assertPartition(partitionSpec); + filter = this.#assertGetCookieFilter(filter); + + const partitionKey = this.#expandStoragePartitionSpec(partitionSpec); + const store = this.#getTheCookieStore(partitionKey); + const cookies = this.#getMatchingCookies(store, filter); + + // Bug 1875255. Exchange platform id for Webdriver BiDi id for the user context to return it to the client. + // For now we use platform user context id for returning cookies for a specific browsing context in the platform API, + // but we can not return it directly to the client, so for now we just remove it from the response. + delete partitionKey.userContext; + + return { cookies, partitionKey }; + } + + /** + * An object representation of the cookie which should be set. + * + * @typedef PartialCookie + * + * @property {string} domain + * @property {number=} expiry + * @property {boolean=} httpOnly + * @property {string} name + * @property {string=} path + * @property {SameSiteType=} sameSite + * @property {boolean=} secure + * @property {number=} size + * @property {Network.BytesValueType} value + */ + + /** + * Create a new cookie in a cookie store. + * + * @param {object=} options + * @param {PartialCookie} options.cookie + * An object representation of the cookie which + * should be set. + * @param {PartitionDescriptor=} options.partition + * An object which holds the information which + * should be used to build a partition key. + * + * @returns {PartitionKey} + * An object with the partition key which was used to + * add the cookie. + * @throws {InvalidArgumentError} + * If the provided arguments are not valid. + * @throws {NoSuchFrameError} + * If the provided browsing context cannot be found. + * @throws {UnableToSetCookieError} + * If the cookie was not added. + * @throws {UnsupportedOperationError} + * Raised when the command is called with `userContext` as + * in `partition` argument. + */ + async setCookie(options = {}) { + const { cookie: cookieSpec, partition: partitionSpec = null } = options; + lazy.assert.object( + cookieSpec, + `Expected "cookie" to be an object, got ${cookieSpec}` + ); + + const { + domain, + expiry = null, + httpOnly = null, + name, + path = null, + sameSite = null, + secure = null, + value, + } = cookieSpec; + this.#assertCookie({ + domain, + expiry, + httpOnly, + name, + path, + sameSite, + secure, + value, + }); + this.#assertPartition(partitionSpec); + + const partitionKey = this.#expandStoragePartitionSpec(partitionSpec); + + // The cookie store is defined by originAttributes. + const originAttributes = this.#getOriginAttributes(partitionKey); + + const deserializedValue = this.#deserializeProtocolBytes(value); + + // The XPCOM interface requires to be specified if a cookie is session. + const isSession = expiry === null; + + let schemeType; + if (secure) { + schemeType = Ci.nsICookie.SCHEME_HTTPS; + } else { + schemeType = Ci.nsICookie.SCHEME_HTTP; + } + + try { + Services.cookies.add( + domain, + path === null ? "/" : path, + name, + deserializedValue, + secure === null ? false : secure, + httpOnly === null ? false : httpOnly, + isSession, + // The XPCOM interface requires the expiry field even for session cookies. + expiry === null ? MAX_COOKIE_EXPIRY : expiry, + originAttributes, + this.#getSameSitePlatformProperty(sameSite), + schemeType + ); + } catch (e) { + throw new lazy.error.UnableToSetCookieError(e); + } + + // Bug 1875255. Exchange platform id for Webdriver BiDi id for the user context to return it to the client. + // For now we use platform user context id for returning cookies for a specific browsing context in the platform API, + // but we can not return it directly to the client, so for now we just remove it from the response. + delete partitionKey.userContext; + + return { partitionKey }; + } + + #assertCookie(cookie) { + lazy.assert.object( + cookie, + `Expected "cookie" to be an object, got ${cookie}` + ); + + const { domain, expiry, httpOnly, name, path, sameSite, secure, value } = + cookie; + + lazy.assert.string( + domain, + `Expected "domain" to be a string, got ${domain}` + ); + + lazy.assert.string(name, `Expected "name" to be a string, got ${name}`); + + this.#assertValue(value); + + if (expiry !== null) { + lazy.assert.positiveInteger( + expiry, + `Expected "expiry" to be a positive number, got ${expiry}` + ); + } + + if (httpOnly !== null) { + lazy.assert.boolean( + httpOnly, + `Expected "httpOnly" to be a boolean, got ${httpOnly}` + ); + } + + if (path !== null) { + lazy.assert.string(path, `Expected "path" to be a string, got ${path}`); + } + + this.#assertSameSite(sameSite); + + if (secure !== null) { + lazy.assert.boolean( + secure, + `Expected "secure" to be a boolean, got ${secure}` + ); + } + } + + #assertGetCookieFilter(filter) { + lazy.assert.object( + filter, + `Expected "filter" to be an object, got ${filter}` + ); + + const { + domain = null, + expiry = null, + httpOnly = null, + name = null, + path = null, + sameSite = null, + secure = null, + size = null, + value = null, + } = filter; + + if (domain !== null) { + lazy.assert.string( + domain, + `Expected "filter.domain" to be a string, got ${domain}` + ); + } + + if (expiry !== null) { + lazy.assert.positiveInteger( + expiry, + `Expected "filter.expiry" to be a positive number, got ${expiry}` + ); + } + + if (httpOnly !== null) { + lazy.assert.boolean( + httpOnly, + `Expected "filter.httpOnly" to be a boolean, got ${httpOnly}` + ); + } + + if (name !== null) { + lazy.assert.string( + name, + `Expected "filter.name" to be a string, got ${name}` + ); + } + + if (path !== null) { + lazy.assert.string( + path, + `Expected "filter.path" to be a string, got ${path}` + ); + } + + this.#assertSameSite(sameSite, "filter.sameSite"); + + if (secure !== null) { + lazy.assert.boolean( + secure, + `Expected "filter.secure" to be a boolean, got ${secure}` + ); + } + + if (size !== null) { + lazy.assert.positiveInteger( + size, + `Expected "filter.size" to be a positive number, got ${size}` + ); + } + + if (value !== null) { + this.#assertValue(value, "filter.value"); + } + + return { + domain, + expiry, + httpOnly, + name, + path, + sameSite, + secure, + size, + value, + }; + } + + #assertPartition(partitionSpec) { + if (partitionSpec === null) { + return; + } + lazy.assert.object( + partitionSpec, + `Expected "partition" to be an object, got ${partitionSpec}` + ); + + const { type } = partitionSpec; + lazy.assert.string( + type, + `Expected "partition.type" to be a string, got ${type}` + ); + + switch (type) { + case PartitionType.Context: { + const { context } = partitionSpec; + lazy.assert.string( + context, + `Expected "partition.context" to be a string, got ${context}` + ); + + break; + } + + case PartitionType.StorageKey: { + const { sourceOrigin = null, userContext = null } = partitionSpec; + if (sourceOrigin !== null) { + lazy.assert.string( + sourceOrigin, + `Expected "partition.sourceOrigin" to be a string, got ${sourceOrigin}` + ); + lazy.assert.that( + sourceOrigin => URL.canParse(sourceOrigin), + `Expected "partition.sourceOrigin" to be a valid URL, got ${sourceOrigin}` + )(sourceOrigin); + + const url = new URL(sourceOrigin); + lazy.assert.that( + url => url.pathname === "/" && url.hash === "" && url.search === "", + `Expected "partition.sourceOrigin" to contain only origin, got ${sourceOrigin}` + )(url); + } + if (userContext !== null) { + lazy.assert.string( + userContext, + `Expected "partition.userContext" to be a string, got ${userContext}` + ); + + // TODO: Bug 1875255. Implement support for "userContext" field. + throw new lazy.error.UnsupportedOperationError( + `"userContext" as a field on "partition" argument is not supported yet for "storage.getCookies" command` + ); + } + break; + } + + default: { + throw new lazy.error.InvalidArgumentError( + `Expected "partition.type" to be one of ${Object.values( + PartitionType + )}, got ${type}` + ); + } + } + } + + #assertSameSite(sameSite, fieldName = "sameSite") { + if (sameSite !== null) { + const sameSiteTypeValue = Object.values(SameSiteType); + lazy.assert.in( + sameSite, + sameSiteTypeValue, + `Expected "${fieldName}" to be one of ${sameSiteTypeValue}, got ${sameSite}` + ); + } + } + + #assertValue(value, fieldName = "value") { + lazy.assert.object( + value, + `Expected "${fieldName}" to be an object, got ${value}` + ); + + const { type, value: protocolBytesValue } = value; + + const bytesValueTypeValue = Object.values(lazy.BytesValueType); + lazy.assert.in( + type, + bytesValueTypeValue, + `Expected "${fieldName}.type" to be one of ${bytesValueTypeValue}, got ${type}` + ); + + lazy.assert.string( + protocolBytesValue, + `Expected "${fieldName}.value" to be string, got ${protocolBytesValue}` + ); + } + + /** + * Deserialize the value to string, since platform API + * returns cookie's value as a string. + */ + #deserializeProtocolBytes(cookieValue) { + const { type, value } = cookieValue; + + if (type === lazy.BytesValueType.String) { + return value; + } + + // For type === BytesValueType.Base64. + return atob(value); + } + + /** + * Build a partition key. + * + * @see https://w3c.github.io/webdriver-bidi/#expand-a-storage-partition-spec + */ + #expandStoragePartitionSpec(partitionSpec) { + if (partitionSpec === null) { + partitionSpec = {}; + } + + if (partitionSpec.type === PartitionType.Context) { + const { context: contextId } = partitionSpec; + const browsingContext = this.#getBrowsingContext(contextId); + + // Define browsing context’s associated storage partition as combination of user context id + // and the origin of the document in this browsing context. + return { + sourceOrigin: browsingContext.currentURI.prePath, + userContext: browsingContext.originAttributes.userContextId, + }; + } + + const partitionKey = {}; + for (const keyName of PartitionKeyAttributes) { + if (keyName in partitionSpec) { + partitionKey[keyName] = partitionSpec[keyName]; + } + } + + return partitionKey; + } + + /** + * Retrieves a browsing context based on its id. + * + * @param {number} contextId + * Id of the browsing context. + * @returns {BrowsingContext} + * The browsing context. + * @throws {NoSuchFrameError} + * If the browsing context cannot be found. + */ + #getBrowsingContext(contextId) { + const context = lazy.TabManager.getBrowsingContextById(contextId); + if (context === null) { + throw new lazy.error.NoSuchFrameError( + `Browsing Context with id ${contextId} not found` + ); + } + + return context; + } + + /** + * Since cookies retrieved from the platform API + * always contain expiry even for session cookies, + * we should check ourselves if it's a session cookie + * and do not return expiry in case it is. + */ + #getCookieExpiry(cookie) { + const { expiry, isSession } = cookie; + return isSession ? null : expiry; + } + + #getCookieSize(cookie) { + const { name, value } = cookie; + return name.length + value.length; + } + + /** + * Filter and serialize given cookies with provided filter. + * + * @see https://w3c.github.io/webdriver-bidi/#get-matching-cookies + */ + #getMatchingCookies(cookieStore, filter) { + const cookies = []; + + for (const storedCookie of cookieStore) { + const serializedCookie = this.#serializeCookie(storedCookie); + if (this.#matchCookie(serializedCookie, filter)) { + cookies.push(serializedCookie); + } + } + return cookies; + } + + /** + * Prepare the data in the required for platform API format. + */ + #getOriginAttributes(partitionKey) { + const originAttributes = {}; + + if (partitionKey.sourceOrigin) { + originAttributes.partitionKey = ChromeUtils.getPartitionKeyFromURL( + partitionKey.sourceOrigin + ); + } + if ("userContext" in partitionKey) { + originAttributes.userContextId = partitionKey.userContext; + } + + return originAttributes; + } + + #getSameSitePlatformProperty(sameSite) { + switch (sameSite) { + case "lax": { + return Ci.nsICookie.SAMESITE_LAX; + } + case "strict": { + return Ci.nsICookie.SAMESITE_STRICT; + } + } + + return Ci.nsICookie.SAMESITE_NONE; + } + + /** + * Return a cookie store of the storage partition for a given storage partition key. + * + * The implementation differs here from the spec, since in gecko there is no + * direct way to get all the cookies for a given partition key. + * + * @see https://w3c.github.io/webdriver-bidi/#get-the-cookie-store + */ + #getTheCookieStore(storagePartitionKey) { + let store = []; + + // Prepare the data in the format required for the platform API. + const originAttributes = this.#getOriginAttributes(storagePartitionKey); + // In case we want to get the cookies for a certain `sourceOrigin`, + // we have to additionally specify `hostname`. When `sourceOrigin` is not present + // `hostname` will stay equal undefined. + let hostname; + + // In case we want to get the cookies for a certain `sourceOrigin`, + // we have to separately retrieve cookies for a hostname built from `sourceOrigin`, + // and with `partitionKey` equal an empty string to retrieve the cookies that which were set + // by this hostname but without `partitionKey`, e.g. with `document.cookie` + if (storagePartitionKey.sourceOrigin) { + const url = new URL(storagePartitionKey.sourceOrigin); + hostname = url.hostname; + + const principal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(url), + {} + ); + const isSecureProtocol = principal.isOriginPotentiallyTrustworthy; + + // We want to keep `userContext` id here, if it's present, + // but set the `partitionKey` to an empty string. + const cookiesMatchingHostname = + Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify({ ...originAttributes, partitionKey: "" }), + hostname + ); + + for (const cookie of cookiesMatchingHostname) { + // Ignore secure cookies for non-secure protocols. + if (cookie.isSecure && !isSecureProtocol) { + continue; + } + store.push(cookie); + } + } + + // Add the cookies which exactly match a built partition attributes. + store = store.concat( + Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify(originAttributes), + hostname + ) + ); + + return store; + } + + /** + * Match a provided cookie with provided filter. + * + * @see https://w3c.github.io/webdriver-bidi/#match-cookie + */ + #matchCookie(storedCookie, filter) { + for (const [fieldName] of Object.entries(CookieFieldsMapping)) { + let value = filter[fieldName]; + if (value !== null) { + let storedCookieValue = storedCookie[fieldName]; + + if (fieldName === "value") { + value = this.#deserializeProtocolBytes(value); + storedCookieValue = this.#deserializeProtocolBytes(storedCookieValue); + } + + if (storedCookieValue !== value) { + return false; + } + } + } + + return true; + } + + /** + * Serialize a cookie. + * + * @see https://w3c.github.io/webdriver-bidi/#serialize-cookie + */ + #serializeCookie(storedCookie) { + const cookie = {}; + for (const [serializedName, cookieName] of Object.entries( + CookieFieldsMapping + )) { + switch (serializedName) { + case "expiry": { + const expiry = this.#getCookieExpiry(storedCookie); + if (expiry !== null) { + cookie.expiry = expiry; + } + break; + } + + case "sameSite": + cookie.sameSite = SameSiteType[storedCookie.sameSite]; + break; + + case "size": + cookie.size = this.#getCookieSize(storedCookie); + break; + + case "value": + // Bug 1879309. Add support for non-UTF8 cookies, + // when a byte representation of value is available. + // For now, use a value field, which is returned as a string. + cookie.value = { + type: lazy.BytesValueType.String, + value: storedCookie.value, + }; + break; + + default: + cookie[serializedName] = storedCookie[cookieName]; + } + } + + return cookie; + } +} + +export const storage = StorageModule; diff --git a/remote/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs b/remote/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs new file mode 100644 index 0000000000..45cee66eb3 --- /dev/null +++ b/remote/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs @@ -0,0 +1,43 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +class BrowsingContextModule extends Module { + destroy() {} + + interceptEvent(name, payload) { + if ( + name == "browsingContext.domContentLoaded" || + name == "browsingContext.load" + ) { + const browsingContext = payload.context; + if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) { + // Discard events for invalid browsing contexts. + return null; + } + + // Resolve browsing context to a Navigable id. + payload.context = + lazy.TabManager.getIdForBrowsingContext(browsingContext); + + // Resolve navigation id. + const navigation = + this.messageHandler.navigationManager.getNavigationForBrowsingContext( + browsingContext + ); + payload.navigation = navigation ? navigation.navigationId : null; + } + + return payload; + } +} + +export const browsingContext = BrowsingContextModule; diff --git a/remote/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs b/remote/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs new file mode 100644 index 0000000000..4cfbc61e63 --- /dev/null +++ b/remote/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs @@ -0,0 +1,37 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + processExtraData: + "chrome://remote/content/webdriver-bidi/modules/Intercept.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +class LogModule extends Module { + destroy() {} + + interceptEvent(name, payload) { + if (name == "log.entryAdded") { + const browsingContext = payload.source.context; + if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) { + // Discard events for invalid browsing contexts. + return null; + } + + // Resolve browsing context to a Navigable id. + payload.source.context = + lazy.TabManager.getIdForBrowsingContext(browsingContext); + + payload = lazy.processExtraData(this.messageHandler.sessionId, payload); + } + + return payload; + } +} + +export const log = LogModule; diff --git a/remote/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs b/remote/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs new file mode 100644 index 0000000000..30af29dd21 --- /dev/null +++ b/remote/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs @@ -0,0 +1,37 @@ +/* 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/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + processExtraData: + "chrome://remote/content/webdriver-bidi/modules/Intercept.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +class ScriptModule extends Module { + destroy() {} + + interceptEvent(name, payload) { + if (name == "script.message") { + const browsingContext = payload.source.context; + if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) { + // Discard events for invalid browsing contexts. + return null; + } + + // Resolve browsing context to a Navigable id. + payload.source.context = + lazy.TabManager.getIdForBrowsingContext(browsingContext); + + payload = lazy.processExtraData(this.messageHandler.sessionId, payload); + } + + return payload; + } +} + +export const script = ScriptModule; diff --git a/remote/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs b/remote/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs new file mode 100644 index 0000000000..8421445d2c --- /dev/null +++ b/remote/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs @@ -0,0 +1,475 @@ +/* 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/. */ + +import { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + ClipRectangleType: + "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + LoadListener: "chrome://remote/content/shared/listeners/LoadListener.sys.mjs", + LocatorType: + "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs", + OriginType: + "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs", +}); + +const DOCUMENT_FRAGMENT_NODE = 11; +const DOCUMENT_NODE = 9; +const ELEMENT_NODE = 1; + +const ORDERED_NODE_SNAPSHOT_TYPE = 7; + +class BrowsingContextModule extends WindowGlobalBiDiModule { + #loadListener; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + + // Setup the LoadListener as early as possible. + this.#loadListener = new lazy.LoadListener(this.messageHandler.window); + this.#loadListener.on("DOMContentLoaded", this.#onDOMContentLoaded); + this.#loadListener.on("load", this.#onLoad); + + // Set of event names which have active subscriptions. + this.#subscribedEvents = new Set(); + } + + destroy() { + this.#loadListener.destroy(); + this.#subscribedEvents = null; + } + + #getNavigationInfo(data) { + // Note: the navigation id is collected in the parent-process and will be + // added via event interception by the windowglobal-in-root module. + return { + context: this.messageHandler.context, + timestamp: Date.now(), + url: data.target.URL, + }; + } + + #getOriginRectangle(origin) { + const win = this.messageHandler.window; + + if (origin === lazy.OriginType.viewport) { + const viewport = win.visualViewport; + // Until it's clarified in the scope of the issue: + // https://github.com/w3c/webdriver-bidi/issues/592 + // if we should take into account scrollbar dimensions, when calculating + // the viewport size, we match the behavior of WebDriver Classic, + // meaning we include scrollbar dimensions. + return new DOMRect( + viewport.pageLeft, + viewport.pageTop, + win.innerWidth, + win.innerHeight + ); + } + + const documentElement = win.document.documentElement; + return new DOMRect( + 0, + 0, + documentElement.scrollWidth, + documentElement.scrollHeight + ); + } + + #startListening() { + if (this.#subscribedEvents.size == 0) { + this.#loadListener.startListening(); + } + } + + #stopListening() { + if (this.#subscribedEvents.size == 0) { + this.#loadListener.stopListening(); + } + } + + #subscribeEvent(event) { + switch (event) { + case "browsingContext._documentInteractive": + this.#startListening(); + this.#subscribedEvents.add("browsingContext._documentInteractive"); + break; + case "browsingContext.domContentLoaded": + this.#startListening(); + this.#subscribedEvents.add("browsingContext.domContentLoaded"); + break; + case "browsingContext.load": + this.#startListening(); + this.#subscribedEvents.add("browsingContext.load"); + break; + } + } + + #unsubscribeEvent(event) { + switch (event) { + case "browsingContext._documentInteractive": + this.#subscribedEvents.delete("browsingContext._documentInteractive"); + break; + case "browsingContext.domContentLoaded": + this.#subscribedEvents.delete("browsingContext.domContentLoaded"); + break; + case "browsingContext.load": + this.#subscribedEvents.delete("browsingContext.load"); + break; + } + + this.#stopListening(); + } + + #onDOMContentLoaded = (eventName, data) => { + if (this.#subscribedEvents.has("browsingContext._documentInteractive")) { + this.messageHandler.emitEvent("browsingContext._documentInteractive", { + baseURL: data.target.baseURI, + contextId: this.messageHandler.contextId, + documentURL: data.target.URL, + innerWindowId: this.messageHandler.innerWindowId, + readyState: data.target.readyState, + }); + } + + if (this.#subscribedEvents.has("browsingContext.domContentLoaded")) { + this.emitEvent( + "browsingContext.domContentLoaded", + this.#getNavigationInfo(data) + ); + } + }; + + #onLoad = (eventName, data) => { + if (this.#subscribedEvents.has("browsingContext.load")) { + this.emitEvent("browsingContext.load", this.#getNavigationInfo(data)); + } + }; + + /** + * Locate nodes using css selector. + * + * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-css + */ + #locateNodesUsingCss(contextNodes, selector, maxReturnedNodeCount) { + const returnedNodes = []; + + for (const contextNode of contextNodes) { + let elements; + try { + elements = contextNode.querySelectorAll(selector); + } catch (e) { + throw new lazy.error.InvalidSelectorError( + `${e.message}: "${selector}"` + ); + } + + if (maxReturnedNodeCount === null) { + returnedNodes.push(...elements); + } else { + for (const element of elements) { + returnedNodes.push(element); + + if (returnedNodes.length === maxReturnedNodeCount) { + return returnedNodes; + } + } + } + } + + return returnedNodes; + } + + /** + * Locate nodes using XPath. + * + * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-xpath + */ + #locateNodesUsingXPath(contextNodes, selector, maxReturnedNodeCount) { + const returnedNodes = []; + + for (const contextNode of contextNodes) { + let evaluationResult; + try { + evaluationResult = this.messageHandler.window.document.evaluate( + selector, + contextNode, + null, + ORDERED_NODE_SNAPSHOT_TYPE, + null + ); + } catch (e) { + const errorMessage = `${e.message}: "${selector}"`; + if (DOMException.isInstance(e) && e.name === "SyntaxError") { + throw new lazy.error.InvalidSelectorError(errorMessage); + } + + throw new lazy.error.UnknownError(errorMessage); + } + + for (let index = 0; index < evaluationResult.snapshotLength; index++) { + const node = evaluationResult.snapshotItem(index); + returnedNodes.push(node); + + if ( + maxReturnedNodeCount !== null && + returnedNodes.length === maxReturnedNodeCount + ) { + return returnedNodes; + } + } + } + + return returnedNodes; + } + + /** + * Normalize rectangle. This ensures that the resulting rect has + * positive width and height dimensions. + * + * @see https://w3c.github.io/webdriver-bidi/#normalise-rect + * + * @param {DOMRect} rect + * An object which describes the size and position of a rectangle. + * + * @returns {DOMRect} Normalized rectangle. + */ + #normalizeRect(rect) { + let { x, y, width, height } = rect; + + if (width < 0) { + x += width; + width = -width; + } + + if (height < 0) { + y += height; + height = -height; + } + + return new DOMRect(x, y, width, height); + } + + /** + * Create a new rectangle which will be an intersection of + * rectangles specified as arguments. + * + * @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection + * + * @param {DOMRect} rect1 + * An object which describes the size and position of a rectangle. + * @param {DOMRect} rect2 + * An object which describes the size and position of a rectangle. + * + * @returns {DOMRect} Rectangle, representing an intersection of <var>rect1</var> and <var>rect2</var>. + */ + #rectangleIntersection(rect1, rect2) { + rect1 = this.#normalizeRect(rect1); + rect2 = this.#normalizeRect(rect2); + + const x_min = Math.max(rect1.x, rect2.x); + const x_max = Math.min(rect1.x + rect1.width, rect2.x + rect2.width); + + const y_min = Math.max(rect1.y, rect2.y); + const y_max = Math.min(rect1.y + rect1.height, rect2.y + rect2.height); + + const width = Math.max(x_max - x_min, 0); + const height = Math.max(y_max - y_min, 0); + + return new DOMRect(x_min, y_min, width, height); + } + + /** + * Internal commands + */ + + _applySessionData(params) { + // TODO: Bug 1775231. Move this logic to a shared module or an abstract + // class. + const { category } = params; + if (category === "event") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#unsubscribeEvent(event); + } + } + + // Subscribe to all events, which have an item in SessionData. + for (const { value } of filteredSessionData) { + this.#subscribeEvent(value); + } + } + } + + /** + * Waits until the viewport has reached the new dimensions. + * + * @param {object} options + * @param {number} options.height + * Expected height the viewport will resize to. + * @param {number} options.width + * Expected width the viewport will resize to. + * + * @returns {Promise} + * Promise that resolves when the viewport has been resized. + */ + async _awaitViewportDimensions(options) { + const { height, width } = options; + + const win = this.messageHandler.window; + let resized; + + // Updates for background tabs are throttled, and we also have to make + // sure that the new browser dimensions have been received by the content + // process. As such wait for the next animation frame. + await lazy.AnimationFramePromise(win); + + const checkBrowserSize = () => { + if (win.innerWidth === width && win.innerHeight === height) { + resized(); + } + }; + + return new Promise(resolve => { + resized = resolve; + + win.addEventListener("resize", checkBrowserSize); + + // Trigger a layout flush in case none happened yet. + checkBrowserSize(); + }).finally(() => { + win.removeEventListener("resize", checkBrowserSize); + }); + } + + _getBaseURL() { + return this.messageHandler.window.document.baseURI; + } + + _getScreenshotRect(params = {}) { + const { clip, origin } = params; + + const originRect = this.#getOriginRectangle(origin); + let clipRect = originRect; + + if (clip !== null) { + switch (clip.type) { + case lazy.ClipRectangleType.Box: { + clipRect = new DOMRect( + clip.x + originRect.x, + clip.y + originRect.y, + clip.width, + clip.height + ); + break; + } + + case lazy.ClipRectangleType.Element: { + const realm = this.messageHandler.getRealm(); + const element = this.deserialize(clip.element, realm); + const viewportRect = this.#getOriginRectangle( + lazy.OriginType.viewport + ); + const elementRect = element.getBoundingClientRect(); + + clipRect = new DOMRect( + elementRect.x + viewportRect.x, + elementRect.y + viewportRect.y, + elementRect.width, + elementRect.height + ); + break; + } + } + } + + return this.#rectangleIntersection(originRect, clipRect); + } + + _locateNodes(params = {}) { + const { + locator, + maxNodeCount, + resultOwnership, + sandbox, + serializationOptions, + startNodes, + } = params; + + const realm = this.messageHandler.getRealm({ sandboxName: sandbox }); + + const contextNodes = []; + if (startNodes === null) { + contextNodes.push(this.messageHandler.window.document.documentElement); + } else { + for (const serializedStartNode of startNodes) { + const startNode = this.deserialize(serializedStartNode, realm); + lazy.assert.that( + startNode => + Node.isInstance(startNode) && + [DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, ELEMENT_NODE].includes( + startNode.nodeType + ), + `Expected an item of "startNodes" to be an Element, got ${startNode}` + )(startNode); + + contextNodes.push(startNode); + } + } + + let returnedNodes; + switch (locator.type) { + case lazy.LocatorType.css: { + returnedNodes = this.#locateNodesUsingCss( + contextNodes, + locator.value, + maxNodeCount + ); + break; + } + case lazy.LocatorType.xpath: { + returnedNodes = this.#locateNodesUsingXPath( + contextNodes, + locator.value, + maxNodeCount + ); + break; + } + } + + const serializedNodes = []; + const seenNodeIds = new Map(); + for (const returnedNode of returnedNodes) { + serializedNodes.push( + this.serialize( + returnedNode, + serializationOptions, + resultOwnership, + realm, + { seenNodeIds } + ) + ); + } + + return { + serializedNodes, + _extraData: { seenNodeIds }, + }; + } +} + +export const browsingContext = BrowsingContextModule; diff --git a/remote/webdriver-bidi/modules/windowglobal/input.sys.mjs b/remote/webdriver-bidi/modules/windowglobal/input.sys.mjs new file mode 100644 index 0000000000..099cf53d46 --- /dev/null +++ b/remote/webdriver-bidi/modules/windowglobal/input.sys.mjs @@ -0,0 +1,111 @@ +/* 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/. */ + +import { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + action: "chrome://remote/content/shared/webdriver/Actions.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +class InputModule extends WindowGlobalBiDiModule { + #actionState; + + constructor(messageHandler) { + super(messageHandler); + + this.#actionState = null; + } + + destroy() {} + + async performActions(options) { + const { actions } = options; + if (this.#actionState === null) { + this.#actionState = new lazy.action.State(); + } + + await this.#deserializeActionOrigins(actions); + const actionChain = lazy.action.Chain.fromJSON(this.#actionState, actions); + + await actionChain.dispatch(this.#actionState, this.messageHandler.window); + } + + async releaseActions() { + if (this.#actionState === null) { + return; + } + await this.#actionState.release(this.messageHandler.window); + this.#actionState = null; + } + + /** + * In the provided array of input.SourceActions, replace all origins matching + * the input.ElementOrigin production with the Element corresponding to this + * origin. + * + * Note that this method replaces the content of the `actions` in place, and + * does not return a new array. + * + * @param {Array<input.SourceActions>} actions + * The array of SourceActions to deserialize. + * @returns {Promise} + * A promise which resolves when all ElementOrigin origins have been + * deserialized. + */ + async #deserializeActionOrigins(actions) { + const promises = []; + + if (!Array.isArray(actions)) { + // Silently ignore invalid action chains because they are fully parsed later. + return Promise.resolve(); + } + + for (const actionsByTick of actions) { + if (!Array.isArray(actionsByTick?.actions)) { + // Silently ignore invalid actions because they are fully parsed later. + return Promise.resolve(); + } + + for (const action of actionsByTick.actions) { + if (action?.origin?.type === "element") { + promises.push( + (async () => { + action.origin = await this.#getElementFromElementOrigin( + action.origin + ); + })() + ); + } + } + } + + return Promise.all(promises); + } + + async #getElementFromElementOrigin(origin) { + const sharedReference = origin.element; + if (typeof sharedReference?.sharedId !== "string") { + throw new lazy.error.InvalidArgumentError( + `Expected "origin.element" to be a SharedReference, got: ${sharedReference}` + ); + } + + const realm = this.messageHandler.getRealm(); + + const element = this.deserialize(sharedReference, realm); + if (!lazy.dom.isElement(element)) { + throw new lazy.error.NoSuchElementError( + `No element found for shared id: ${sharedReference.sharedId}` + ); + } + + return element; + } +} + +export const input = InputModule; diff --git a/remote/webdriver-bidi/modules/windowglobal/log.sys.mjs b/remote/webdriver-bidi/modules/windowglobal/log.sys.mjs new file mode 100644 index 0000000000..9f3934c1bd --- /dev/null +++ b/remote/webdriver-bidi/modules/windowglobal/log.sys.mjs @@ -0,0 +1,256 @@ +/* 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/. */ + +import { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ConsoleAPIListener: + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs", + ConsoleListener: + "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs", + isChromeFrame: "chrome://remote/content/shared/Stack.sys.mjs", + OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", + setDefaultSerializationOptions: + "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", +}); + +class LogModule extends WindowGlobalBiDiModule { + #consoleAPIListener; + #consoleMessageListener; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + + // Create the console-api listener and listen on "message" events. + this.#consoleAPIListener = new lazy.ConsoleAPIListener( + this.messageHandler.innerWindowId + ); + this.#consoleAPIListener.on("message", this.#onConsoleAPIMessage); + + // Create the console listener and listen on error messages. + this.#consoleMessageListener = new lazy.ConsoleListener( + this.messageHandler.innerWindowId + ); + this.#consoleMessageListener.on("error", this.#onJavaScriptError); + + // Set of event names which have active subscriptions. + this.#subscribedEvents = new Set(); + } + + destroy() { + this.#consoleAPIListener.off("message", this.#onConsoleAPIMessage); + this.#consoleAPIListener.destroy(); + this.#consoleMessageListener.off("error", this.#onJavaScriptError); + this.#consoleMessageListener.destroy(); + + this.#subscribedEvents = null; + } + + #buildSource(realm) { + return { + realm: realm.id, + context: this.messageHandler.context, + }; + } + + /** + * Map the internal stacktrace representation to a WebDriver BiDi + * compatible one. + * + * Currently chrome frames will be filtered out until chrome scope + * is supported (bug 1722679). + * + * @param {Array<StackFrame>=} stackTrace + * Stack frames to process. + * + * @returns {object=} Object, containing the list of frames as `callFrames`. + */ + #buildStackTrace(stackTrace) { + if (stackTrace == undefined) { + return undefined; + } + + const callFrames = stackTrace + .filter(frame => !lazy.isChromeFrame(frame)) + .map(frame => { + return { + columnNumber: frame.columnNumber - 1, + functionName: frame.functionName, + lineNumber: frame.lineNumber - 1, + url: frame.filename, + }; + }); + + return { callFrames }; + } + + #getLogEntryLevelFromConsoleMethod(method) { + switch (method) { + case "assert": + case "error": + return "error"; + case "debug": + case "trace": + return "debug"; + case "warn": + return "warn"; + default: + return "info"; + } + } + + #onConsoleAPIMessage = (eventName, data = {}) => { + const { + // `arguments` cannot be used as variable name in functions + arguments: messageArguments, + // `level` corresponds to the console method used + level: method, + stacktrace, + timeStamp, + } = data; + + // Step numbers below refer to the specifications at + // https://w3c.github.io/webdriver-bidi/#event-log-entryAdded + + // Translate the console message method to a log.LogEntry level + const logEntrylevel = this.#getLogEntryLevelFromConsoleMethod(method); + + // Use the message's timeStamp or fallback on the current time value. + const timestamp = timeStamp || Date.now(); + + // Start assembling the text representation of the message. + let text = ""; + + // Formatters have already been applied at this points. + // message.arguments corresponds to the "formatted args" from the + // specifications. + + // Concatenate all formatted arguments in text + // TODO: For m1 we only support string arguments, so we rely on the builtin + // toString for each argument which will be available in message.arguments. + const args = messageArguments || []; + text += args.map(String).join(" "); + + const defaultRealm = this.messageHandler.getRealm(); + const serializedArgs = []; + const seenNodeIds = new Map(); + + // Serialize each arg as remote value. + for (const arg of args) { + // Note that we can pass a default realm for now since realms are only + // involved when creating object references, which will not happen with + // OwnershipModel.None. This will be revisited in Bug 1742589. + serializedArgs.push( + this.serialize( + Cu.waiveXrays(arg), + lazy.setDefaultSerializationOptions(), + lazy.OwnershipModel.None, + defaultRealm, + { seenNodeIds } + ) + ); + } + + // Set source to an object which contains realm and browsing context. + // TODO: Bug 1742589. Use an actual realm from which the event came from. + const source = this.#buildSource(defaultRealm); + + // Set stack trace only for certain methods. + let stackTrace; + if (["assert", "error", "trace", "warn"].includes(method)) { + stackTrace = this.#buildStackTrace(stacktrace); + } + + // Build the ConsoleLogEntry + const entry = { + type: "console", + method, + source, + args: serializedArgs, + level: logEntrylevel, + text, + timestamp, + stackTrace, + _extraData: { seenNodeIds }, + }; + + // TODO: Those steps relate to: + // - emitting associated BrowsingContext. See log.entryAdded full support + // in https://bugzilla.mozilla.org/show_bug.cgi?id=1724669#c0 + // - handling cases where session doesn't exist or the event is not + // monitored. The implementation differs from the spec here because we + // only react to events if there is a session & if the session subscribed + // to those events. + + this.emitEvent("log.entryAdded", entry); + }; + + #onJavaScriptError = (eventName, data = {}) => { + const { level, message, stacktrace, timeStamp } = data; + const defaultRealm = this.messageHandler.getRealm(); + + // Build the JavascriptLogEntry + const entry = { + type: "javascript", + level, + // TODO: Bug 1742589. Use an actual realm from which the event came from. + source: this.#buildSource(defaultRealm), + text: message, + timestamp: timeStamp || Date.now(), + stackTrace: this.#buildStackTrace(stacktrace), + }; + + this.emitEvent("log.entryAdded", entry); + }; + + #subscribeEvent(event) { + if (event === "log.entryAdded") { + this.#consoleAPIListener.startListening(); + this.#consoleMessageListener.startListening(); + this.#subscribedEvents.add(event); + } + } + + #unsubscribeEvent(event) { + if (event === "log.entryAdded") { + this.#consoleAPIListener.stopListening(); + this.#consoleMessageListener.stopListening(); + this.#subscribedEvents.delete(event); + } + } + + /** + * Internal commands + */ + + _applySessionData(params) { + // TODO: Bug 1775231. Move this logic to a shared module or an abstract + // class. + const { category } = params; + if (category === "event") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#unsubscribeEvent(event); + } + } + + // Subscribe to all events, which have an item in SessionData. + for (const { value } of filteredSessionData) { + this.#subscribeEvent(value); + } + } + } +} + +export const log = LogModule; diff --git a/remote/webdriver-bidi/modules/windowglobal/script.sys.mjs b/remote/webdriver-bidi/modules/windowglobal/script.sys.mjs new file mode 100644 index 0000000000..e0f9542bdd --- /dev/null +++ b/remote/webdriver-bidi/modules/windowglobal/script.sys.mjs @@ -0,0 +1,493 @@ +/* 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/. */ + +import { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + getFramesFromStack: "chrome://remote/content/shared/Stack.sys.mjs", + isChromeFrame: "chrome://remote/content/shared/Stack.sys.mjs", + OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", + setDefaultSerializationOptions: + "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", + stringify: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", +}); + +/** + * @typedef {string} EvaluationStatus + */ + +/** + * Enum of possible evaluation states. + * + * @readonly + * @enum {EvaluationStatus} + */ +const EvaluationStatus = { + Normal: "normal", + Throw: "throw", +}; + +class ScriptModule extends WindowGlobalBiDiModule { + #observerListening; + #preloadScripts; + + constructor(messageHandler) { + super(messageHandler); + + // Set of structs with an item named expression, which is a string, + // and an item named sandbox which is a string or null. + this.#preloadScripts = new Set(); + } + + destroy() { + this.#preloadScripts = null; + + this.#stopObserving(); + } + + observe(subject, topic) { + if (topic !== "document-element-inserted") { + return; + } + + const window = subject?.defaultView; + + // Ignore events without a window and those from other tabs. + if (window === this.messageHandler.window) { + this.#evaluatePreloadScripts(); + } + } + + #buildExceptionDetails( + exception, + stack, + realm, + resultOwnership, + seenNodeIds + ) { + exception = this.#toRawObject(exception); + + // A stacktrace is mandatory to build exception details and a missing stack + // means we encountered an unexpected issue. Throw with an explicit error. + if (!stack) { + throw new Error( + `Missing stack, unable to build exceptionDetails for exception: ${lazy.stringify( + exception + )}` + ); + } + + const frames = lazy.getFramesFromStack(stack) || []; + const callFrames = frames + // Remove chrome/internal frames + .filter(frame => !lazy.isChromeFrame(frame)) + // Translate frames from getFramesFromStack to frames expected by + // WebDriver BiDi. + .map(frame => { + return { + columnNumber: frame.columnNumber - 1, + functionName: frame.functionName, + lineNumber: frame.lineNumber - 1, + url: frame.filename, + }; + }); + + return { + columnNumber: stack.column - 1, + exception: this.serialize( + exception, + lazy.setDefaultSerializationOptions(), + resultOwnership, + realm, + { seenNodeIds } + ), + lineNumber: stack.line - 1, + stackTrace: { callFrames }, + text: lazy.stringify(exception), + }; + } + + async #buildReturnValue( + rv, + realm, + awaitPromise, + resultOwnership, + serializationOptions + ) { + let evaluationStatus, exception, result, stack; + + if ("return" in rv) { + evaluationStatus = EvaluationStatus.Normal; + if ( + awaitPromise && + // Only non-primitive return values are wrapped in Debugger.Object. + rv.return instanceof Debugger.Object && + rv.return.isPromise + ) { + try { + // Force wrapping the promise resolution result in a Debugger.Object + // wrapper for consistency with the synchronous codepath. + const asyncResult = await rv.return.unsafeDereference(); + result = realm.globalObjectReference.makeDebuggeeValue(asyncResult); + } catch (asyncException) { + evaluationStatus = EvaluationStatus.Throw; + exception = + realm.globalObjectReference.makeDebuggeeValue(asyncException); + + // If the returned promise was rejected by calling its reject callback + // the stack will be available on promiseResolutionSite. + // Otherwise, (eg. rejected Promise chained with a then() call) we + // fallback on the promiseAllocationSite. + stack = + rv.return.promiseResolutionSite || rv.return.promiseAllocationSite; + } + } else { + // rv.return is a Debugger.Object or a primitive. + result = rv.return; + } + } else if ("throw" in rv) { + // rv.throw will be set if the evaluation synchronously failed, either if + // the script contains a syntax error or throws an exception. + evaluationStatus = EvaluationStatus.Throw; + exception = rv.throw; + stack = rv.stack; + } + + const seenNodeIds = new Map(); + switch (evaluationStatus) { + case EvaluationStatus.Normal: + const dataSuccess = this.serialize( + this.#toRawObject(result), + serializationOptions, + resultOwnership, + realm, + { seenNodeIds } + ); + + return { + evaluationStatus, + realmId: realm.id, + result: dataSuccess, + _extraData: { seenNodeIds }, + }; + case EvaluationStatus.Throw: + const dataThrow = this.#buildExceptionDetails( + exception, + stack, + realm, + resultOwnership, + seenNodeIds + ); + + return { + evaluationStatus, + exceptionDetails: dataThrow, + realmId: realm.id, + _extraData: { seenNodeIds }, + }; + default: + throw new lazy.error.UnsupportedOperationError( + `Unsupported completion value for expression evaluation` + ); + } + } + + /** + * Emit "script.message" event with provided data. + * + * @param {Realm} realm + * @param {ChannelProperties} channelProperties + * @param {RemoteValue} message + */ + #emitScriptMessage = (realm, channelProperties, message) => { + const { + channel, + ownership: ownershipType = lazy.OwnershipModel.None, + serializationOptions, + } = channelProperties; + + const seenNodeIds = new Map(); + const data = this.serialize( + this.#toRawObject(message), + lazy.setDefaultSerializationOptions(serializationOptions), + ownershipType, + realm, + { seenNodeIds } + ); + + this.emitEvent("script.message", { + channel, + data, + source: this.#getSource(realm), + _extraData: { seenNodeIds }, + }); + }; + + #evaluatePreloadScripts() { + let resolveBlockerPromise; + const blockerPromise = new Promise(resolve => { + resolveBlockerPromise = resolve; + }); + + // Block script parsing. + this.messageHandler.window.document.blockParsing(blockerPromise); + for (const script of this.#preloadScripts.values()) { + const { + arguments: commandArguments, + functionDeclaration, + sandbox, + } = script; + const realm = this.messageHandler.getRealm({ sandboxName: sandbox }); + const deserializedArguments = commandArguments.map(arg => + this.deserialize(arg, realm, { + emitScriptMessage: this.#emitScriptMessage, + }) + ); + const rv = realm.executeInGlobalWithBindings( + functionDeclaration, + deserializedArguments + ); + + if ("throw" in rv) { + const exception = this.#toRawObject(rv.throw); + realm.reportError(lazy.stringify(exception), rv.stack); + } + } + + // Continue script parsing. + resolveBlockerPromise(); + } + + #getSource(realm) { + return { + realm: realm.id, + context: this.messageHandler.context, + }; + } + + #startObserving() { + if (!this.#observerListening) { + Services.obs.addObserver(this, "document-element-inserted"); + this.#observerListening = true; + } + } + + #stopObserving() { + if (this.#observerListening) { + Services.obs.removeObserver(this, "document-element-inserted"); + this.#observerListening = false; + } + } + + #toRawObject(maybeDebuggerObject) { + if (maybeDebuggerObject instanceof Debugger.Object) { + // Retrieve the referent for the provided Debugger.object. + // See https://firefox-source-docs.mozilla.org/devtools-user/debugger-api/debugger.object/index.html + const rawObject = maybeDebuggerObject.unsafeDereference(); + + // TODO: Getters for Maps and Sets iterators return "Opaque" objects and + // are not iterable. RemoteValue.jsm' serializer should handle calling + // waiveXrays on Maps/Sets/... and then unwaiveXrays on entries but since + // we serialize with maxDepth=1, calling waiveXrays once on the root + // object allows to return correctly serialized values. + return Cu.waiveXrays(rawObject); + } + + // If maybeDebuggerObject was not a Debugger.Object, it is a primitive value + // which can be used as is. + return maybeDebuggerObject; + } + + /** + * Call a function in the current window global. + * + * @param {object} options + * @param {boolean} options.awaitPromise + * Determines if the command should wait for the return value of the + * expression to resolve, if this return value is a Promise. + * @param {Array<RemoteValue>=} options.commandArguments + * The arguments to pass to the function call. + * @param {string} options.functionDeclaration + * The body of the function to call. + * @param {string=} options.realmId + * The id of the realm. + * @param {OwnershipModel} options.resultOwnership + * The ownership model to use for the results of this evaluation. + * @param {string=} options.sandbox + * The name of the sandbox. + * @param {SerializationOptions=} options.serializationOptions + * An object which holds the information of how the result of evaluation + * in case of ECMAScript objects should be serialized. + * @param {RemoteValue=} options.thisParameter + * The value of the this keyword for the function call. + * @param {boolean=} options.userActivation + * Determines whether execution should be treated as initiated by user. + * + * @returns {object} + * - evaluationStatus {EvaluationStatus} One of "normal", "throw". + * - exceptionDetails {ExceptionDetails=} the details of the exception if + * the evaluation status was "throw". + * - result {RemoteValue=} the result of the evaluation serialized as a + * RemoteValue if the evaluation status was "normal". + */ + async callFunctionDeclaration(options) { + const { + awaitPromise, + commandArguments = null, + functionDeclaration, + realmId = null, + resultOwnership, + sandbox: sandboxName = null, + serializationOptions, + thisParameter = null, + userActivation, + } = options; + + const realm = this.messageHandler.getRealm({ realmId, sandboxName }); + + const deserializedArguments = + commandArguments !== null + ? commandArguments.map(arg => + this.deserialize(arg, realm, { + emitScriptMessage: this.#emitScriptMessage, + }) + ) + : []; + + const deserializedThis = + thisParameter !== null + ? this.deserialize(thisParameter, realm, { + emitScriptMessage: this.#emitScriptMessage, + }) + : null; + + realm.userActivationEnabled = userActivation; + + const rv = realm.executeInGlobalWithBindings( + functionDeclaration, + deserializedArguments, + deserializedThis + ); + + return this.#buildReturnValue( + rv, + realm, + awaitPromise, + resultOwnership, + serializationOptions + ); + } + + /** + * Delete the provided handles from the realm corresponding to the provided + * sandbox name. + * + * @param {object=} options + * @param {Array<string>} options.handles + * Array of handle ids to disown. + * @param {string=} options.realmId + * The id of the realm. + * @param {string=} options.sandbox + * The name of the sandbox. + */ + disownHandles(options) { + const { handles, realmId = null, sandbox: sandboxName = null } = options; + const realm = this.messageHandler.getRealm({ realmId, sandboxName }); + for (const handle of handles) { + realm.removeObjectHandle(handle); + } + } + + /** + * Evaluate a provided expression in the current window global. + * + * @param {object} options + * @param {boolean} options.awaitPromise + * Determines if the command should wait for the return value of the + * expression to resolve, if this return value is a Promise. + * @param {string} options.expression + * The expression to evaluate. + * @param {string=} options.realmId + * The id of the realm. + * @param {OwnershipModel} options.resultOwnership + * The ownership model to use for the results of this evaluation. + * @param {string=} options.sandbox + * The name of the sandbox. + * @param {boolean=} options.userActivation + * Determines whether execution should be treated as initiated by user. + * + * @returns {object} + * - evaluationStatus {EvaluationStatus} One of "normal", "throw". + * - exceptionDetails {ExceptionDetails=} the details of the exception if + * the evaluation status was "throw". + * - result {RemoteValue=} the result of the evaluation serialized as a + * RemoteValue if the evaluation status was "normal". + */ + async evaluateExpression(options) { + const { + awaitPromise, + expression, + realmId = null, + resultOwnership, + sandbox: sandboxName = null, + serializationOptions, + userActivation, + } = options; + + const realm = this.messageHandler.getRealm({ realmId, sandboxName }); + + realm.userActivationEnabled = userActivation; + + const rv = realm.executeInGlobal(expression); + + return this.#buildReturnValue( + rv, + realm, + awaitPromise, + resultOwnership, + serializationOptions + ); + } + + /** + * Get realms for the current window global. + * + * @returns {Array<object>} + * - context {BrowsingContext} The browsing context, associated with the realm. + * - origin {string} The serialization of an origin. + * - realm {string} The realm unique identifier. + * - sandbox {string=} The name of the sandbox. + * - type {RealmType.Window} The window realm type. + */ + getWindowRealms() { + return Array.from(this.messageHandler.realms.values()).map(realm => { + const { context, origin, realm: id, sandbox, type } = realm.getInfo(); + return { context, origin, realm: id, sandbox, type }; + }); + } + + /** + * Internal commands + */ + + _applySessionData(params) { + if (params.category === "preload-script") { + this.#preloadScripts = new Set(); + for (const item of params.sessionData) { + if (this.messageHandler.matchesContext(item.contextDescriptor)) { + this.#preloadScripts.add(item.value); + } + } + + if (this.#preloadScripts.size) { + this.#startObserving(); + } + } + } +} + +export const script = ScriptModule; diff --git a/remote/webdriver-bidi/moz.build b/remote/webdriver-bidi/moz.build new file mode 100644 index 0000000000..1b58153c45 --- /dev/null +++ b/remote/webdriver-bidi/moz.build @@ -0,0 +1,14 @@ +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Remote Protocol", "WebDriver BiDi") + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.toml", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] diff --git a/remote/webdriver-bidi/test/browser/browser.toml b/remote/webdriver-bidi/test/browser/browser.toml new file mode 100644 index 0000000000..21e54bf538 --- /dev/null +++ b/remote/webdriver-bidi/test/browser/browser.toml @@ -0,0 +1,8 @@ +[DEFAULT] +tags = "wd" +subsuite = "remote" +support-files = ["head.js"] + +["browser_RemoteValue.js"] + +["browser_RemoteValueDOM.js"] diff --git a/remote/webdriver-bidi/test/browser/browser_RemoteValue.js b/remote/webdriver-bidi/test/browser/browser_RemoteValue.js new file mode 100644 index 0000000000..b95636037b --- /dev/null +++ b/remote/webdriver-bidi/test/browser/browser_RemoteValue.js @@ -0,0 +1,1117 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Realm } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Realm.sys.mjs" +); +const { deserialize, serialize, setDefaultSerializationOptions, stringify } = + ChromeUtils.importESModule( + "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs" + ); + +const PRIMITIVE_TYPES = [ + { value: undefined, serialized: { type: "undefined" } }, + { value: null, serialized: { type: "null" } }, + { value: "foo", serialized: { type: "string", value: "foo" } }, + { value: Number.NaN, serialized: { type: "number", value: "NaN" } }, + { value: -0, serialized: { type: "number", value: "-0" } }, + { + value: Number.POSITIVE_INFINITY, + serialized: { type: "number", value: "Infinity" }, + }, + { + value: Number.NEGATIVE_INFINITY, + serialized: { type: "number", value: "-Infinity" }, + }, + { value: 42, serialized: { type: "number", value: 42 } }, + { value: false, serialized: { type: "boolean", value: false } }, + { value: 42n, serialized: { type: "bigint", value: "42" } }, +]; + +const REMOTE_SIMPLE_VALUES = [ + { + value: new RegExp(/foo/), + serialized: { + type: "regexp", + value: { + pattern: "foo", + flags: "", + }, + }, + deserializable: true, + }, + { + value: new RegExp(/foo/g), + serialized: { + type: "regexp", + value: { + pattern: "foo", + flags: "g", + }, + }, + deserializable: true, + }, + { + value: new Date(1654004849000), + serialized: { + type: "date", + value: "2022-05-31T13:47:29.000Z", + }, + deserializable: true, + }, +]; + +const REMOTE_COMPLEX_VALUES = [ + { value: Symbol("foo"), serialized: { type: "symbol" } }, + { + value: [1], + serialized: { + type: "array", + value: [{ type: "number", value: 1 }], + }, + }, + { + value: [1], + serializationOptions: { + maxObjectDepth: 0, + }, + serialized: { + type: "array", + }, + }, + { + value: [1, "2", true, new RegExp(/foo/g)], + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "array", + value: [ + { type: "number", value: 1 }, + { type: "string", value: "2" }, + { type: "boolean", value: true }, + { + type: "regexp", + value: { + pattern: "foo", + flags: "g", + }, + }, + ], + }, + deserializable: true, + }, + { + value: [1, [3, "4"]], + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "array", + value: [{ type: "number", value: 1 }, { type: "array" }], + }, + }, + { + value: [1, [3, "4"]], + serializationOptions: { + maxObjectDepth: 2, + }, + serialized: { + type: "array", + value: [ + { type: "number", value: 1 }, + { + type: "array", + value: [ + { type: "number", value: 3 }, + { type: "string", value: "4" }, + ], + }, + ], + }, + deserializable: true, + }, + { + value: new Map(), + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "map", + value: [], + }, + deserializable: true, + }, + { + value: new Map([]), + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "map", + value: [], + }, + deserializable: true, + }, + { + value: new Map([ + [1, 2], + ["2", "3"], + [true, false], + ]), + serialized: { + type: "map", + value: [ + [ + { type: "number", value: 1 }, + { type: "number", value: 2 }, + ], + ["2", { type: "string", value: "3" }], + [ + { type: "boolean", value: true }, + { type: "boolean", value: false }, + ], + ], + }, + }, + { + value: new Map([ + [1, 2], + ["2", "3"], + [true, false], + ]), + serializationOptions: { + maxObjectDepth: 0, + }, + serialized: { + type: "map", + }, + }, + { + value: new Map([ + [1, 2], + ["2", "3"], + [true, false], + ]), + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "map", + value: [ + [ + { type: "number", value: 1 }, + { type: "number", value: 2 }, + ], + ["2", { type: "string", value: "3" }], + [ + { type: "boolean", value: true }, + { type: "boolean", value: false }, + ], + ], + }, + deserializable: true, + }, + { + value: new Set(), + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "set", + value: [], + }, + deserializable: true, + }, + { + value: new Set([]), + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "set", + value: [], + }, + deserializable: true, + }, + { + value: new Set([1, "2", true]), + serialized: { + type: "set", + value: [ + { type: "number", value: 1 }, + { type: "string", value: "2" }, + { type: "boolean", value: true }, + ], + }, + }, + { + value: new Set([1, "2", true]), + serializationOptions: { + maxObjectDepth: 0, + }, + serialized: { + type: "set", + }, + }, + { + value: new Set([1, "2", true]), + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "set", + value: [ + { type: "number", value: 1 }, + { type: "string", value: "2" }, + { type: "boolean", value: true }, + ], + }, + deserializable: true, + }, + { value: new WeakMap([[{}, 1]]), serialized: { type: "weakmap" } }, + { value: new WeakSet([{}]), serialized: { type: "weakset" } }, + { + value: (function* () { + yield "a"; + })(), + serialized: { type: "generator" }, + }, + { + value: (async function* () { + yield await Promise.resolve(1); + })(), + serialized: { type: "generator" }, + }, + { value: new Error("error message"), serialized: { type: "error" } }, + { + value: new SyntaxError("syntax error message"), + serialized: { type: "error" }, + }, + { + value: new TypeError("type error message"), + serialized: { type: "error" }, + }, + { value: new Proxy({}, {}), serialized: { type: "proxy" } }, + { value: new Promise(() => true), serialized: { type: "promise" } }, + { value: new Int8Array(), serialized: { type: "typedarray" } }, + { value: new ArrayBuffer(), serialized: { type: "arraybuffer" } }, + { value: new URL("https://example.com"), serialized: { type: "object" } }, + { value: () => true, serialized: { type: "function" } }, + { value() {}, serialized: { type: "function" } }, + { + value: {}, + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "object", + value: [], + }, + deserializable: true, + }, + { + value: { + 1: 1, + 2: "2", + foo: true, + }, + serialized: { + type: "object", + value: [ + ["1", { type: "number", value: 1 }], + ["2", { type: "string", value: "2" }], + ["foo", { type: "boolean", value: true }], + ], + }, + }, + { + value: { + 1: 1, + 2: "2", + foo: true, + }, + serializationOptions: { + maxObjectDepth: 0, + }, + serialized: { + type: "object", + }, + }, + { + value: { + 1: 1, + 2: "2", + foo: true, + }, + serializationOptions: { + maxObjectDepth: 1, + }, + serialized: { + type: "object", + value: [ + ["1", { type: "number", value: 1 }], + ["2", { type: "string", value: "2" }], + ["foo", { type: "boolean", value: true }], + ], + }, + deserializable: true, + }, + { + value: { + 1: 1, + 2: "2", + 3: { + bar: "foo", + }, + foo: true, + }, + serializationOptions: { + maxObjectDepth: 2, + }, + serialized: { + type: "object", + value: [ + ["1", { type: "number", value: 1 }], + ["2", { type: "string", value: "2" }], + [ + "3", + { + type: "object", + value: [["bar", { type: "string", value: "foo" }]], + }, + ], + ["foo", { type: "boolean", value: true }], + ], + }, + deserializable: true, + }, +]; + +add_task(function test_deserializePrimitiveTypes() { + const realm = new Realm(); + + for (const type of PRIMITIVE_TYPES) { + const { value: expectedValue, serialized } = type; + + info(`Checking '${serialized.type}'`); + const value = deserialize(serialized, realm, {}); + + if (serialized.value == "NaN") { + ok(Number.isNaN(value), `Got expected value for ${serialized}`); + } else { + Assert.strictEqual( + value, + expectedValue, + `Got expected value for ${serialized}` + ); + } + } +}); + +add_task(function test_deserializeDateLocalValue() { + const realm = new Realm(); + + const validaDateStrings = [ + "2009", + "2009-05", + "2009-05-19", + "2022-02-29", + "2009T15:00", + "2009-05T15:00", + "2022-06-31T15:00", + "2009-05-19T15:00", + "2009-05-19T15:00:15", + "2009-05-19T15:00-00:00", + "2009-05-19T15:00:15.452", + "2009-05-19T15:00:15.452Z", + "2009-05-19T15:00:15.452+02:00", + "2009-05-19T15:00:15.452-02:00", + "-271821-04-20T00:00:00Z", + "+000000-01-01T00:00:00Z", + ]; + for (const dateString of validaDateStrings) { + info(`Checking '${dateString}'`); + const value = deserialize({ type: "date", value: dateString }, realm, {}); + + Assert.equal( + value.getTime(), + new Date(dateString).getTime(), + `Got expected value for ${dateString}` + ); + } +}); + +add_task(function test_deserializeLocalValues() { + const realm = new Realm(); + + for (const type of REMOTE_SIMPLE_VALUES.concat(REMOTE_COMPLEX_VALUES)) { + const { value: expectedValue, serialized, deserializable } = type; + + // Skip non deserializable cases + if (!deserializable) { + continue; + } + + info(`Checking '${serialized.type}'`); + const value = deserialize(serialized, realm, {}); + assertLocalValue(serialized.type, value, expectedValue); + } +}); + +add_task(async function test_deserializeLocalValuesInWindowRealm() { + for (const type of REMOTE_SIMPLE_VALUES.concat(REMOTE_COMPLEX_VALUES)) { + const { value: expectedValue, serialized, deserializable } = type; + + // Skip non deserializable cases + if (!deserializable) { + continue; + } + + const value = await deserializeInWindowRealm(serialized); + assertLocalValue(serialized.type, value, expectedValue); + } +}); + +add_task(async function test_deserializeChannel() { + const realm = new Realm(); + const channel = { + type: "channel", + value: { channel: "channel_name" }, + }; + const deserializationOptions = { + emitScriptMessage: (realm, channelProperties, message) => message, + }; + + info(`Checking 'channel'`); + const value = deserialize(channel, realm, deserializationOptions, {}); + Assert.equal( + Object.prototype.toString.call(value), + "[object Function]", + "Got expected type Function" + ); + Assert.equal(value("foo"), "foo", "Got expected result"); +}); + +add_task(function test_deserializeLocalValuesByHandle() { + // Create two realms, realm1 will be used to serialize values, while realm2 + // will be used as a reference empty realm without any object reference. + const realm1 = new Realm(); + const realm2 = new Realm(); + + for (const type of REMOTE_SIMPLE_VALUES.concat(REMOTE_COMPLEX_VALUES)) { + const { value: expectedValue, serialized } = type; + + // No need to skip non-deserializable cases here. + + info(`Checking '${serialized.type}'`); + // Serialize the value once to get a handle. + const serializedValue = serialize( + expectedValue, + { maxObjectDepth: 0 }, + "root", + new Map(), + realm1, + {} + ); + + // Create a remote reference containing only the handle. + // `deserialize` should not need any other property. + const remoteReference = { handle: serializedValue.handle }; + + // Check that the remote reference can be deserialized in realm1. + const value = deserialize(remoteReference, realm1, {}); + assertLocalValue(serialized.type, value, expectedValue); + + Assert.throws( + () => deserialize(remoteReference, realm2, {}), + /NoSuchHandleError:/, + `Got expected error when using the wrong realm for deserialize` + ); + + realm1.removeObjectHandle(serializedValue.handle); + Assert.throws( + () => deserialize(remoteReference, realm1, {}), + /NoSuchHandleError:/, + `Got expected error when after deleting the object handle` + ); + } +}); + +add_task(function test_deserializeHandleInvalidTypes() { + const realm = new Realm(); + + for (const invalidType of [false, 42, {}, []]) { + info(`Checking type: '${invalidType}'`); + + Assert.throws( + () => deserialize({ type: "object", handle: invalidType }, realm, {}), + /InvalidArgumentError:/, + `Got expected error for type ${invalidType}` + ); + } +}); + +add_task(function test_deserializePrimitiveTypesInvalidValues() { + const realm = new Realm(); + + const invalidValues = [ + { type: "bigint", values: [undefined, null, false, "foo", [], {}] }, + { type: "boolean", values: [undefined, null, 42, "foo", [], {}] }, + { + type: "number", + values: [undefined, null, false, "43", [], {}], + }, + { type: "string", values: [undefined, null, false, 42, [], {}] }, + ]; + + for (const invalidValue of invalidValues) { + const { type, values } = invalidValue; + + for (const value of values) { + info(`Checking '${type}' with value ${value}`); + + Assert.throws( + () => deserialize({ type, value }, realm, {}), + /InvalidArgument/, + `Got expected error for type ${type} and value ${value}` + ); + } + } +}); + +add_task(function test_deserializeDateLocalValueInvalidValues() { + const realm = new Realm(); + + const invalidaDateStrings = [ + "10", + "20009", + "+20009", + "2009-", + "2009-0", + "2009-15", + "2009-02-1", + "2009-02-50", + "15:00", + "T15:00", + "9-05-19T15:00", + "2009-5-19T15:00", + "2009-05-1T15:00", + "2009-02-10T15", + "2009-05-19T15:", + "2009-05-19T1:00", + "2009-05-19T10:1", + "2009-05-19T60:00", + "2009-05-19T15:70", + "2009-05-19T15:00.25", + "2009-05-19+10:00", + "2009-05-19Z", + "2009-05-19 15:00", + "2009-05-19t15:00Z", + "2009-05-19T15:00z", + "2009-05-19T15:00+01", + "2009-05-19T10:10+1:00", + "2009-05-19T10:10+01:1", + "2009-05-19T15:00+75:00", + "2009-05-19T15:00+02:80", + "02009-05-19T15:00", + ]; + for (const dateString of invalidaDateStrings) { + info(`Checking '${dateString}'`); + + Assert.throws( + () => deserialize({ type: "date", value: dateString }, realm, {}), + /InvalidArgumentError:/, + `Got expected error for date string: ${dateString}` + ); + } +}); + +add_task(function test_deserializeLocalValuesInvalidType() { + const realm = new Realm(); + + const invalidTypes = [undefined, null, false, 42, {}]; + + for (const invalidType of invalidTypes) { + info(`Checking type: '${invalidType}'`); + + Assert.throws( + () => deserialize({ type: invalidType }, realm, {}), + /InvalidArgumentError:/, + `Got expected error for type ${invalidType}` + ); + + Assert.throws( + () => + deserialize( + { + type: "array", + value: [{ type: invalidType }], + }, + realm, + {} + ), + /InvalidArgumentError:/, + `Got expected error for nested type ${invalidType}` + ); + } +}); + +add_task(function test_deserializeLocalValuesInvalidValues() { + const realm = new Realm(); + + const invalidValues = [ + { type: "array", values: [undefined, null, false, 42, "foo", {}] }, + { + type: "regexp", + values: [ + undefined, + null, + false, + "foo", + 42, + [], + {}, + { pattern: null }, + { pattern: 1 }, + { pattern: true }, + { pattern: "foo", flags: null }, + { pattern: "foo", flags: 1 }, + { pattern: "foo", flags: false }, + { pattern: "foo", flags: "foo" }, + ], + }, + { + type: "date", + values: [ + undefined, + null, + false, + "foo", + "05 October 2011 14:48 UTC", + "Tue Jun 14 2022 10:46:50 GMT+0200!", + 42, + [], + {}, + ], + }, + { + type: "map", + values: [ + undefined, + null, + false, + "foo", + 42, + ["1"], + [[]], + [["1"]], + [{ 1: "2" }], + {}, + ], + }, + { + type: "set", + values: [undefined, null, false, "foo", 42, {}], + }, + { + type: "object", + values: [ + undefined, + null, + false, + "foo", + 42, + {}, + ["1"], + [[]], + [["1"]], + [{ 1: "2" }], + [ + [ + { type: "number", value: "1" }, + { type: "number", value: "2" }, + ], + ], + [ + [ + { type: "object", value: [] }, + { type: "number", value: "1" }, + ], + ], + [ + [ + { + type: "regexp", + value: { + pattern: "foo", + }, + }, + { type: "number", value: "1" }, + ], + ], + ], + }, + ]; + + for (const invalidValue of invalidValues) { + const { type, values } = invalidValue; + + for (const value of values) { + info(`Checking '${type}' with value ${value}`); + + Assert.throws( + () => deserialize({ type, value }, realm, {}), + /InvalidArgumentError:/, + `Got expected error for type ${type} and value ${value}` + ); + } + } +}); + +add_task(function test_serializePrimitiveTypes() { + const realm = new Realm(); + + for (const type of PRIMITIVE_TYPES) { + const { value, serialized } = type; + const defaultSerializationOptions = setDefaultSerializationOptions(); + + const serializationInternalMap = new Map(); + const serializedValue = serialize( + value, + defaultSerializationOptions, + "none", + serializationInternalMap, + realm, + {} + ); + assertInternalIds(serializationInternalMap, 0); + Assert.deepEqual(serialized, serializedValue, "Got expected structure"); + + // For primitive values, the serialization with ownershipType=root should + // be exactly identical to the one with ownershipType=none. + const serializationInternalMapWithRoot = new Map(); + const serializedWithRoot = serialize( + value, + defaultSerializationOptions, + "root", + serializationInternalMapWithRoot, + realm, + {} + ); + assertInternalIds(serializationInternalMapWithRoot, 0); + Assert.deepEqual(serialized, serializedWithRoot, "Got expected structure"); + } +}); + +add_task(function test_serializeRemoteSimpleValues() { + const realm = new Realm(); + + for (const type of REMOTE_SIMPLE_VALUES) { + const { value, serialized } = type; + const defaultSerializationOptions = setDefaultSerializationOptions(); + + info(`Checking '${serialized.type}' with none ownershipType`); + const serializationInternalMapWithNone = new Map(); + const serializedValue = serialize( + value, + defaultSerializationOptions, + "none", + serializationInternalMapWithNone, + realm, + {} + ); + + assertInternalIds(serializationInternalMapWithNone, 0); + Assert.deepEqual(serialized, serializedValue, "Got expected structure"); + + info(`Checking '${serialized.type}' with root ownershipType`); + const serializationInternalMapWithRoot = new Map(); + const serializedWithRoot = serialize( + value, + defaultSerializationOptions, + "root", + serializationInternalMapWithRoot, + realm, + {} + ); + + assertInternalIds(serializationInternalMapWithRoot, 0); + Assert.equal( + typeof serializedWithRoot.handle, + "string", + "Got a handle property" + ); + Assert.deepEqual( + Object.assign({}, serialized, { handle: serializedWithRoot.handle }), + serializedWithRoot, + "Got expected structure, plus a generated handle id" + ); + } +}); + +add_task(function test_serializeRemoteComplexValues() { + for (const type of REMOTE_COMPLEX_VALUES) { + const { value, serialized, serializationOptions } = type; + const serializationOptionsWithDefaults = + setDefaultSerializationOptions(serializationOptions); + + info(`Checking '${serialized.type}' with none ownershipType`); + const realm = new Realm(); + const serializationInternalMapWithNone = new Map(); + + const serializedValue = serialize( + value, + serializationOptionsWithDefaults, + "none", + serializationInternalMapWithNone, + realm, + {} + ); + + assertInternalIds(serializationInternalMapWithNone, 0); + Assert.deepEqual(serialized, serializedValue, "Got expected structure"); + + info(`Checking '${serialized.type}' with root ownershipType`); + const serializationInternalMapWithRoot = new Map(); + const serializedWithRoot = serialize( + value, + serializationOptionsWithDefaults, + "root", + serializationInternalMapWithRoot, + realm, + {} + ); + + assertInternalIds(serializationInternalMapWithRoot, 0); + Assert.equal( + typeof serializedWithRoot.handle, + "string", + "Got a handle property" + ); + Assert.deepEqual( + Object.assign({}, serialized, { handle: serializedWithRoot.handle }), + serializedWithRoot, + "Got expected structure, plus a generated handle id" + ); + } +}); + +add_task(function test_serializeWithSerializationInternalMap() { + const dataSet = [ + { + data: [1], + serializedData: [{ type: "number", value: 1 }], + type: "array", + }, + { + data: new Map([[true, false]]), + serializedData: [ + [ + { type: "boolean", value: true }, + { type: "boolean", value: false }, + ], + ], + type: "map", + }, + { + data: new Set(["foo"]), + serializedData: [{ type: "string", value: "foo" }], + type: "set", + }, + { + data: { foo: "bar" }, + serializedData: [["foo", { type: "string", value: "bar" }]], + type: "object", + }, + ]; + const realm = new Realm(); + + for (const { type, data, serializedData } of dataSet) { + info(`Checking '${type}' with serializationInternalMap`); + + const serializationInternalMap = new Map(); + const value = [ + data, + data, + [data], + new Set([data]), + new Map([["bar", data]]), + { bar: data }, + ]; + + const serializedValue = serialize( + value, + { maxObjectDepth: 2 }, + "none", + serializationInternalMap, + realm, + {} + ); + + assertInternalIds(serializationInternalMap, 1); + + const internalId = serializationInternalMap.get(data).internalId; + + const serialized = { + type: "array", + value: [ + { + type, + value: serializedData, + internalId, + }, + { + type, + internalId, + }, + { + type: "array", + value: [{ type, internalId }], + }, + { + type: "set", + value: [{ type, internalId }], + }, + { + type: "map", + value: [["bar", { type, internalId }]], + }, + { + type: "object", + value: [["bar", { type, internalId }]], + }, + ], + }; + + Assert.deepEqual(serialized, serializedValue, "Got expected structure"); + } +}); + +add_task(function test_serializeMultipleValuesWithSerializationInternalMap() { + const realm = new Realm(); + const serializationInternalMap = new Map(); + const obj1 = { foo: "bar" }; + const obj2 = [1, 2]; + const value = [obj1, obj2, obj1, obj2]; + + serialize( + value, + { maxObjectDepth: 2 }, + "none", + serializationInternalMap, + realm, + {} + ); + + assertInternalIds(serializationInternalMap, 2); + + const internalId1 = serializationInternalMap.get(obj1).internalId; + const internalId2 = serializationInternalMap.get(obj2).internalId; + + Assert.notEqual( + internalId1, + internalId2, + "Internal ids for different object are also different" + ); +}); + +add_task(function test_stringify() { + const STRINGIFY_TEST_CASES = [ + [undefined, "undefined"], + [null, "null"], + ["foobar", "foobar"], + ["2", "2"], + [-0, "0"], + [Infinity, "Infinity"], + [-Infinity, "-Infinity"], + [3, "3"], + [1.4, "1.4"], + [true, "true"], + [42n, "42"], + [{ toString: () => "bar" }, "bar", "toString: () => 'bar'"], + [{ toString: () => 4 }, "[object Object]", "toString: () => 4"], + [{ toString: undefined }, "[object Object]", "toString: undefined"], + [{ toString: null }, "[object Object]", "toString: null"], + [ + { + toString: () => { + throw new Error("toString error"); + }, + }, + "[object Object]", + "toString: () => { throw new Error('toString error'); }", + ], + ]; + + for (const [value, expectedString, description] of STRINGIFY_TEST_CASES) { + info(`Checking '${description || value}'`); + const stringifiedValue = stringify(value); + + Assert.strictEqual(expectedString, stringifiedValue, "Got expected string"); + } +}); + +function assertLocalValue(type, value, expectedValue) { + let formattedValue = value; + let formattedExpectedValue = expectedValue; + + // Format certain types for easier assertion + if (type == "map") { + Assert.equal( + Object.prototype.toString.call(expectedValue), + "[object Map]", + "Got expected type Map" + ); + + formattedValue = Array.from(value.values()); + formattedExpectedValue = Array.from(expectedValue.values()); + } else if (type == "set") { + Assert.equal( + Object.prototype.toString.call(expectedValue), + "[object Set]", + "Got expected type Set" + ); + + formattedValue = Array.from(value); + formattedExpectedValue = Array.from(expectedValue); + } + + Assert.deepEqual( + formattedValue, + formattedExpectedValue, + "Got expected structure" + ); +} + +function assertInternalIds(serializationInternalMap, amount) { + const remoteValuesWithInternalIds = Array.from( + serializationInternalMap.values() + ).filter(remoteValue => !!remoteValue.internalId); + + Assert.equal( + remoteValuesWithInternalIds.length, + amount, + "Got expected amount of internalIds in serializationInternalMap" + ); +} + +function deserializeInWindowRealm(serialized) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [serialized], + async _serialized => { + const { WindowRealm } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Realm.sys.mjs" + ); + const { deserialize } = ChromeUtils.importESModule( + "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs" + ); + const realm = new WindowRealm(content); + info(`Checking '${_serialized.type}'`); + return deserialize(_serialized, realm, {}); + } + ); +} diff --git a/remote/webdriver-bidi/test/browser/browser_RemoteValueDOM.js b/remote/webdriver-bidi/test/browser/browser_RemoteValueDOM.js new file mode 100644 index 0000000000..3e72c9c659 --- /dev/null +++ b/remote/webdriver-bidi/test/browser/browser_RemoteValueDOM.js @@ -0,0 +1,845 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* eslint no-undef: 0 no-unused-vars: 0 */ + +add_task(async function test_deserializeSharedIdInvalidTypes() { + await runTestInContent(() => { + for (const invalidType of [false, 42, {}, []]) { + info(`Checking type: '${invalidType}'`); + + const serializedValue = { + sharedId: invalidType, + }; + + Assert.throws( + () => deserialize(serializedValue, realm, { nodeCache }), + /InvalidArgumentError:/, + `Got expected error for type ${invalidType}` + ); + } + }); +}); + +add_task(async function test_deserializeSharedIdInvalidValue() { + await runTestInContent(() => { + const serializedValue = { + sharedId: "foo", + }; + + Assert.throws( + () => deserialize(serializedValue, realm, { nodeCache }), + /NoSuchNodeError:/, + "Got expected error for unknown 'sharedId'" + ); + }); +}); + +add_task(async function test_deserializeSharedId() { + await loadURL(inline("<div>")); + + await runTestInContent(() => { + const domEl = content.document.querySelector("div"); + const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds); + + const serializedValue = { + sharedId: domElRef, + }; + + const node = deserialize(serializedValue, realm, { nodeCache }); + + Assert.equal(node, domEl); + }); +}); + +add_task(async function test_deserializeSharedIdPrecedenceOverHandle() { + await loadURL(inline("<div>")); + + await runTestInContent(() => { + const domEl = content.document.querySelector("div"); + const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds); + + const serializedValue = { + handle: "foo", + sharedId: domElRef, + }; + + const node = deserialize(serializedValue, realm, { nodeCache }); + + Assert.equal(node, domEl); + }); +}); + +add_task(async function test_deserializeSharedIdNoWindowRealm() { + await loadURL(inline("<div>")); + + await runTestInContent(() => { + const domEl = content.document.querySelector("div"); + const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds); + + const serializedValue = { + sharedId: domElRef, + }; + + Assert.throws( + () => deserialize(serializedValue, new Realm(), { nodeCache }), + /NoSuchNodeError/, + `Got expected error for a non-window realm` + ); + }); +}); + +// Bug 1819902: Instead of a browsing context check compare the origin +add_task(async function test_deserializeSharedIdOtherBrowsingContext() { + await loadURL(inline("<iframe>")); + + await runTestInContent(() => { + const iframeEl = content.document.querySelector("iframe"); + const domEl = iframeEl.contentWindow.document.createElement("div"); + iframeEl.contentWindow.document.body.appendChild(domEl); + + const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds); + + const serializedValue = { + sharedId: domElRef, + }; + + const node = deserialize(serializedValue, realm, { nodeCache }); + + Assert.equal(node, null); + }); +}); + +add_task(async function test_serializeRemoteComplexValues() { + await loadURL(inline("<div>")); + + await runTestInContent(() => { + const domEl = content.document.querySelector("div"); + const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds); + + const REMOTE_COMPLEX_VALUES = [ + { + value: content.document.querySelector("div"), + serialized: { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + }, + { + value: content.document.querySelectorAll("div"), + serialized: { + type: "nodelist", + value: [ + { + type: "node", + sharedId: domElRef, + value: { + nodeType: 1, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + childNodeCount: 0, + attributes: {}, + shadowRoot: null, + }, + }, + ], + }, + }, + { + value: content.document.getElementsByTagName("div"), + serialized: { + type: "htmlcollection", + value: [ + { + type: "node", + sharedId: domElRef, + value: { + nodeType: 1, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + childNodeCount: 0, + attributes: {}, + shadowRoot: null, + }, + }, + ], + }, + }, + ]; + + for (const type of REMOTE_COMPLEX_VALUES) { + serializeAndAssertRemoteValue(type); + } + }); +}); + +add_task(async function test_serializeWindow() { + await loadURL(inline("<iframe>")); + + await runTestInContent(() => { + const REMOTE_COMPLEX_VALUES = [ + { + value: content, + serialized: { + type: "window", + value: { + context: content.browsingContext.browserId.toString(), + isTopBrowsingContext: true, + }, + }, + }, + { + value: content.frames[0], + serialized: { + type: "window", + value: { + context: content.frames[0].browsingContext.id.toString(), + }, + }, + }, + { + value: content.document.querySelector("iframe").contentWindow, + serialized: { + type: "window", + value: { + context: content.document + .querySelector("iframe") + .contentWindow.browsingContext.id.toString(), + }, + }, + }, + ]; + + for (const type of REMOTE_COMPLEX_VALUES) { + serializeAndAssertRemoteValue(type); + } + }); +}); + +add_task(async function test_serializeNodeChildren() { + await loadURL(inline("<div></div><iframe/>")); + + await runTestInContent(() => { + // Add the used elements to the cache so that we know the unique reference. + const bodyEl = content.document.body; + const domEl = bodyEl.querySelector("div"); + const iframeEl = bodyEl.querySelector("iframe"); + + const bodyElRef = nodeCache.getOrCreateNodeReference(bodyEl, seenNodeIds); + const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds); + const iframeElRef = nodeCache.getOrCreateNodeReference( + iframeEl, + seenNodeIds + ); + + const dataSet = [ + { + node: bodyEl, + serializationOptions: { + maxDomDepth: null, + }, + serialized: { + type: "node", + sharedId: bodyElRef, + value: { + nodeType: 1, + localName: "body", + namespaceURI: "http://www.w3.org/1999/xhtml", + childNodeCount: 2, + children: [ + { + type: "node", + sharedId: domElRef, + value: { + nodeType: 1, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + childNodeCount: 0, + children: [], + attributes: {}, + shadowRoot: null, + }, + }, + { + type: "node", + sharedId: iframeElRef, + value: { + nodeType: 1, + localName: "iframe", + namespaceURI: "http://www.w3.org/1999/xhtml", + childNodeCount: 0, + children: [], + attributes: {}, + shadowRoot: null, + }, + }, + ], + attributes: {}, + shadowRoot: null, + }, + }, + }, + { + node: bodyEl, + serializationOptions: { + maxDomDepth: 0, + }, + serialized: { + type: "node", + sharedId: bodyElRef, + value: { + attributes: {}, + childNodeCount: 2, + localName: "body", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + }, + { + node: bodyEl, + serializationOptions: { + maxDomDepth: 1, + }, + serialized: { + type: "node", + sharedId: bodyElRef, + value: { + attributes: {}, + childNodeCount: 2, + children: [ + { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + { + type: "node", + sharedId: iframeElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "iframe", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + ], + localName: "body", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + }, + { + node: domEl, + serializationOptions: { + maxDomDepth: 0, + }, + serialized: { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + }, + { + node: domEl, + serializationOptions: { + maxDomDepth: 1, + }, + serialized: { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + children: [], + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + }, + ]; + + for (const { node, serializationOptions, serialized } of dataSet) { + const { maxDomDepth } = serializationOptions; + info(`Checking '${node.localName}' with maxDomDepth ${maxDomDepth}`); + + const serializationInternalMap = new Map(); + + const serializedValue = serialize( + node, + serializationOptions, + "none", + serializationInternalMap, + realm, + { nodeCache, seenNodeIds } + ); + + Assert.deepEqual(serializedValue, serialized, "Got expected structure"); + } + }); +}); + +add_task(async function test_serializeNodeEmbeddedWithin() { + await loadURL(inline("<div>")); + + await runTestInContent(() => { + // Add the used elements to the cache so that we know the unique reference. + const bodyEl = content.document.body; + const domEl = bodyEl.querySelector("div"); + const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds); + + const dataSet = [ + { + embedder: "array", + wrapper: node => [node], + serialized: { + type: "array", + value: [ + { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + ], + }, + }, + { + embedder: "map", + wrapper: node => { + const map = new Map(); + map.set(node, "elem"); + return map; + }, + serialized: { + type: "map", + value: [ + [ + { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + { + type: "string", + value: "elem", + }, + ], + ], + }, + }, + { + embedder: "map", + wrapper: node => { + const map = new Map(); + map.set("elem", node); + return map; + }, + serialized: { + type: "map", + value: [ + [ + "elem", + { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + ], + ], + }, + }, + { + embedder: "object", + wrapper: node => ({ elem: node }), + serialized: { + type: "object", + value: [ + [ + "elem", + { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + ], + ], + }, + }, + { + embedder: "set", + wrapper: node => { + const set = new Set(); + set.add(node); + return set; + }, + serialized: { + type: "set", + value: [ + { + type: "node", + sharedId: domElRef, + value: { + attributes: {}, + childNodeCount: 0, + localName: "div", + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: null, + }, + }, + ], + }, + }, + ]; + + for (const { embedder, wrapper, serialized } of dataSet) { + info(`Checking embedding node within ${embedder}`); + + const serializationInternalMap = new Map(); + + const serializedValue = serialize( + wrapper(domEl), + { maxDomDepth: 0 }, + "none", + serializationInternalMap, + realm, + { nodeCache } + ); + + Assert.deepEqual(serializedValue, serialized, "Got expected structure"); + } + }); +}); + +add_task(async function test_serializeShadowRoot() { + await runTestInContent(() => { + for (const mode of ["open", "closed"]) { + info(`Checking shadow root with mode '${mode}'`); + const customElement = content.document.createElement( + `${mode}-custom-element` + ); + const insideShadowRootElement = content.document.createElement("input"); + content.document.body.appendChild(customElement); + const shadowRoot = customElement.attachShadow({ mode }); + shadowRoot.appendChild(insideShadowRootElement); + + // Add the used elements to the cache so that we know the unique reference. + const customElementRef = nodeCache.getOrCreateNodeReference( + customElement, + seenNodeIds + ); + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + const insideShadowRootElementRef = nodeCache.getOrCreateNodeReference( + insideShadowRootElement, + seenNodeIds + ); + + const dataSet = [ + { + node: customElement, + serializationOptions: { + maxDomDepth: 1, + }, + serialized: { + type: "node", + sharedId: customElementRef, + value: { + attributes: {}, + childNodeCount: 0, + children: [], + localName: `${mode}-custom-element`, + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: { + sharedId: shadowRootRef, + type: "node", + value: { + childNodeCount: 1, + mode, + nodeType: 11, + }, + }, + }, + }, + }, + { + node: customElement, + serializationOptions: { + includeShadowTree: "open", + maxDomDepth: 1, + }, + serialized: { + type: "node", + sharedId: customElementRef, + value: { + attributes: {}, + childNodeCount: 0, + children: [], + localName: `${mode}-custom-element`, + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: { + sharedId: shadowRootRef, + type: "node", + value: { + childNodeCount: 1, + mode, + nodeType: 11, + ...(mode === "open" + ? { + children: [ + { + type: "node", + sharedId: insideShadowRootElementRef, + value: { + nodeType: 1, + localName: "input", + namespaceURI: "http://www.w3.org/1999/xhtml", + childNodeCount: 0, + attributes: {}, + shadowRoot: null, + }, + }, + ], + } + : {}), + }, + }, + }, + }, + }, + { + node: customElement, + serializationOptions: { + includeShadowTree: "all", + maxDomDepth: 1, + }, + serialized: { + type: "node", + sharedId: customElementRef, + value: { + attributes: {}, + childNodeCount: 0, + children: [], + localName: `${mode}-custom-element`, + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + shadowRoot: { + sharedId: shadowRootRef, + type: "node", + value: { + childNodeCount: 1, + mode, + nodeType: 11, + children: [ + { + type: "node", + sharedId: insideShadowRootElementRef, + value: { + nodeType: 1, + localName: "input", + namespaceURI: "http://www.w3.org/1999/xhtml", + childNodeCount: 0, + attributes: {}, + shadowRoot: null, + }, + }, + ], + }, + }, + }, + }, + }, + ]; + + for (const { node, serializationOptions, serialized } of dataSet) { + const { maxDomDepth, includeShadowTree } = serializationOptions; + info( + `Checking shadow root with maxDomDepth ${maxDomDepth} and includeShadowTree ${includeShadowTree}` + ); + + const serializationInternalMap = new Map(); + + const serializedValue = serialize( + node, + serializationOptions, + "none", + serializationInternalMap, + realm, + { nodeCache } + ); + + Assert.deepEqual(serializedValue, serialized, "Got expected structure"); + } + } + }); +}); + +add_task(async function test_serializeNodeSharedId() { + await loadURL(inline("<div>")); + + await runTestInContent(() => { + const domEl = content.document.querySelector("div"); + + // Already add the domEl to the cache so that we know the unique reference. + const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds); + + const serializedValue = serialize( + domEl, + { maxDomDepth: 0 }, + "root", + serializationInternalMap, + realm, + { nodeCache, seenNodeIds } + ); + + Assert.equal(nodeCache.size, 1, "No additional reference added"); + Assert.equal(serializedValue.sharedId, domElRef); + Assert.notEqual(serializedValue.handle, domElRef); + }); +}); + +function runTestInContent(callback) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [callback.toString()], + async callback => { + const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" + ); + const { Realm, WindowRealm } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Realm.sys.mjs" + ); + const { deserialize, serialize, setDefaultSerializationOptions } = + ChromeUtils.importESModule( + "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs" + ); + + function assertInternalIds(serializationInternalMap, amount) { + const remoteValuesWithInternalIds = Array.from( + serializationInternalMap.values() + ).filter(remoteValue => !!remoteValue.internalId); + + Assert.equal( + remoteValuesWithInternalIds.length, + amount, + "Got expected amount of internalIds in serializationInternalMap" + ); + } + + const nodeCache = new NodeCache(); + const seenNodeIds = new Map(); + const realm = new WindowRealm(content); + const serializationInternalMap = new Map(); + + function serializeAndAssertRemoteValue(remoteValue) { + const { value, serialized } = remoteValue; + const serializationOptionsWithDefaults = + setDefaultSerializationOptions(); + const serializationInternalMapWithNone = new Map(); + + info(`Checking '${serialized.type}' with none ownershipType`); + + const serializedValue = serialize( + value, + serializationOptionsWithDefaults, + "none", + serializationInternalMapWithNone, + realm, + { nodeCache, seenNodeIds } + ); + + assertInternalIds(serializationInternalMapWithNone, 0); + Assert.deepEqual(serialized, serializedValue, "Got expected structure"); + + info(`Checking '${serialized.type}' with root ownershipType`); + const serializationInternalMapWithRoot = new Map(); + const serializedWithRoot = serialize( + value, + serializationOptionsWithDefaults, + "root", + serializationInternalMapWithRoot, + realm, + { nodeCache, seenNodeIds } + ); + + assertInternalIds(serializationInternalMapWithRoot, 0); + Assert.equal( + typeof serializedWithRoot.handle, + "string", + "Got a handle property" + ); + Assert.deepEqual( + Object.assign({}, serialized, { handle: serializedWithRoot.handle }), + serializedWithRoot, + "Got expected structure, plus a generated handle id" + ); + } + + // eslint-disable-next-line no-eval + eval(`(${callback})()`); + } + ); +} diff --git a/remote/webdriver-bidi/test/browser/head.js b/remote/webdriver-bidi/test/browser/head.js new file mode 100644 index 0000000000..e9a125a193 --- /dev/null +++ b/remote/webdriver-bidi/test/browser/head.js @@ -0,0 +1,28 @@ +/** + * Load a given URL in the currently selected tab + */ +async function loadURL(url, expectedURL = undefined) { + expectedURL = expectedURL || url; + + const browser = gBrowser.selectedTab.linkedBrowser; + const loaded = BrowserTestUtils.browserLoaded(browser, true, expectedURL); + + BrowserTestUtils.startLoadingURIString(browser, url); + await loaded; +} + +/** Creates an inline URL for the given source document. */ +function inline(src, doctype = "html") { + let doc; + switch (doctype) { + case "html": + doc = `<!doctype html>\n<meta charset=utf-8>\n${src}`; + break; + default: + throw new Error("Unexpected doctype: " + doctype); + } + + return `https://example.com/document-builder.sjs?html=${encodeURIComponent( + doc + )}`; +} diff --git a/remote/webdriver-bidi/test/xpcshell/test_WebDriverBiDiConnection.js b/remote/webdriver-bidi/test/xpcshell/test_WebDriverBiDiConnection.js new file mode 100644 index 0000000000..e10c77caf3 --- /dev/null +++ b/remote/webdriver-bidi/test/xpcshell/test_WebDriverBiDiConnection.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { splitMethod } = ChromeUtils.importESModule( + "chrome://remote/content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs" +); + +add_task(function test_Connection_splitMethod() { + for (const t of [42, null, true, {}, [], undefined]) { + Assert.throws(() => splitMethod(t), /TypeError/, `${typeof t} throws`); + } + for (const s of ["", ".", "foo.", ".bar", "foo.bar.baz"]) { + Assert.throws( + () => splitMethod(s), + /Invalid method format: '.*'/, + `"${s}" throws` + ); + } + deepEqual(splitMethod("foo.bar"), { + module: "foo", + command: "bar", + }); +}); diff --git a/remote/webdriver-bidi/test/xpcshell/xpcshell.toml b/remote/webdriver-bidi/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..31cd9e3f04 --- /dev/null +++ b/remote/webdriver-bidi/test/xpcshell/xpcshell.toml @@ -0,0 +1,3 @@ +[DEFAULT] + +["test_WebDriverBiDiConnection.js"] |