588 lines
16 KiB
JavaScript
588 lines
16 KiB
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/. */
|
|
|
|
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",
|
|
pprint: "chrome://remote/content/shared/Format.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 <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);
|
|
}
|
|
|
|
#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
|
|
),
|
|
lazy.pprint`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;
|