diff options
Diffstat (limited to 'devtools/shared/commands/script')
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); + } +} |