summaryrefslogtreecommitdiffstats
path: root/testing/marionette/evaluate.js
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /testing/marionette/evaluate.js
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/marionette/evaluate.js')
-rw-r--r--testing/marionette/evaluate.js629
1 files changed, 629 insertions, 0 deletions
diff --git a/testing/marionette/evaluate.js b/testing/marionette/evaluate.js
new file mode 100644
index 0000000000..5dd22d4010
--- /dev/null
+++ b/testing/marionette/evaluate.js
@@ -0,0 +1,629 @@
+/* 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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["evaluate", "sandbox", "Sandboxes"];
+
+const { clearTimeout, setTimeout } = ChromeUtils.import(
+ "resource://gre/modules/Timer.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ assert: "chrome://marionette/content/assert.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ Log: "chrome://marionette/content/log.js",
+ WebElement: "chrome://marionette/content/element.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+const ARGUMENTS = "__webDriverArguments";
+const CALLBACK = "__webDriverCallback";
+const COMPLETE = "__webDriverComplete";
+const DEFAULT_TIMEOUT = 10000; // ms
+const FINISH = "finish";
+
+/** @namespace */
+this.evaluate = {};
+
+/**
+ * Evaluate a script in given sandbox.
+ *
+ * The the provided `script` will be wrapped in an anonymous function
+ * with the `args` argument applied.
+ *
+ * The arguments provided by the `args<` argument are exposed
+ * through the `arguments` object available in the script context,
+ * and if the script is executed asynchronously with the `async`
+ * option, an additional last argument that is synonymous to the
+ * name `resolve` is appended, and can be accessed
+ * through `arguments[arguments.length - 1]`.
+ *
+ * The `timeout` option specifies the duration for how long the
+ * script should be allowed to run before it is interrupted and aborted.
+ * An interrupted script will cause a {@link ScriptTimeoutError} to occur.
+ *
+ * The `async` option indicates that the script will not return
+ * until the `resolve` callback is invoked,
+ * which is analogous to the last argument of the `arguments` object.
+ *
+ * The `file` option is used in error messages to provide information
+ * on the origin script file in the local end.
+ *
+ * The `line` option is used in error messages, along with `filename`,
+ * to provide the line number in the origin script file on the local end.
+ *
+ * @param {nsISandbox} sb
+ * Sandbox the script will be evaluted in.
+ * @param {string} script
+ * Script to evaluate.
+ * @param {Array.<?>=} args
+ * A sequence of arguments to call the script with.
+ * @param {boolean=} [async=false] async
+ * Indicates if the script should return immediately or wait for
+ * the callback to be invoked before returning.
+ * @param {string=} [file="dummy file"] file
+ * File location of the program in the client.
+ * @param {number=} [line=0] line
+ * Line number of th eprogram in the client.
+ * @param {number=} [timeout=DEFAULT_TIMEOUT] timeout
+ * Duration in milliseconds before interrupting the script.
+ *
+ * @return {Promise}
+ * A promise that when resolved will give you the return value from
+ * the script. Note that the return value requires serialisation before
+ * it can be sent to the client.
+ *
+ * @throws {JavaScriptError}
+ * If an {@link Error} was thrown whilst evaluating the script.
+ * @throws {ScriptTimeoutError}
+ * If the script was interrupted due to script timeout.
+ */
+evaluate.sandbox = function(
+ sb,
+ script,
+ args = [],
+ {
+ async = false,
+ file = "dummy file",
+ line = 0,
+ timeout = DEFAULT_TIMEOUT,
+ } = {}
+) {
+ let unloadHandler;
+ let marionetteSandbox = sandbox.create(sb.window);
+
+ // timeout handler
+ let scriptTimeoutID, timeoutPromise;
+ if (timeout !== null) {
+ timeoutPromise = new Promise((resolve, reject) => {
+ scriptTimeoutID = setTimeout(() => {
+ reject(new error.ScriptTimeoutError(`Timed out after ${timeout} ms`));
+ }, timeout);
+ });
+ }
+
+ let promise = new Promise((resolve, reject) => {
+ let src = "";
+ sb[COMPLETE] = resolve;
+ sb[ARGUMENTS] = sandbox.cloneInto(args, sb);
+
+ // callback function made private
+ // so that introspection is possible
+ // on the arguments object
+ if (async) {
+ sb[CALLBACK] = sb[COMPLETE];
+ src += `${ARGUMENTS}.push(rv => ${CALLBACK}(rv));`;
+ }
+
+ src += `(function() {
+ ${script}
+ }).apply(null, ${ARGUMENTS})`;
+
+ unloadHandler = sandbox.cloneInto(
+ () => reject(new error.JavaScriptError("Document was unloaded")),
+ marionetteSandbox
+ );
+ marionetteSandbox.window.addEventListener("unload", unloadHandler);
+
+ let promises = [
+ Cu.evalInSandbox(
+ src,
+ sb,
+ "1.8",
+ file,
+ line,
+ /* enforceFilenameRestrictions */ false
+ ),
+ timeoutPromise,
+ ];
+
+ // Wait for the immediate result of calling evalInSandbox, or a timeout.
+ // Only resolve the promise if the scriptPromise was resolved and is not
+ // async, because the latter has to call resolve() itself.
+ Promise.race(promises).then(
+ value => {
+ if (!async) {
+ resolve(value);
+ }
+ },
+ err => {
+ reject(err);
+ }
+ );
+ });
+
+ // This block is mainly for async scripts, which escape the inner promise
+ // when calling resolve() on their own. The timeout promise will be re-used
+ // to break out after the initially setup timeout.
+ return Promise.race([promise, timeoutPromise])
+ .catch(err => {
+ // Only raise valid errors for both the sync and async scripts.
+ if (err instanceof error.ScriptTimeoutError) {
+ throw err;
+ }
+ throw new error.JavaScriptError(err);
+ })
+ .finally(() => {
+ clearTimeout(scriptTimeoutID);
+ marionetteSandbox.window.removeEventListener("unload", unloadHandler);
+ });
+};
+
+/**
+ * Convert any web elements in arbitrary objects to DOM elements by
+ * looking them up in the seen element store. For ElementIdentifiers a new
+ * entry in the seen element reference store gets added when running in the
+ * parent process, otherwise ContentDOMReference is used to retrieve the DOM
+ * node.
+ *
+ * @param {Object} obj
+ * Arbitrary object containing web elements or ElementIdentifiers.
+ * @param {(element.Store|element.ReferenceStore)=} seenEls
+ * Known element store to look up web elements from. If `seenEls` is an
+ * instance of `element.ReferenceStore`, return WebElement. If `seenEls`
+ * is an instance of `element.Store`, return Element. If `seenEls` is
+ * `undefined` the Element from the ContentDOMReference cache is returned
+ * when executed in the child process, in the parent process the WebElement
+ * is passed-through.
+ * @param {WindowProxy=} win
+ * Current browsing context, if `seenEls` is provided.
+ *
+ * @return {Object}
+ * Same object as provided by `obj` with the web elements
+ * replaced by DOM elements.
+ *
+ * @throws {NoSuchElementError}
+ * If `seenEls` is an `element.Store` and the web element reference has not
+ * been seen before.
+ * @throws {StaleElementReferenceError}
+ * If `seenEls` is an `element.ReferenceStore` or `element.Store` and the
+ * element has gone stale, indicating it is no longer attached to the DOM,
+ * or its node document is no longer the active document.
+ */
+evaluate.fromJSON = function(obj, seenEls = undefined, win = undefined) {
+ switch (typeof obj) {
+ case "boolean":
+ case "number":
+ case "string":
+ default:
+ return obj;
+
+ case "object":
+ if (obj === null) {
+ return obj;
+
+ // arrays
+ } else if (Array.isArray(obj)) {
+ return obj.map(e => evaluate.fromJSON(e, seenEls, win));
+
+ // ElementIdentifier and ReferenceStore (used by JSWindowActor)
+ } else if (WebElement.isReference(obj.webElRef)) {
+ if (seenEls instanceof element.ReferenceStore) {
+ // Parent: Store web element reference in the cache
+ return seenEls.add(obj);
+ } else if (!seenEls) {
+ // Child: Resolve ElementIdentifier by using ContentDOMReference
+ return element.resolveElement(obj, win);
+ }
+ throw new TypeError("seenEls is not an instance of ReferenceStore");
+
+ // WebElement and Store (used by framescript)
+ } else if (WebElement.isReference(obj)) {
+ const webEl = WebElement.fromJSON(obj);
+ if (seenEls instanceof element.Store) {
+ // Child: Get web element from the store
+ return seenEls.get(webEl, win);
+ } else if (!seenEls) {
+ // Parent: No conversion. Just return the web element
+ return webEl;
+ }
+ throw new TypeError("seenEls is not an instance of Store");
+ }
+
+ // arbitrary objects
+ let rv = {};
+ for (let prop in obj) {
+ rv[prop] = evaluate.fromJSON(obj[prop], seenEls, win);
+ }
+ return rv;
+ }
+};
+
+/**
+ * Marshal arbitrary objects to JSON-safe primitives that can be
+ * transported over the Marionette protocol or across processes.
+ *
+ * The marshaling rules are as follows:
+ *
+ * - Primitives are returned as is.
+ *
+ * - Collections, such as `Array<`, `NodeList`, `HTMLCollection`
+ * et al. are expanded to arrays and then recursed.
+ *
+ * - Elements that are not known web elements are added to the `seenEls` element
+ * store, or the ContentDOMReference registry. Once known, the elements'
+ * associated web element representation is returned.
+ *
+ * - WebElements are transformed to the corresponding ElementIdentifier
+ * for use in the content process, if an `element.ReferenceStore` is provided.
+ *
+ * - Objects with custom JSON representations, i.e. if they have
+ * a callable `toJSON` function, are returned verbatim. This means
+ * their internal integrity _are not_ checked. Be careful.
+ *
+ * - Other arbitrary objects are first tested for cyclic references
+ * and then recursed into.
+ *
+ * @param {Object} obj
+ * Object to be marshaled.
+ *
+ * @param {(element.Store|element.ReferenceStore)=} seenEls
+ * Element store to use for lookup of web element references.
+ *
+ * @return {Object}
+ * Same object as provided by `obj` with the elements
+ * replaced by web elements.
+ *
+ * @throws {JavaScriptError}
+ * If an object contains cyclic references.
+ */
+evaluate.toJSON = function(obj, seenEls) {
+ const t = Object.prototype.toString.call(obj);
+
+ // null
+ if (t == "[object Undefined]" || t == "[object Null]") {
+ return null;
+
+ // primitives
+ } else if (
+ t == "[object Boolean]" ||
+ t == "[object Number]" ||
+ t == "[object String]"
+ ) {
+ return obj;
+
+ // Array, NodeList, HTMLCollection, et al.
+ } else if (element.isCollection(obj)) {
+ assert.acyclic(obj);
+ return [...obj].map(el => evaluate.toJSON(el, seenEls));
+
+ // WebElement
+ } else if (WebElement.isReference(obj)) {
+ // Parent: Convert to ElementIdentifier for use in child actor
+ if (seenEls instanceof element.ReferenceStore) {
+ return seenEls.get(WebElement.fromJSON(obj));
+ }
+
+ return obj;
+
+ // ElementIdentifier
+ } else if (WebElement.isReference(obj.webElRef)) {
+ // Parent: Pass-through ElementIdentifiers to the child
+ if (seenEls instanceof element.ReferenceStore) {
+ return obj;
+ }
+
+ // Parent: Otherwise return the web element
+ return WebElement.fromJSON(obj.webElRef);
+
+ // Element (HTMLElement, SVGElement, XULElement, et al.)
+ } else if (element.isElement(obj)) {
+ // Parent
+ if (seenEls instanceof element.ReferenceStore) {
+ throw new TypeError(`ReferenceStore can't be used with Element`);
+
+ // Child: Add element to the Store, return as WebElement
+ } else if (seenEls instanceof element.Store) {
+ return seenEls.add(obj);
+ }
+
+ // If no storage has been specified assume we are in a child process.
+ // Evaluation of code will take place in mutable sandboxes, which are
+ // created to waive xrays by default. As such DOM nodes have to be unwaived
+ // before accessing the ownerGlobal is possible, which is needed by
+ // ContentDOMReference.
+ return element.getElementId(Cu.unwaiveXrays(obj));
+
+ // custom JSON representation
+ } else if (typeof obj.toJSON == "function") {
+ let unsafeJSON = obj.toJSON();
+ return evaluate.toJSON(unsafeJSON, seenEls);
+ }
+
+ // arbitrary objects + files
+ let rv = {};
+ for (let prop in obj) {
+ assert.acyclic(obj[prop]);
+
+ try {
+ rv[prop] = evaluate.toJSON(obj[prop], seenEls);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_NOT_IMPLEMENTED) {
+ logger.debug(`Skipping ${prop}: ${e.message}`);
+ } else {
+ throw e;
+ }
+ }
+ }
+ return rv;
+};
+
+/**
+ * Tests if an arbitrary object is cyclic.
+ *
+ * Element prototypes are by definition acyclic, even when they
+ * contain cyclic references. This is because `evaluate.toJSON`
+ * ensures they are marshaled as web elements.
+ *
+ * @param {*} value
+ * Object to test for cyclical references.
+ *
+ * @return {boolean}
+ * True if object is cyclic, false otherwise.
+ */
+evaluate.isCyclic = function(value, stack = []) {
+ let t = Object.prototype.toString.call(value);
+
+ // null
+ if (t == "[object Undefined]" || t == "[object Null]") {
+ return false;
+
+ // primitives
+ } else if (
+ t == "[object Boolean]" ||
+ t == "[object Number]" ||
+ t == "[object String]"
+ ) {
+ return false;
+
+ // HTMLElement, SVGElement, XULElement, et al.
+ } else if (element.isElement(value)) {
+ return false;
+
+ // Array, NodeList, HTMLCollection, et al.
+ } else if (element.isCollection(value)) {
+ if (stack.includes(value)) {
+ return true;
+ }
+ stack.push(value);
+
+ for (let i = 0; i < value.length; i++) {
+ if (evaluate.isCyclic(value[i], stack)) {
+ return true;
+ }
+ }
+
+ stack.pop();
+ return false;
+ }
+
+ // arbitrary objects
+ if (stack.includes(value)) {
+ return true;
+ }
+ stack.push(value);
+
+ for (let prop in value) {
+ if (evaluate.isCyclic(value[prop], stack)) {
+ return true;
+ }
+ }
+
+ stack.pop();
+ return false;
+};
+
+/**
+ * `Cu.isDeadWrapper` does not return true for a dead sandbox that
+ * was assosciated with and extension popup. This provides a way to
+ * still test for a dead object.
+ *
+ * @param {Object} obj
+ * A potentially dead object.
+ * @param {string} prop
+ * Name of a property on the object.
+ *
+ * @returns {boolean}
+ * True if <var>obj</var> is dead, false otherwise.
+ */
+evaluate.isDead = function(obj, prop) {
+ try {
+ obj[prop];
+ } catch (e) {
+ if (e.message.includes("dead object")) {
+ return true;
+ }
+ throw e;
+ }
+ return false;
+};
+
+this.sandbox = {};
+
+/**
+ * Provides a safe way to take an object defined in a privileged scope and
+ * create a structured clone of it in a less-privileged scope. It returns
+ * a reference to the clone.
+ *
+ * Unlike for {@link Components.utils.cloneInto}, `obj` may contain
+ * functions and DOM elements.
+ */
+sandbox.cloneInto = function(obj, sb) {
+ return Cu.cloneInto(obj, sb, { cloneFunctions: true, wrapReflectors: true });
+};
+
+/**
+ * Augment given sandbox by an adapter that has an `exports` map
+ * property, or a normal map, of function names and function references.
+ *
+ * @param {Sandbox} sb
+ * The sandbox to augment.
+ * @param {Object} adapter
+ * Object that holds an `exports` property, or a map, of function
+ * names and function references.
+ *
+ * @return {Sandbox}
+ * The augmented sandbox.
+ */
+sandbox.augment = function(sb, adapter) {
+ function* entries(obj) {
+ for (let key of Object.keys(obj)) {
+ yield [key, obj[key]];
+ }
+ }
+
+ let funcs = adapter.exports || entries(adapter);
+ for (let [name, func] of funcs) {
+ sb[name] = func;
+ }
+
+ return sb;
+};
+
+/**
+ * Creates a sandbox.
+ *
+ * @param {Window} win
+ * The DOM Window object.
+ * @param {nsIPrincipal=} principal
+ * An optional, custom principal to prefer over the Window. Useful if
+ * you need elevated security permissions.
+ *
+ * @return {Sandbox}
+ * The created sandbox.
+ */
+sandbox.create = function(win, principal = null, opts = {}) {
+ let p = principal || win;
+ opts = Object.assign(
+ {
+ sameZoneAs: win,
+ sandboxPrototype: win,
+ wantComponents: true,
+ wantXrays: true,
+ wantGlobalProperties: ["ChromeUtils"],
+ },
+ opts
+ );
+ return new Cu.Sandbox(p, opts);
+};
+
+/**
+ * Creates a mutable sandbox, where changes to the global scope
+ * will have lasting side-effects.
+ *
+ * @param {Window} win
+ * The DOM Window object.
+ *
+ * @return {Sandbox}
+ * The created sandbox.
+ */
+sandbox.createMutable = function(win) {
+ let opts = {
+ wantComponents: false,
+ wantXrays: false,
+ };
+ // Note: We waive Xrays here to match potentially-accidental old behavior.
+ return Cu.waiveXrays(sandbox.create(win, null, opts));
+};
+
+sandbox.createSystemPrincipal = function(win) {
+ let principal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
+ Ci.nsIPrincipal
+ );
+ return sandbox.create(win, principal);
+};
+
+sandbox.createSimpleTest = function(win, harness) {
+ let sb = sandbox.create(win);
+ sb = sandbox.augment(sb, harness);
+ sb[FINISH] = () => sb[COMPLETE](harness.generate_results());
+ return sb;
+};
+
+/**
+ * Sandbox storage. When the user requests a sandbox by a specific name,
+ * if one exists in the storage this will be used as long as its window
+ * reference is still valid.
+ *
+ * @memberof evaluate
+ */
+this.Sandboxes = class {
+ /**
+ * @param {function(): Window} windowFn
+ * A function that returns the references to the current Window
+ * object.
+ */
+ constructor(windowFn) {
+ this.windowFn_ = windowFn;
+ this.boxes_ = new Map();
+ }
+
+ get window_() {
+ return this.windowFn_();
+ }
+
+ /**
+ * Factory function for getting a sandbox by name, or failing that,
+ * creating a new one.
+ *
+ * If the sandbox' window does not match the provided window, a new one
+ * will be created.
+ *
+ * @param {string} name
+ * The name of the sandbox to get or create.
+ * @param {boolean=} [fresh=false] fresh
+ * Remove old sandbox by name first, if it exists.
+ *
+ * @return {Sandbox}
+ * A used or fresh sandbox.
+ */
+ get(name = "default", fresh = false) {
+ let sb = this.boxes_.get(name);
+ if (sb) {
+ if (fresh || evaluate.isDead(sb, "window") || sb.window != this.window_) {
+ this.boxes_.delete(name);
+ return this.get(name, false);
+ }
+ } else {
+ if (name == "system") {
+ sb = sandbox.createSystemPrincipal(this.window_);
+ } else {
+ sb = sandbox.create(this.window_);
+ }
+ this.boxes_.set(name, sb);
+ }
+ return sb;
+ }
+
+ /** Clears cache of sandboxes. */
+ clear() {
+ this.boxes_.clear();
+ }
+};