/* 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, { accessibility: "chrome://remote/content/shared/webdriver/Accessibility.sys.mjs", 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", OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", PollPromise: "chrome://remote/content/shared/Sync.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; } /** * Collect nodes using accessibility attributes. * * @see https://w3c.github.io/webdriver-bidi/#collect-nodes-using-accessibility-attributes */ async #collectNodesUsingAccessibilityAttributes( contextNodes, selector, maxReturnedNodeCount, returnedNodes ) { if (returnedNodes === null) { returnedNodes = []; } for (const contextNode of contextNodes) { let match = true; if (contextNode.nodeType === ELEMENT_NODE) { if ("role" in selector) { const role = await lazy.accessibility.getComputedRole(contextNode); if (selector.role !== role) { match = false; } } if ("name" in selector) { const name = await lazy.accessibility.getAccessibleName(contextNode); if (selector.name !== name) { match = false; } } } else { match = false; } if (match) { if ( maxReturnedNodeCount !== null && returnedNodes.length === maxReturnedNodeCount ) { break; } returnedNodes.push(contextNode); } const childNodes = [...contextNode.children]; await this.#collectNodesUsingAccessibilityAttributes( childNodes, selector, maxReturnedNodeCount, returnedNodes ); } return returnedNodes; } #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 ); } /** * Locate nodes using accessibility attributes. * * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-accessibility-attributes */ async #locateNodesUsingAccessibilityAttributes( contextNodes, selector, maxReturnedNodeCount ) { if (!("role" in selector) && !("name" in selector)) { throw new lazy.error.InvalidSelectorError( "Locating nodes by accessibility attributes requires `role` or `name` arguments" ); } return this.#collectNodesUsingAccessibilityAttributes( contextNodes, selector, maxReturnedNodeCount, null ); } /** * 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); } #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)); } }; /** * 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 rect1 and rect2. */ #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); } #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(); } /** * 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); }); } /** * Waits until the visibility state of the document has the expected value. * * @param {object} options * @param {number} options.value * Expected value of the visibility state. * * @returns {Promise} * Promise that resolves when the visibility state has the expected value. */ async _awaitVisibilityState(options) { const { value } = options; const win = this.messageHandler.window; await lazy.PollPromise((resolve, reject) => { if (win.document.visibilityState === value) { resolve(); } else { reject(); } }); } _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); } async _locateNodes(params = {}) { const { locator, maxNodeCount, serializationOptions, startNodes } = params; const realm = this.messageHandler.getRealm(); 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.accessibility: { returnedNodes = await this.#locateNodesUsingAccessibilityAttributes( contextNodes, locator.value, maxNodeCount ); break; } 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, lazy.OwnershipModel.None, realm, { seenNodeIds } ) ); } return { serializedNodes, _extraData: { seenNodeIds }, }; } } export const browsingContext = BrowsingContextModule;