diff options
Diffstat (limited to 'remote/webdriver-bidi/modules/windowglobal')
4 files changed, 1335 insertions, 0 deletions
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; |