summaryrefslogtreecommitdiffstats
path: root/devtools/shared/commands/script
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/shared/commands/script
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/commands/script')
-rw-r--r--devtools/shared/commands/script/moz.build10
-rw-r--r--devtools/shared/commands/script/script-command.js149
-rw-r--r--devtools/shared/commands/script/tests/browser.ini11
-rw-r--r--devtools/shared/commands/script/tests/browser_script_command_execute_basic.js1050
-rw-r--r--devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js41
-rw-r--r--devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js85
-rw-r--r--devtools/shared/commands/script/tests/browser_script_command_execute_throw.js75
-rw-r--r--devtools/shared/commands/script/tests/head.js51
8 files changed, 1472 insertions, 0 deletions
diff --git a/devtools/shared/commands/script/moz.build b/devtools/shared/commands/script/moz.build
new file mode 100644
index 0000000000..2387c7e63b
--- /dev/null
+++ b/devtools/shared/commands/script/moz.build
@@ -0,0 +1,10 @@
+# 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/.
+
+DevToolsModules(
+ "script-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/script/script-command.js b/devtools/shared/commands/script/script-command.js
new file mode 100644
index 0000000000..93917944d5
--- /dev/null
+++ b/devtools/shared/commands/script/script-command.js
@@ -0,0 +1,149 @@
+/* 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 {
+ getAdHocFrontOrPrimitiveGrip,
+ // eslint-disable-next-line mozilla/reject-some-requires
+} = require("resource://devtools/client/fronts/object.js");
+
+class ScriptCommand {
+ constructor({ commands }) {
+ this._commands = commands;
+ }
+
+ /**
+ * Execute a JavaScript expression.
+ *
+ * @param {String} expression: The code you want to evaluate.
+ * @param {Object} options: Options for evaluation:
+ * @param {Object} options.frameActor: a FrameActor ID. The actor holds a reference to
+ * a Debugger.Frame. This option allows you to evaluate the string in the frame
+ * of the given FrameActor.
+ * @param {String} options.url: the url to evaluate the script as. Defaults to "debugger eval code".
+ * @param {TargetFront} options.selectedTargetFront: When passed, the expression will be
+ * evaluated in the context of the target (as opposed to the default, top-level one).
+ * @param {String} options.selectedNodeActor: A NodeActor ID that may be used by helper
+ * functions that can reference the currently selected node in the Inspector, like $0.
+ * @param {String} options.selectedObjectActor: the actorID of a given objectActor.
+ * This is used by context menu entries to get a reference to an object, in order
+ * to perform some operation on it (copy it, store it as a global variable, …).
+ * @param {Number} options.innerWindowID: An optional window id to be used for the evaluation,
+ * instead of the regular webConsoleActor.evalWindow.
+ * This is used by functions that may want to evaluate in a different window (for
+ * example a non-remote iframe), like getting the elements of a given document.
+ * @param {object} options.mapped: An optional object indicating if the original expression
+ * entered by the users have been modified
+ * @param {boolean} options.mapped.await: true if the expression was a top-level await
+ * expression that was wrapped in an async-iife
+ *
+ * @return {Promise}: A promise that resolves with the response.
+ */
+ async execute(expression, options = {}) {
+ const {
+ selectedObjectActor,
+ selectedNodeActor,
+ frameActor,
+ selectedTargetFront,
+ } = options;
+
+ // Retrieve the right WebConsole front that relates either to (by order of priority):
+ // - the currently selected target in the context selector
+ // (selectedTargetFront argument),
+ // - the object picked in the console (when using store as global) (selectedObjectActor),
+ // - the currently selected Node in the inspector (selectedNodeActor),
+ // - the currently selected frame in the debugger (when paused) (frameActor),
+ // - the currently selected target in the iframe dropdown
+ // (selectedTargetFront from the TargetCommand)
+ let targetFront = this._commands.targetCommand.selectedTargetFront;
+
+ const selectedActor =
+ selectedObjectActor || selectedNodeActor || frameActor;
+
+ if (selectedTargetFront) {
+ targetFront = selectedTargetFront;
+ } else if (selectedActor) {
+ const selectedFront = this._commands.client.getFrontByID(selectedActor);
+ if (selectedFront) {
+ targetFront = selectedFront.targetFront;
+ }
+ }
+
+ const consoleFront = await targetFront.getFront("console");
+
+ // We call `evaluateJSAsync` RDP request, which immediately returns a simple `resultID`,
+ // for which we later receive a related `evaluationResult` RDP event, with the same resultID.
+ // The evaluation result will be contained in this RDP event.
+ let resultID;
+ const response = await new Promise(resolve => {
+ const offEvaluationResult = consoleFront.on(
+ "evaluationResult",
+ async packet => {
+ // In some cases, the evaluationResult event can be received before the call to
+ // evaluationJSAsync completes. So make sure to wait for the corresponding promise
+ // before handling the evaluationResult event.
+ await onEvaluateJSAsync;
+
+ if (packet.resultID === resultID) {
+ resolve(packet);
+ offEvaluationResult();
+ }
+ }
+ );
+
+ const onEvaluateJSAsync = consoleFront
+ .evaluateJSAsync({
+ text: expression,
+ eager: options.eager,
+ frameActor,
+ innerWindowID: options.innerWindowID,
+ mapped: options.mapped,
+ selectedNodeActor,
+ selectedObjectActor,
+ url: options.url,
+ })
+ .then(packet => {
+ resultID = packet.resultID;
+ });
+ });
+
+ // `response` is the packet sent via `evaluationResult` RDP event.
+ if (response.error) {
+ throw response;
+ }
+
+ if (response.result) {
+ response.result = getAdHocFrontOrPrimitiveGrip(
+ response.result,
+ consoleFront
+ );
+ }
+
+ if (response.helperResult?.object) {
+ response.helperResult.object = getAdHocFrontOrPrimitiveGrip(
+ response.helperResult.object,
+ consoleFront
+ );
+ }
+
+ if (response.exception) {
+ response.exception = getAdHocFrontOrPrimitiveGrip(
+ response.exception,
+ consoleFront
+ );
+ }
+
+ if (response.exceptionMessage) {
+ response.exceptionMessage = getAdHocFrontOrPrimitiveGrip(
+ response.exceptionMessage,
+ consoleFront
+ );
+ }
+
+ return response;
+ }
+}
+
+module.exports = ScriptCommand;
diff --git a/devtools/shared/commands/script/tests/browser.ini b/devtools/shared/commands/script/tests/browser.ini
new file mode 100644
index 0000000000..949c54f760
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ !/devtools/client/shared/test/shared-head.js
+ head.js
+
+[browser_script_command_execute_basic.js]
+[browser_script_command_execute_document__proto__.js]
+[browser_script_command_execute_last_result.js]
+[browser_script_command_execute_throw.js]
diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js
new file mode 100644
index 0000000000..e63f55a338
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js
@@ -0,0 +1,1050 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing basic expression evaluation
+const {
+ MAX_AUTOCOMPLETE_ATTEMPTS,
+ MAX_AUTOCOMPLETIONS,
+} = require("resource://devtools/shared/webconsole/js-property-provider.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+add_task(async () => {
+ const tab = await addTab(`data:text/html;charset=utf-8,
+ <!DOCTYPE html>
+ <html dir="ltr" class="class1">
+ <head><title>Testcase</title></head>
+ <script>
+ window.foobarObject = Object.create(
+ null,
+ Object.getOwnPropertyDescriptors({
+ foo: 1,
+ foobar: 2,
+ foobaz: 3,
+ omg: 4,
+ omgfoo: 5,
+ strfoo: "foobarz",
+ omgstr: "foobarz" + "abb".repeat(${DevToolsServer.LONG_STRING_LENGTH} * 2),
+ })
+ );
+
+ window.largeObject1 = Object.create(null);
+ for (let i = 0; i < ${MAX_AUTOCOMPLETE_ATTEMPTS} + 1; i++) {
+ window.largeObject1["a" + i] = i;
+ }
+
+ window.largeObject2 = Object.create(null);
+ for (let i = 0; i < ${MAX_AUTOCOMPLETIONS} * 2; i++) {
+ window.largeObject2["a" + i] = i;
+ }
+
+ var originalExec = RegExp.prototype.exec;
+
+ var promptIterable = { [Symbol.iterator]() { return { next: prompt } } };
+
+ function aliasedTest() {
+ const aliased = "ALIASED";
+ return [0].map(() => aliased)[0];
+ }
+
+ var testMap = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]);
+ var testSet = new Set([1, 2, 3, 4, 5]);
+ var testProxy = new Proxy({}, { getPrototypeOf: prompt });
+ var testArray = [1,2,3];
+ var testInt8Array = new Int8Array([1, 2, 3]);
+ var testArrayBuffer = testInt8Array.buffer;
+ var testDataView = new DataView(testArrayBuffer, 2);
+
+ var testCanvasContext = document.createElement("canvas").getContext("2d");
+
+ var objWithNativeGetter = {};
+ Object.defineProperty(objWithNativeGetter, "print", { get: print });
+ Object.defineProperty(objWithNativeGetter, "Element", { get: Element });
+ Object.defineProperty(objWithNativeGetter, "setAttribute", { get: Element.prototype.setAttribute });
+ Object.defineProperty(objWithNativeGetter, "setClassName", { get: Object.getOwnPropertyDescriptor(Element.prototype, "className").set });
+ Object.defineProperty(objWithNativeGetter, "requestPermission", { get: Notification.requestPermission });
+
+ async function testAsync() { return 10; }
+ async function testAsyncAwait() { await 1; return 10; }
+ async function * testAsyncGen() { return 10; }
+ async function * testAsyncGenAwait() { await 1; return 10; }
+
+ function testFunc() {}
+
+ var testLocale = new Intl.Locale("de-latn-de-u-ca-gregory-co-phonebk-hc-h23-kf-true-kn-false-nu-latn");
+ </script>
+ <body id="body1" class="class2"><h1>Body text</h1></body>
+ </html>`);
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ await doSimpleEval(commands);
+ await doWindowEval(commands);
+ await doEvalWithException(commands);
+ await doEvalWithHelper(commands);
+ await doEvalString(commands);
+ await doEvalLongString(commands);
+ await doEvalWithBinding(commands);
+ await forceLexicalInit(commands);
+ await doSimpleEagerEval(commands);
+ await doEagerEvalWithSideEffect(commands);
+ await doEagerEvalWithSideEffectIterator(commands);
+ await doEagerEvalWithSideEffectMonkeyPatched(commands);
+ await doEagerEvalESGetters(commands);
+ await doEagerEvalDOMGetters(commands);
+ await doEagerEvalOtherNativeGetters(commands);
+ await doEagerEvalAsyncFunctions(commands);
+
+ await commands.destroy();
+});
+
+async function doSimpleEval(commands) {
+ info("test eval '2+2'");
+ const response = await commands.scriptCommand.execute("2+2");
+ checkObject(response, {
+ input: "2+2",
+ result: 4,
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+}
+
+async function doWindowEval(commands) {
+ info("test eval 'document'");
+ const response = await commands.scriptCommand.execute("document");
+ checkObject(response, {
+ input: "document",
+ result: {
+ type: "object",
+ class: "HTMLDocument",
+ actor: /[a-z]/,
+ },
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+}
+
+async function doEvalWithException(commands) {
+ info("test eval with exception");
+ const response = await commands.scriptCommand.execute(
+ "window.doTheImpossible()"
+ );
+ checkObject(response, {
+ input: "window.doTheImpossible()",
+ result: {
+ type: "undefined",
+ },
+ exceptionMessage: /doTheImpossible/,
+ });
+
+ ok(response.exception, "js eval exception");
+ ok(!response.helperResult, "no helper result");
+}
+
+async function doEvalWithHelper(commands) {
+ info("test eval with helper");
+ const response = await commands.scriptCommand.execute("clear()");
+ checkObject(response, {
+ input: "clear()",
+ result: {
+ type: "undefined",
+ },
+ helperResult: { type: "clearOutput" },
+ });
+
+ ok(!response.exception, "no eval exception");
+}
+
+async function doEvalString(commands) {
+ const response = await commands.scriptCommand.execute(
+ "window.foobarObject.strfoo"
+ );
+
+ checkObject(response, {
+ input: "window.foobarObject.strfoo",
+ result: "foobarz",
+ });
+}
+
+async function doEvalLongString(commands) {
+ const response = await commands.scriptCommand.execute(
+ "window.foobarObject.omgstr"
+ );
+
+ const str = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ function () {
+ return content.wrappedJSObject.foobarObject.omgstr;
+ }
+ );
+
+ const initial = str.substring(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH);
+
+ checkObject(response, {
+ input: "window.foobarObject.omgstr",
+ result: {
+ type: "longString",
+ initial,
+ length: str.length,
+ },
+ });
+}
+
+async function doEvalWithBinding(commands) {
+ const response = await commands.scriptCommand.execute("document;");
+ const documentActor = response.result.actorID;
+
+ info("running a command with _self as document using selectedObjectActor");
+ const selectedObjectSame = await commands.scriptCommand.execute(
+ "_self === document",
+ {
+ selectedObjectActor: documentActor,
+ }
+ );
+ checkObject(selectedObjectSame, {
+ result: true,
+ });
+}
+
+async function forceLexicalInit(commands) {
+ info("test that failed let/const bindings are initialized to undefined");
+
+ const testData = [
+ {
+ stmt: "let foopie = wubbalubadubdub",
+ vars: ["foopie"],
+ },
+ {
+ stmt: "let {z, w={n}=null} = {}",
+ vars: ["z", "w"],
+ },
+ {
+ stmt: "let [a, b, c] = null",
+ vars: ["a", "b", "c"],
+ },
+ {
+ stmt: "const nein1 = rofl, nein2 = copter",
+ vars: ["nein1", "nein2"],
+ },
+ {
+ stmt: "const {ha} = null",
+ vars: ["ha"],
+ },
+ {
+ stmt: "const [haw=[lame]=null] = []",
+ vars: ["haw"],
+ },
+ {
+ stmt: "const [rawr, wat=[lame]=null] = []",
+ vars: ["rawr", "haw"],
+ },
+ {
+ stmt: "let {zzz: xyz=99, zwz: wb} = nexistepas()",
+ vars: ["xyz", "wb"],
+ },
+ {
+ stmt: "let {c3pdoh=101} = null",
+ vars: ["c3pdoh"],
+ },
+ {
+ stmt: "const {...x} = x",
+ vars: ["x"],
+ },
+ {
+ stmt: "const {xx,yy,...rest} = null",
+ vars: ["xx", "yy", "rest"],
+ },
+ ];
+
+ for (const data of testData) {
+ const response = await commands.scriptCommand.execute(data.stmt);
+ checkObject(response, {
+ input: data.stmt,
+ result: { type: "undefined" },
+ });
+ ok(response.exception, "expected exception");
+ for (const varName of data.vars) {
+ const response2 = await commands.scriptCommand.execute(varName);
+ checkObject(response2, {
+ input: varName,
+ result: { type: "undefined" },
+ });
+ ok(!response2.exception, "unexpected exception");
+ }
+ }
+}
+
+async function doSimpleEagerEval(commands) {
+ const testData = [
+ {
+ code: "2+2",
+ result: 4,
+ },
+ {
+ code: "(x => x * 2)(3)",
+ result: 6,
+ },
+ {
+ code: "[1, 2, 3].map(x => x * 2).join()",
+ result: "2,4,6",
+ },
+ {
+ code: `"abc".match(/a./)[0]`,
+ result: "ab",
+ },
+ {
+ code: "aliasedTest()",
+ result: "ALIASED",
+ },
+ {
+ code: "testArray.concat([4,5]).join()",
+ result: "1,2,3,4,5",
+ },
+ {
+ code: "testArray.entries().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testArray.keys().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testArray.values().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testArray.every(x => x < 100)",
+ result: true,
+ },
+ {
+ code: "testArray.some(x => x > 1)",
+ result: true,
+ },
+ {
+ code: "testArray.filter(x => x % 2 == 0).join()",
+ result: "2",
+ },
+ {
+ code: "testArray.find(x => x % 2 == 0)",
+ result: 2,
+ },
+ {
+ code: "testArray.findIndex(x => x % 2 == 0)",
+ result: 1,
+ },
+ {
+ code: "[testArray].flat().join()",
+ result: "1,2,3",
+ },
+ {
+ code: "[testArray].flatMap(x => x).join()",
+ result: "1,2,3",
+ },
+ {
+ code: "testArray.forEach(x => x); testArray.join()",
+ result: "1,2,3",
+ },
+ {
+ code: "testArray.includes(1)",
+ result: true,
+ },
+ {
+ code: "testArray.lastIndexOf(1)",
+ result: 0,
+ },
+ {
+ code: "testArray.map(x => x + 1).join()",
+ result: "2,3,4",
+ },
+ {
+ code: "testArray.reduce((acc,x) => acc + x, 0)",
+ result: 6,
+ },
+ {
+ code: "testArray.reduceRight((acc,x) => acc + x, 0)",
+ result: 6,
+ },
+ {
+ code: "testArray.slice(0,1).join()",
+ result: "1",
+ },
+ {
+ code: "testArray.toReversed().join()",
+ result: "3,2,1",
+ },
+ {
+ code: "testArray.toSorted().join()",
+ result: "1,2,3",
+ },
+ {
+ code: "testArray.toSpliced(0,1).join()",
+ result: "2,3",
+ },
+ {
+ code: "testArray.with(1, 'b').join()",
+ result: "1,b,3",
+ },
+
+ {
+ code: "testInt8Array.entries().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testInt8Array.keys().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testInt8Array.values().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testInt8Array.every(x => x < 100)",
+ result: true,
+ },
+ {
+ code: "testInt8Array.some(x => x > 1)",
+ result: true,
+ },
+ {
+ code: "testInt8Array.filter(x => x % 2 == 0).join()",
+ result: "2",
+ },
+ {
+ code: "testInt8Array.find(x => x % 2 == 0)",
+ result: 2,
+ },
+ {
+ code: "testInt8Array.findIndex(x => x % 2 == 0)",
+ result: 1,
+ },
+ {
+ code: "testInt8Array.forEach(x => x); testInt8Array.join()",
+ result: "1,2,3",
+ },
+ {
+ code: "testInt8Array.includes(1)",
+ result: true,
+ },
+ {
+ code: "testInt8Array.lastIndexOf(1)",
+ result: 0,
+ },
+ {
+ code: "testInt8Array.map(x => x + 1).join()",
+ result: "2,3,4",
+ },
+ {
+ code: "testInt8Array.reduce((acc,x) => acc + x, 0)",
+ result: 6,
+ },
+ {
+ code: "testInt8Array.reduceRight((acc,x) => acc + x, 0)",
+ result: 6,
+ },
+ {
+ code: "testInt8Array.slice(0,1).join()",
+ result: "1",
+ },
+ {
+ code: "testInt8Array.toReversed().join()",
+ skip:
+ typeof Reflect.getPrototypeOf(Int8Array).prototype.toReversed !==
+ "function",
+ result: "3,2,1",
+ },
+ {
+ code: "testInt8Array.toSorted().join()",
+ skip:
+ typeof Reflect.getPrototypeOf(Int8Array).prototype.toSorted !==
+ "function",
+ result: "1,2,3",
+ },
+ {
+ code: "testInt8Array.with(1, 0).join()",
+ skip:
+ typeof Reflect.getPrototypeOf(Int8Array).prototype.with !== "function",
+ result: "1,0,3",
+ },
+ ];
+
+ for (const { code, result, skip } of testData) {
+ if (skip) {
+ info(`Skipping evaluation of ${code}`);
+ continue;
+ }
+
+ info(`Evaluating: ${code}`);
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(response, {
+ input: code,
+ result,
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalWithSideEffect(commands) {
+ const testData = [
+ // Modify environment.
+ "var a = 10; a;",
+
+ // Directly call a funtion with side effect.
+ "prompt();",
+
+ // Call a funtion with side effect inside a scripted function.
+ "(() => { prompt(); })()",
+
+ // Call a funtion with side effect from self-hosted JS function.
+ "[1, 2, 3].map(prompt)",
+
+ // Call a function with Function.prototype.call.
+ "Function.prototype.call.bind(Function.prototype.call)(prompt);",
+
+ // Call a function with Function.prototype.apply.
+ "Function.prototype.apply.bind(Function.prototype.apply)(prompt);",
+
+ // Indirectly call a function with Function.prototype.apply.
+ "Reflect.apply(prompt, null, []);",
+ "'aaaaaaaa'.replace(/(a)(a)(a)(a)(a)(a)(a)(a)/, prompt)",
+
+ // Indirect call on obj[Symbol.iterator]().next.
+ "Array.from(promptIterable)",
+ ];
+
+ for (const code of testData) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(response, {
+ input: code,
+ result: { type: "undefined" },
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalWithSideEffectIterator(commands) {
+ // Indirect call on %ArrayIterator%.prototype.next,
+
+ // Create an iterable object that reuses iterator across multiple call.
+ let response = await commands.scriptCommand.execute(`
+var arr = [1, 2, 3];
+var iterator = arr[Symbol.iterator]();
+var iterable = { [Symbol.iterator]() { return iterator; } };
+"ok";
+`);
+ checkObject(response, {
+ result: "ok",
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+
+ const testData = [
+ "Array.from(iterable)",
+ "new Map(iterable)",
+ "new Set(iterable)",
+ ];
+
+ for (const code of testData) {
+ response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(response, {
+ input: code,
+ result: { type: "undefined" },
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ // Verify the iterator's internal state isn't modified.
+ response = await commands.scriptCommand.execute(`[...iterator].join(",")`);
+ checkObject(response, {
+ result: "1,2,3",
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+}
+
+async function doEagerEvalWithSideEffectMonkeyPatched(commands) {
+ // Patch the built-in function without eager evaluation.
+ let response = await commands.scriptCommand.execute(
+ `RegExp.prototype.exec = prompt; "patched"`
+ );
+ checkObject(response, {
+ result: "patched",
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+
+ // Test eager evaluation, where the patched built-in is called internally.
+ // This should be aborted.
+ const code = `"abc".match(/a./)[0]`;
+ response = await commands.scriptCommand.execute(code, { eager: true });
+ checkObject(response, {
+ input: code,
+ result: { type: "undefined" },
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+
+ // Undo the patch without eager evaluation.
+ response = await commands.scriptCommand.execute(
+ `RegExp.prototype.exec = originalExec; "unpatched"`
+ );
+ checkObject(response, {
+ result: "unpatched",
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+
+ // Test eager evaluation again, without the patch.
+ // This should be evaluated.
+ response = await commands.scriptCommand.execute(code, { eager: true });
+ checkObject(response, {
+ input: code,
+ result: "ab",
+ });
+}
+
+async function doEagerEvalESGetters(commands) {
+ // [code, expectedResult]
+ const testData = [
+ // ArrayBuffer
+ ["testArrayBuffer.byteLength", 3],
+
+ // DataView
+ ["testDataView.buffer === testArrayBuffer", true],
+ ["testDataView.byteLength", 1],
+ ["testDataView.byteOffset", 2],
+
+ // Error
+ ["typeof new Error().stack", "string"],
+
+ // Function
+ ["typeof testFunc.arguments", "object"],
+ ["typeof testFunc.caller", "object"],
+
+ // Intl.Locale
+ ["testLocale.baseName", "de-Latn-DE"],
+ ["testLocale.calendar", "gregory"],
+ ["testLocale.caseFirst", ""],
+ ["testLocale.collation", "phonebk"],
+ ["testLocale.hourCycle", "h23"],
+ ["testLocale.numeric", false],
+ ["testLocale.numberingSystem", "latn"],
+ ["testLocale.language", "de"],
+ ["testLocale.script", "Latn"],
+ ["testLocale.region", "DE"],
+
+ // Map
+ ["testMap.size", 4],
+
+ // RegExp
+ ["/a/.dotAll", false],
+ ["/a/giy.flags", "giy"],
+ ["/a/g.global", true],
+ ["/a/g.hasIndices", false],
+ ["/a/g.ignoreCase", false],
+ ["/a/g.multiline", false],
+ ["/a/g.source", "a"],
+ ["/a/g.sticky", false],
+ ["/a/g.unicode", false],
+
+ // Set
+ ["testSet.size", 5],
+
+ // Symbol
+ ["Symbol.iterator.description", "Symbol.iterator"],
+
+ // TypedArray
+ ["testInt8Array.buffer === testArrayBuffer", true],
+ ["testInt8Array.byteLength", 3],
+ ["testInt8Array.byteOffset", 0],
+ ["testInt8Array.length", 3],
+ ["testInt8Array[Symbol.toStringTag]", "Int8Array"],
+ ];
+
+ for (const [code, expectedResult] of testData) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ // Test RegExp static properties.
+ // Run preparation code here to avoid interference with other tests,
+ // given RegExp static properties are global state.
+ const regexpPreparationCode = `
+/b(c)(d)(e)(f)(g)(h)(i)(j)(k)l/.test("abcdefghijklm")
+`;
+
+ const prepResponse = await commands.scriptCommand.execute(
+ regexpPreparationCode
+ );
+ checkObject(prepResponse, {
+ input: regexpPreparationCode,
+ result: true,
+ });
+
+ ok(!prepResponse.exception, "no eval exception");
+ ok(!prepResponse.helperResult, "no helper result");
+
+ const testDataRegExp = [
+ // RegExp static
+ ["RegExp.input", "abcdefghijklm"],
+ ["RegExp.lastMatch", "bcdefghijkl"],
+ ["RegExp.lastParen", "k"],
+ ["RegExp.leftContext", "a"],
+ ["RegExp.rightContext", "m"],
+ ["RegExp.$1", "c"],
+ ["RegExp.$2", "d"],
+ ["RegExp.$3", "e"],
+ ["RegExp.$4", "f"],
+ ["RegExp.$5", "g"],
+ ["RegExp.$6", "h"],
+ ["RegExp.$7", "i"],
+ ["RegExp.$8", "j"],
+ ["RegExp.$9", "k"],
+ ["RegExp.$_", "abcdefghijklm"], // input
+ ["RegExp['$&']", "bcdefghijkl"], // lastMatch
+ ["RegExp['$+']", "k"], // lastParen
+ ["RegExp['$`']", "a"], // leftContext
+ ["RegExp[`$'`]", "m"], // rightContext
+ ];
+
+ for (const [code, expectedResult] of testDataRegExp) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ const testDataWithSideEffect = [
+ // get Object.prototype.__proto__
+ //
+ // This can invoke Proxy getPrototypeOf handler, which can be any native
+ // function, and debugger cannot hook the call.
+ `[].__proto__`,
+ `testProxy.__proto__`,
+ ];
+
+ for (const code of testDataWithSideEffect) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: { type: "undefined" },
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalDOMGetters(commands) {
+ // Getters explicitly marked no-side-effect.
+ //
+ // [code, expectedResult]
+ const testDataExplicit = [
+ // DOMTokenList
+ ["document.documentElement.classList.length", 1],
+ ["document.documentElement.classList.value", "class1"],
+
+ // Document
+ ["document.URL.startsWith('data:')", true],
+ ["document.documentURI.startsWith('data:')", true],
+ ["document.compatMode", "CSS1Compat"],
+ ["document.characterSet", "UTF-8"],
+ ["document.charset", "UTF-8"],
+ ["document.inputEncoding", "UTF-8"],
+ ["document.contentType", "text/html"],
+ ["document.doctype.constructor.name", "DocumentType"],
+ ["document.documentElement.constructor.name", "HTMLHtmlElement"],
+ ["document.title", "Testcase"],
+ ["document.dir", "ltr"],
+ ["document.body.constructor.name", "HTMLBodyElement"],
+ ["document.head.constructor.name", "HTMLHeadElement"],
+ ["document.images.constructor.name", "HTMLCollection"],
+ ["document.embeds.constructor.name", "HTMLCollection"],
+ ["document.plugins.constructor.name", "HTMLCollection"],
+ ["document.links.constructor.name", "HTMLCollection"],
+ ["document.forms.constructor.name", "HTMLCollection"],
+ ["document.scripts.constructor.name", "HTMLCollection"],
+ ["document.defaultView === window", true],
+ ["typeof document.currentScript", "object"],
+ ["document.anchors.constructor.name", "HTMLCollection"],
+ ["document.applets.constructor.name", "HTMLCollection"],
+ ["document.all.constructor.name", "HTMLAllCollection"],
+ ["document.styleSheetSets.constructor.name", "DOMStringList"],
+ ["typeof document.featurePolicy", "undefined"],
+ ["typeof document.blockedNodeByClassifierCount", "undefined"],
+ ["typeof document.blockedNodesByClassifier", "undefined"],
+ ["typeof document.permDelegateHandler", "undefined"],
+ ["document.children.constructor.name", "HTMLCollection"],
+ ["document.firstElementChild === document.documentElement", true],
+ ["document.lastElementChild === document.documentElement", true],
+ ["document.childElementCount", 1],
+ ["document.location.href.startsWith('data:')", true],
+
+ // Element
+ ["document.body.namespaceURI", "http://www.w3.org/1999/xhtml"],
+ ["document.body.prefix === null", true],
+ ["document.body.localName", "body"],
+ ["document.body.tagName", "BODY"],
+ ["document.body.id", "body1"],
+ ["document.body.className", "class2"],
+ ["document.body.classList.constructor.name", "DOMTokenList"],
+ ["document.body.part.constructor.name", "DOMTokenList"],
+ ["document.body.attributes.constructor.name", "NamedNodeMap"],
+ ["document.body.innerHTML.includes('Body text')", true],
+ ["document.body.outerHTML.includes('Body text')", true],
+ ["document.body.previousElementSibling !== null", true],
+ ["document.body.nextElementSibling === null", true],
+ ["document.body.children.constructor.name", "HTMLCollection"],
+ ["document.body.firstElementChild !== null", true],
+ ["document.body.lastElementChild !== null", true],
+ ["document.body.childElementCount", 1],
+
+ // Node
+ ["document.body.nodeType === Node.ELEMENT_NODE", true],
+ ["document.body.nodeName", "BODY"],
+ ["document.body.baseURI.startsWith('data:')", true],
+ ["document.body.isConnected", true],
+ ["document.body.ownerDocument === document", true],
+ ["document.body.parentNode === document.documentElement", true],
+ ["document.body.parentElement === document.documentElement", true],
+ ["document.body.childNodes.constructor.name", "NodeList"],
+ ["document.body.firstChild !== null", true],
+ ["document.body.lastChild !== null", true],
+ ["document.body.previousSibling !== null", true],
+ ["document.body.nextSibling === null", true],
+ ["document.body.nodeValue === null", true],
+ ["document.body.textContent.includes('Body text')", true],
+ ["typeof document.body.flattenedTreeParentNode", "undefined"],
+ ["typeof document.body.isNativeAnonymous", "undefined"],
+ ["typeof document.body.containingShadowRoot", "undefined"],
+ ["typeof document.body.accessibleNode", "undefined"],
+
+ // Performance
+ ["performance.timeOrigin > 0", true],
+ ["performance.timing.constructor.name", "PerformanceTiming"],
+ ["performance.navigation.constructor.name", "PerformanceNavigation"],
+ ["performance.eventCounts.constructor.name", "EventCounts"],
+
+ // window
+ ["window.window === window", true],
+ ["window.self === window", true],
+ ["window.document.constructor.name", "HTMLDocument"],
+ ["window.performance.constructor.name", "Performance"],
+ ["typeof window.browsingContext", "undefined"],
+ ["typeof window.windowUtils", "undefined"],
+ ["typeof window.windowGlobalChild", "undefined"],
+ ["window.visualViewport.constructor.name", "VisualViewport"],
+ ["typeof window.caches", "undefined"],
+ ["window.location.href.startsWith('data:')", true],
+ ];
+ if (typeof Scheduler === "function") {
+ // Scheduler is behind a pref.
+ testDataExplicit.push(["window.scheduler.constructor.name", "Scheduler"]);
+ }
+
+ for (const [code, expectedResult] of testDataExplicit) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ // Getters not-explicitly marked no-side-effect.
+ // All DOM getters are considered no-side-effect in eager evaluation context.
+ const testDataImplicit = [
+ // NOTE: This is not an exhaustive list.
+ // Document
+ [`document.implementation.constructor.name`, "DOMImplementation"],
+ [`typeof document.domain`, "string"],
+ [`typeof document.referrer`, "string"],
+ [`typeof document.cookie`, "string"],
+ [`typeof document.lastModified`, "string"],
+ [`typeof document.readyState`, "string"],
+ [`typeof document.designMode`, "string"],
+ [`typeof document.onbeforescriptexecute`, "object"],
+ [`typeof document.onafterscriptexecute`, "object"],
+
+ // Element
+ [`typeof document.documentElement.scrollTop`, "number"],
+ [`typeof document.documentElement.scrollLeft`, "number"],
+ [`typeof document.documentElement.scrollWidth`, "number"],
+ [`typeof document.documentElement.scrollHeight`, "number"],
+
+ // Performance
+ [`typeof performance.onresourcetimingbufferfull`, "object"],
+
+ // window
+ [`typeof window.name`, "string"],
+ [`window.history.constructor.name`, "History"],
+ [`window.customElements.constructor.name`, "CustomElementRegistry"],
+ [`window.locationbar.constructor.name`, "BarProp"],
+ [`window.menubar.constructor.name`, "BarProp"],
+ [`typeof window.status`, "string"],
+ [`window.closed`, false],
+
+ // CanvasRenderingContext2D / CanvasCompositing
+ [`testCanvasContext.globalAlpha`, 1],
+ ];
+
+ for (const [code, expectedResult] of testDataImplicit) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalOtherNativeGetters(commands) {
+ // DOM getter functions are allowed to be eagerly-evaluated.
+ // Test the situation where non-DOM-getter function is called by accessing
+ // getter.
+ //
+ // "being a DOM getter" is tested by checking if the native function has
+ // JSJitInfo and it's marked as getter.
+ const testData = [
+ // Has no JitInfo.
+ "objWithNativeGetter.print",
+ "objWithNativeGetter.Element",
+
+ // Not marked as getter, but method.
+ "objWithNativeGetter.getAttribute",
+
+ // Not marked as getter, but setter.
+ "objWithNativeGetter.setClassName",
+
+ // Not marked as getter, but static method.
+ "objWithNativeGetter.requestPermission",
+ ];
+
+ for (const code of testData) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: { type: "undefined" },
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalAsyncFunctions(commands) {
+ // [code, expectedResult]
+ const testData = [["typeof testAsync()", "object"]];
+
+ for (const [code, expectedResult] of testData) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ const testDataWithSideEffect = [
+ // await is effectful
+ "testAsyncAwait()",
+
+ // initial yield is effectful
+ "testAsyncGen()",
+ "testAsyncGenAwait()",
+ ];
+
+ for (const code of testDataWithSideEffect) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: { type: "undefined" },
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js
new file mode 100644
index 0000000000..28f56ebac3
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing evaluating document.__proto__
+
+add_task(async () => {
+ const tab = await addTab(
+ `data:text/html;charset=utf-8,Test evaluating document.__proto__`
+ );
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ const evaluationResponse = await commands.scriptCommand.execute(
+ "document.__proto__"
+ );
+ checkObject(evaluationResponse, {
+ input: "document.__proto__",
+ result: {
+ type: "object",
+ actor: /[a-z]/,
+ },
+ });
+
+ ok(!evaluationResponse.exception, "no eval exception");
+ ok(!evaluationResponse.helperResult, "no helper result");
+
+ const response = await evaluationResponse.result.getPrototypeAndProperties();
+ ok(!response.error, "no response error");
+
+ const props = response.ownProperties;
+ ok(props, "response properties available");
+
+ const expectedProps = Object.getOwnPropertyNames(
+ Object.getPrototypeOf(document)
+ );
+ checkObject(Object.keys(props), expectedProps, "Same own properties.");
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js
new file mode 100644
index 0000000000..aebdaeb168
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing that last evaluation result can be accessed with `$_`
+
+add_task(async () => {
+ const tab = await addTab(`data:text/html;charset=utf-8,`);
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ info("$_ returns undefined if nothing has evaluated yet");
+ let response = await commands.scriptCommand.execute("$_");
+ basicResultCheck(response, "$_", { type: "undefined" });
+
+ info("$_ returns last value and performs basic arithmetic");
+ response = await commands.scriptCommand.execute("2+2");
+ basicResultCheck(response, "2+2", 4);
+
+ response = await commands.scriptCommand.execute("$_");
+ basicResultCheck(response, "$_", 4);
+
+ response = await commands.scriptCommand.execute("$_ + 2");
+ basicResultCheck(response, "$_ + 2", 6);
+
+ response = await commands.scriptCommand.execute("$_ + 4");
+ basicResultCheck(response, "$_ + 4", 10);
+
+ info("$_ has correct references to objects");
+ response = await commands.scriptCommand.execute("var foo = {bar:1}; foo;");
+ basicResultCheck(response, "var foo = {bar:1}; foo;", {
+ type: "object",
+ class: "Object",
+ actor: /[a-z]/,
+ });
+ checkObject(response.result.getGrip().preview.ownProperties, {
+ bar: {
+ value: 1,
+ },
+ });
+
+ response = await commands.scriptCommand.execute("$_");
+ basicResultCheck(response, "$_", {
+ type: "object",
+ class: "Object",
+ actor: /[a-z]/,
+ });
+ checkObject(response.result.getGrip().preview.ownProperties, {
+ bar: {
+ value: 1,
+ },
+ });
+
+ info(
+ "Update a property value and check that evaluating $_ returns the expected object instance"
+ );
+ await ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
+ content.wrappedJSObject.foo.bar = "updated_value";
+ });
+
+ response = await commands.scriptCommand.execute("$_");
+ basicResultCheck(response, "$_", {
+ type: "object",
+ class: "Object",
+ actor: /[a-z]/,
+ });
+ checkObject(response.result.getGrip().preview.ownProperties, {
+ bar: {
+ value: "updated_value",
+ },
+ });
+
+ await commands.destroy();
+});
+
+function basicResultCheck(response, input, output) {
+ checkObject(response, {
+ input,
+ result: output,
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+}
diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js
new file mode 100644
index 0000000000..8680193ecb
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing evaluating thowing expressions
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+add_task(async () => {
+ const tab = await addTab(`data:text/html;charset=utf-8,Test throw`);
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ const falsyValues = [
+ "-0",
+ "null",
+ "undefined",
+ "Infinity",
+ "-Infinity",
+ "NaN",
+ ];
+ for (const value of falsyValues) {
+ const response = await commands.scriptCommand.execute(`throw ${value};`);
+ is(
+ response.exception.type,
+ value,
+ `Got the expected value for response.exception.type when throwing "${value}"`
+ );
+ }
+
+ const identityTestValues = [false, 0];
+ for (const value of identityTestValues) {
+ const response = await commands.scriptCommand.execute(`throw ${value};`);
+ is(
+ response.exception,
+ value,
+ `Got the expected value for response.exception when throwing "${value}"`
+ );
+ }
+
+ const symbolTestValues = [
+ ["Symbol.iterator", "Symbol(Symbol.iterator)"],
+ ["Symbol('foo')", "Symbol(foo)"],
+ ["Symbol()", "Symbol()"],
+ ];
+ for (const [expr, message] of symbolTestValues) {
+ const response = await commands.scriptCommand.execute(`throw ${expr};`);
+ is(
+ response.exceptionMessage,
+ message,
+ `Got the expected value for response.exceptionMessage when throwing "${expr}"`
+ );
+ }
+
+ const longString = Array(DevToolsServer.LONG_STRING_LENGTH + 1).join("a"),
+ shortedString = longString.substring(
+ 0,
+ DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ );
+ const response = await commands.scriptCommand.execute(
+ "throw '" + longString + "';"
+ );
+ is(
+ response.exception.initial,
+ shortedString,
+ "Got the expected value for exception.initial when throwing a longString"
+ );
+ is(
+ response.exceptionMessage.initial,
+ shortedString,
+ "Got the expected value for exceptionMessage.initial when throwing a longString"
+ );
+});
diff --git a/devtools/shared/commands/script/tests/head.js b/devtools/shared/commands/script/tests/head.js
new file mode 100644
index 0000000000..50635e4502
--- /dev/null
+++ b/devtools/shared/commands/script/tests/head.js
@@ -0,0 +1,51 @@
+/* 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";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
+ this
+);
+
+function checkObject(object, expected, message) {
+ if (object && object.getGrip) {
+ object = object.getGrip();
+ }
+
+ for (const name of Object.keys(expected)) {
+ const expectedValue = expected[name];
+ const value = object[name];
+ checkValue(name, value, expectedValue, message);
+ }
+}
+
+function checkValue(name, value, expected, message) {
+ if (message) {
+ message = ` for '${message}'`;
+ }
+
+ if (expected === null) {
+ is(value, null, `'${name}' is null${message}`);
+ } else if (expected === undefined) {
+ is(value, expected, `'${name}' is undefined${message}`);
+ } else if (
+ typeof expected == "string" ||
+ typeof expected == "number" ||
+ typeof expected == "boolean"
+ ) {
+ is(value, expected, "property '" + name + "'" + message);
+ } else if (expected instanceof RegExp) {
+ ok(
+ expected.test(value),
+ name + ": " + expected + " matched " + value + message
+ );
+ } else if (Array.isArray(expected)) {
+ info("checking array for property '" + name + "'" + message);
+ checkObject(value, expected, message);
+ } else if (typeof expected == "object") {
+ info("checking object for property '" + name + "'" + message);
+ checkObject(value, expected, message);
+ }
+}