summaryrefslogtreecommitdiffstats
path: root/remote/webdriver-bidi/modules/windowglobal
diff options
context:
space:
mode:
Diffstat (limited to 'remote/webdriver-bidi/modules/windowglobal')
-rw-r--r--remote/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs475
-rw-r--r--remote/webdriver-bidi/modules/windowglobal/input.sys.mjs111
-rw-r--r--remote/webdriver-bidi/modules/windowglobal/log.sys.mjs256
-rw-r--r--remote/webdriver-bidi/modules/windowglobal/script.sys.mjs493
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;