/* 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;