Bug 1958081 - [devtools] Introduce a test framework to ease testing all possible JavaScript types against various codebases. r=frontend-codestyle-reviewers,devtools-reviewers,nchevobbe,Standard8

Differential Revision: https://phabricator.services.mozilla.com/D244246
This commit is contained in:
Alexandre Poirot
2025-05-07 21:51:47 +00:00
committed by apoirot@mozilla.com
parent fef3c421de
commit ddcbf73cd8
15 changed files with 2879 additions and 36 deletions

View File

@@ -102,6 +102,7 @@ module.exports = [
"devtools/client/preferences/", "devtools/client/preferences/",
// Ignore devtools generated code // Ignore devtools generated code
"devtools/**/*.snapshot.mjs",
"devtools/client/webconsole/test/node/fixtures/stubs/*.js", "devtools/client/webconsole/test/node/fixtures/stubs/*.js",
"!devtools/client/webconsole/test/node/fixtures/stubs/index.js", "!devtools/client/webconsole/test/node/fixtures/stubs/index.js",
"devtools/client/shared/source-map-loader/test/browser/fixtures/*.js", "devtools/client/shared/source-map-loader/test/browser/fixtures/*.js",

View File

@@ -1054,6 +1054,7 @@ devtools/client/debugger/webpack.config.js
devtools/client/preferences/ devtools/client/preferences/
# Ignore devtools generated code # Ignore devtools generated code
devtools/**/*.snapshot.mjs
devtools/client/webconsole/test/node/fixtures/stubs/*.js devtools/client/webconsole/test/node/fixtures/stubs/*.js
!devtools/client/webconsole/test/node/fixtures/stubs/index.js !devtools/client/webconsole/test/node/fixtures/stubs/index.js
devtools/client/shared/components/test/node/stubs/reps/*.js devtools/client/shared/components/test/node/stubs/reps/*.js

View File

@@ -586,6 +586,9 @@ fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and
skip-if = ["os == 'linux' && os_version == '18.04' && processor == 'x86_64' && debug && http3"] # Bug 1829298 skip-if = ["os == 'linux' && os_version == '18.04' && processor == 'x86_64' && debug && http3"] # Bug 1829298
["browser_webconsole_previewers.js"] ["browser_webconsole_previewers.js"]
support-files = ["browser_webconsole_previewers.snapshot.mjs"]
https_first_disabled = true # JS HttpServer doesn't support https
skip-if = ["http3"] # JS HttpServer doesn't support http3
["browser_webconsole_promise_rejected_object.js"] ["browser_webconsole_promise_rejected_object.js"]
fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled

View File

@@ -3,44 +3,27 @@
"use strict"; "use strict";
const TEST_URI = `data:text/html,<!DOCTYPE html>Test for object previews in console const { JSObjectsTestUtils, CONTEXTS } = ChromeUtils.importESModule(
<script> "resource://testing-common/JSObjectsTestUtils.sys.mjs"
globalThis.myPolicy = trustedTypes.createPolicy("myPolicy", { );
createHTML: s => "<my-policy>" + s + "</my-policy>", JSObjectsTestUtils.init(this);
createScript: s => "/* myPolicy */ " + s,
createScriptURL: s => s + "?myPolicy", const EXPECTED_VALUES_FILE = "browser_webconsole_previewers.snapshot.mjs";
});
</script>`;
add_task(async function () { add_task(async function () {
await pushPref("dom.security.trusted_types.enabled", true); // nsHttpServer does not support https
const hud = await openNewTabAndConsole(TEST_URI); // eslint-disable-next-line @microsoft/sdl/no-insecure-url
const hud = await openNewTabAndConsole("http://example.com");
const TESTS = [ await JSObjectsTestUtils.runTest(
{ EXPECTED_VALUES_FILE,
input: `myPolicy.createHTML("hello")`, async function ({ context, expression }) {
preview: `TrustedHTML "<my-policy>hello</my-policy>"`, if (context == CONTEXTS.CHROME) {
}, return undefined;
{
input: `myPolicy.createScript("const hello = 'world'")`,
preview: `TrustedScript "/* myPolicy */ const hello = 'world'"`,
},
{
input: `myPolicy.createScriptURL("https://example.com/trusted")`,
preview: `TrustedScriptURL https://example.com/trusted?myPolicy`,
},
{
input: `new BigInt64Array(Array.from({length: 20}, (_, i) => BigInt(i)))`,
preview: `BigInt64Array(20) [ 0n, 1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n, 9n, … ]`,
},
];
for (const { input, preview } of TESTS) {
const message = await executeAndWaitForResultMessage(hud, input, "");
is(
message.node.innerText.trim(),
preview,
`Got expected preview for \`${input}\``
);
} }
const message = await executeAndWaitForResultMessage(hud, expression, "");
return message.node.innerText.trim();
}
);
}); });

View File

@@ -0,0 +1,305 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/*
* THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND.
*
* More info in https://firefox-source-docs.mozilla.org/devtools/tests/js-object-tests.html
*/
export default [
// undefined
"undefined",
// null
"null",
// true
"true",
// false
"false",
// NaN
"NaN",
// "abc"
"\"abc\"",
// "鼬ú"
"\"鼬ú\"",
// 42
"42",
// -42
"-42",
// -0
"-0",
// Infinity
"Infinity",
// BigInt(1000000000000000000)
"1000000000000000000n",
// 1n
"1n",
// -2n
"-2n",
// 0n
"0n",
// ({})
"Object { }",
// ({ foo: "bar"})
"Object { foo: \"bar\" }",
// []
"Array []",
// [1]
"Array [ 1 ]",
// ["foo"]
"Array [ \"foo\" ]",
// new BigInt64Array()
"BigInt64Array []",
// const a = new BigInt64Array(1);
// a[0] = BigInt(42);
// a;
//
"BigInt64Array [ 42n ]",
// new Map(
// Array.from({ length: 2 }).map((el, i) => [
// { key: i },
// { object: 42 },
// ])
// )
"Map { {…} → {…}, {…} → {…} }",
// new Map(Array.from({ length: 20 }).map((el, i) => [Symbol(i), i]))
"Map(20) { Symbol(\"0\") → 0, Symbol(\"1\") → 1, Symbol(\"2\") → 2, Symbol(\"3\") → 3, Symbol(\"4\") → 4, Symbol(\"5\") → 5, Symbol(\"6\") → 6, Symbol(\"7\") → 7, Symbol(\"8\") → 8, Symbol(\"9\") → 9, … }",
// new Map(Array.from({ length: 331 }).map((el, i) => [Symbol(i), i]))
"Map(331) { Symbol(\"0\") → 0, Symbol(\"1\") → 1, Symbol(\"2\") → 2, Symbol(\"3\") → 3, Symbol(\"4\") → 4, Symbol(\"5\") → 5, Symbol(\"6\") → 6, Symbol(\"7\") → 7, Symbol(\"8\") → 8, Symbol(\"9\") → 9, … }",
// new Set(Array.from({ length: 2 }).map((el, i) => ({ value: i })))
"Set [ {…}, {…} ]",
// new Set(Array.from({ length: 20 }).map((el, i) => i))
"Set(20) [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, … ]",
// new Set(Array.from({ length: 222 }).map((el, i) => i))
"Set(222) [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, … ]",
// new Temporal.Instant(355924804000000000n)
"Temporal.Instant 1981-04-12T12:00:04Z",
// new Temporal.PlainDate(2021, 7, 1, "coptic")
"Temporal.PlainDate 2021-07-01[u-ca=coptic]",
// new Temporal.PlainDateTime(2021, 7, 1, 0, 0, 0, 0, 0, 0, "gregory")
"Temporal.PlainDateTime 2021-07-01T00:00:00[u-ca=gregory]",
// new Temporal.PlainMonthDay(7, 1, "chinese")
"Temporal.PlainMonthDay 1972-07-01[u-ca=chinese]",
// new Temporal.PlainTime(4, 20)
"Temporal.PlainTime 04:20:00",
// new Temporal.PlainYearMonth(2021, 7, "indian")
"Temporal.PlainYearMonth 2021-07-01[u-ca=indian]",
// new Temporal.ZonedDateTime(0n, "America/New_York")
"Temporal.ZonedDateTime 1969-12-31T19:00:00-05:00[America/New_York]",
// Temporal.Duration.from({ years: 1 })
"Temporal.Duration P1Y",
// myPolicy.createHTML("hello")
"TrustedHTML \"<my-policy>hello</my-policy>\"",
// myPolicy.createScript("const hello = 'world'")
"TrustedScript \"/* myPolicy */ const hello = 'world'\"",
// myPolicy.createScriptURL("https://example.com/trusted")
"TrustedScriptURL https://example.com/trusted?myPolicy",
// const formData = new FormData();
// formData.append("a", 1);
// formData.append("a", 2);
// formData.append("b", 3);
// formData;
//
"FormData(3) { a → \"1\", a → \"2\", b → \"3\" }",
// customElements.define("fx-test", class extends HTMLElement {});
// const { states } = document.createElement("fx-test").attachInternals();
// states.add("custom-state");
// states.add("another-custom-state");
// states;
//
"CustomStateSet [ \"custom-state\", \"another-custom-state\" ]",
// CSS.highlights.set("search", new Highlight());
// CSS.highlights.set("glow", new Highlight());
// CSS.highlights.set("anchor", new Highlight());
// CSS.highlights;
//
"HighlightRegistry(3) { search → Highlight, glow → Highlight, anchor → Highlight }",
// new URLSearchParams([
// ["a", 1],
// ["a", 2],
// ["b", 3],
// ["b", 3],
// ["b", 5],
// ["c", "this is 6"],
// ["d", 7],
// ["e", 8],
// ["f", 9],
// ["g", 10],
// ["h", 11],
// ])
"URLSearchParams(11) { a → \"1\", a → \"2\", b → \"3\", b → \"3\", b → \"5\", c → \"this is 6\", d → \"7\", e → \"8\", f → \"9\", g → \"10\", … }",
// new Error("foo")
"Error: foo",
// throw new Error("Long error ".repeat(10000));
"Uncaught Error: Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error Long error…\ndebugger eval code:1:7",
// throw `“https://evil.com/?aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa“ is evil and “https://not-so-evil.com/?bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb“ is not good either`;
//
"Uncaught “https://evil.com/?aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa“ is evil and “https://not-so-evil.com/?bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb“ is not good either\ndebugger eval code:2:7",
// Error("bar")
"Error: bar",
// function bar() {
// asdf();
// }
// function foo() {
// bar();
// }
//
// foo();
//
"Uncaught ReferenceError: asdf is not defined[Learn More]\ndebugger eval code:3:9",
// eval("let a, a")
"Uncaught SyntaxError: redeclaration of let a[Learn More]\ndebugger eval code:1:1\nnote: Previously declared at line 1, column 5\ndebugger eval code:1:5",
// throw "";
"Uncaught <empty string>\ndebugger eval code:1:1",
// throw false;
"Uncaught false\ndebugger eval code:1:1",
// throw undefined;
"Uncaught undefined\ndebugger eval code:1:1",
// throw 0;
"Uncaught 0\ndebugger eval code:1:1",
// throw { vegetable: "cucumber" };
"Uncaught \nObject { vegetable: \"cucumber\" }\ndebugger eval code:1:1",
// throw Symbol("potato");
"Uncaught Symbol(\"potato\")\ndebugger eval code:1:7",
// var err = new Error("pineapple");
// err.name = "JuicyError";
// err.flavor = "delicious";
// throw err;
//
"Uncaught JuicyError: pineapple\ndebugger eval code:5:7",
// var originalError = new SyntaxError("original error");
// var err = new Error("something went wrong", {
// cause: originalError,
// });
// throw err;
//
"Uncaught Error: something went wrong\nCaused by: SyntaxError: original error\ndebugger eval code:6:7",
// var a = new Error("err-a");
// var b = new Error("err-b", { cause: a });
// var c = new Error("err-c", { cause: b });
// var d = new Error("err-d", { cause: c });
// throw d;
//
"Uncaught SyntaxError: redeclaration of const a[Learn More]\ndebugger eval code:1:1",
// var a = new Error("err-a", { cause: b });
// var b = new Error("err-b", { cause: a });
// throw b;
//
"Uncaught SyntaxError: redeclaration of const a[Learn More]\ndebugger eval code:1:1",
// throw new Error("null cause", { cause: null });
"Uncaught Error: null cause\nCaused by: null\ndebugger eval code:1:7",
// throw new Error("number cause", { cause: 0 });
"Uncaught Error: number cause\nCaused by: 0\ndebugger eval code:1:7",
// throw new Error("string cause", { cause: "cause message" });
"Uncaught Error: string cause\nCaused by: \"cause message\"\ndebugger eval code:1:7",
// throw new Error("object cause", {
// cause: { code: 234, message: "ERR_234" },
// });
//
"Uncaught Error: object cause\nCaused by: Object { … }\ndebugger eval code:2:13",
// Promise.reject("")
"Promise { <state>: \"rejected\", <reason>: \"\" }",
// Promise.reject("tomato")
"Promise { <state>: \"rejected\", <reason>: \"tomato\" }",
// Promise.reject(false)
"Promise { <state>: \"rejected\", <reason>: false }",
// Promise.reject(0)
"Promise { <state>: \"rejected\", <reason>: 0 }",
// Promise.reject(null)
"Promise { <state>: \"rejected\", <reason>: null }",
// Promise.reject(undefined)
"Promise { <state>: \"rejected\", <reason>: undefined }",
// Promise.reject(Symbol("potato"))
"Promise { <state>: \"rejected\", <reason>: Symbol(\"potato\") }",
// Promise.reject({vegetable: "cucumber"})
"Promise { <state>: \"rejected\", <reason>: {…} }",
// Promise.reject(new Error("pumpkin"))
"Promise { <state>: \"rejected\", <reason>: Error }",
// var err = new Error("pineapple");
// err.name = "JuicyError";
// err.flavor = "delicious";
// Promise.reject(err);
//
"Promise { <state>: \"rejected\", <reason>: JuicyError }",
// Promise.resolve().then(() => {
// try {
// unknownFunc();
// } catch(e) {
// throw new Error("something went wrong", { cause: e })
// }
// })
"Promise { <state>: \"rejected\", <reason>: Error }",
];

View File

@@ -55,6 +55,7 @@ Automated tests
DevTools mochitests <tests/mochitest-devtools.md> DevTools mochitests <tests/mochitest-devtools.md>
Node tests <tests/node-tests.md> Node tests <tests/node-tests.md>
Memory Allocation tests </devtools/tests/memory/index.md> Memory Allocation tests </devtools/tests/memory/index.md>
JavaScript Objects tests<tests/js-object-tests.md>
Writing tests <tests/writing-tests.md> Writing tests <tests/writing-tests.md>
Debugging intermittent failures <tests/debugging-intermittents.md> Debugging intermittent failures <tests/debugging-intermittents.md>
Performance tests overview<tests/performance-tests-overview.md> Performance tests overview<tests/performance-tests-overview.md>

View File

@@ -0,0 +1,133 @@
# JavaScript Objects test framework
`JSObjectsTestUtils.sys.mjs` exposes xpcshell and mochitest test helpers to easily test all the
arbitrary JavaScript object types that Gecko can spawn in JavaScript.
This includes:
* any JavaScript type that Spidermonkey supports,
* any JavaScript type exposed to Web Page from Gecko (All the DOM APIs),
* any JavaScript type only exposed to Worker threads,
* any privileged JavaScript type used in parent or content processes,
* ...
This test framework consists in:
* a manifest file, [AllJavaScriptTypes.mjs](https://searchfox.org/mozilla-central/source/devtools/shared/tests/objects/AllJavaScriptTypes.mjs) which defines all the JS objects that gecko can spawn
* one xpcshell or one mochitest file, using [JSObjectTestUtils](https://searchfox.org/mozilla-central/source/devtools/shared/tests/objects/JSObjectTestUtils.sys.mjs) helper to evaluate all the JS Objects and generate a value for each of them.
* a snapshot file, read and written by JSObjectTestUtils, specific to each xpcshell/mochitest and storing all its the generated values.
You can run your test to execute the assertions:
```bash
$ ./mach test my/browser_test.js
```
And you can update the snapshot by running:
```bash
$ ./mach test my/browser_test.js --setenv UPDATE_SNAPSHOT=true
```
## JSObjectsTestUtils APIs
This test helper is available to all xpcshell and mochitest tests from `resource://testing-common/JSObjectsTestUtils.sys.mjs`.
It exposes only two methods:
* `JSOBjectsTestUtils.init(testScope)`
Which is meant to be called early in the test run, before the test page is loaded.
This will update all preferences which helps enable all the experimental types
or ease instantiating them.
The global scope for the current test should be passed as argument.
This will be used to retrieve the current test location in order to
load the expected values from an ES Module located in the same folder as the current test.
* `JSOBjectsTestUtils.runTest(expectedValuesFileName, testFunction)`
This is the main method, which will call the `testFunction` for each JavaScript object example.
This method will receive a single argument which is an object with two attributes:
* `context`
A string whose value can be one of [AllJavaScriptTypes.mjs](https://searchfox.org/mozilla-central/source/devtools/shared/tests/objects/AllJavaScriptTypes.mjs) `CONTEXTS` dictionary:
* "js": Basic JS value available from any possible JavaScript context (worker, page, chrome scopes)
* "page": Values only available from a Web page global
* "chrome": Privileged values, only available from a chrome, privileged scope
* `expression`
A string which should be evaled in order to instantiate the object example to cover.
For example, it may receive as argument:
`{ type: "js", expression: "42" }`
`{ type: "js", expression: "[42]" }`
`{ type: "js", expression: "let a = new BigInt64Array(1); a[0] = BigInt(42); a;" }`
`{ type: "page", expression: "document.body" }`
`{ type: "chrome", expression: "ChromeUtils.domProcessChild" }`
This method should return the value which represents the given object example.
### Mochitest Example
```js
const { JSObjectsTestUtils, CONTEXTS } = ChromeUtils.importESModule(
"resource://testing-common/JSObjectsTestUtils.sys.mjs"
);
// We have to manually initialize the test helper module from the parent process
JSObjectsTestUtils.init(this);
// Name of your snapshot file, next to this test, to be registered in the .toml file in a support-files rule
const EXPECTED_VALUES_FILE = "browser_mytest.snapshot.mjs";
add_task(async function () {
// Open the test page suitable to spawn all the expected JS Objects in a new tab
//
// nsHttpServer does not support https
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
const tab = await addTab("http://example.com");
await JSObjectsTestUtils.runTest(
EXPECTED_VALUES_FILE,
async function ({ context, expression }) {
// In this test, we ignore the privileged objects
if (context == CONTEXTS.CHROME) {
return;
}
// Then, execute the key runTest method from the tab's content process
await SpecialPowsers.spawn(tab.linkedBrowser, [expression], async function (expressionString) {
// This is the most important part, where we evaluate the `expression` provided by the test framework
// in the test page and return the value we would like to assert over time.
// We have to ensure handling exception, which we expect to interpret as a returned value.
let value;
try {
value = content.eval(expressionString);
} catch(e) {
value = e;
}
// Here we cover the native stringification of all the JS Objects.
return String(value);
}
);
});
```
## AllJavaScriptTypes manifest
All the JavaScript object examples are stored in a manifest file located in the current folder: AllJavaScriptTypes.mjs.
This module exports an array of objects descriptions, which are objects with the two following attributes:
* `context`:
A String to designate the context into which this expression could be evaluated.
See the first paragraph for the list of all contexts.
* `expression`:
The JavaScript expression to evaluate, which can either be:
* a string representing a piece of JavaScript value.
* a function, which would be stringified and evaluated in many scopes.
This is to be used when you need intermediate value before spawning another specific JS Value.
If the expression throws, the thrown exception will be considered as the value to assert.
The object descriptions can also have a couple of optional attributes:
* `prefs`: An array of arrays. The nested array are made of two elements: a string and a value.
The string represents a preference name and the value, the preference value.
This is used to set preference before starting the test.
This helps enable as well as ease instantiating the related JS value.
* `disabled`: A boolean to be set to true if the value can't be instantiated on the current runtime.

View File

@@ -0,0 +1,82 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
registerCleanupFunction(() => {
Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
});
const { JSObjectsTestUtils, CONTEXTS } = ChromeUtils.importESModule(
"resource://testing-common/JSObjectsTestUtils.sys.mjs"
);
JSObjectsTestUtils.init(this);
const EXPECTED_VALUES_FILE = "test_javascript_object_actors.snapshot.mjs";
/**
* This test will run `test` function twice.
* Once replicating a page debugging, and a second time replicating a worker debugging environment
*/
add_task(
threadFrontTest(
async function test({ threadFront, debuggee, _isWorkerServer }) {
await JSObjectsTestUtils.runTest(
EXPECTED_VALUES_FILE,
async function ({ context, expression }) {
// Only support basic JS Values
if (context != CONTEXTS.JS) {
return undefined;
}
// Create the function that the privileged code will call to pause
// from executeOnNextTickAndWaitForPause callback
debuggee.eval(`function stopMe(arg) { debugger; }`);
const packet = await executeOnNextTickAndWaitForPause(async () => {
let value;
try {
value = debuggee.eval(expression);
} catch (e) {
value = e;
}
// Catch all async rejection to avoid unecessary error reports
if (value instanceof debuggee.Promise) {
// eslint-disable-next-line max-nested-callbacks
value.catch(function () {});
}
debuggee.stopMe(value);
}, threadFront);
const firstArg = packet.frame.arguments[0];
await threadFront.resume();
// Avoid storing any actor ID as it may not be super stable
stripActorIDs(firstArg);
return firstArg;
}
); // End of runTest
},
// Use a content principal to better reflect evaluating into a web page,
// but also to ensure seeing the stack trace of exception only within the sandbox
// and especially not see the test harness ones, which are privileged.
{ principal: "https://example.org" }
) // End of threadFrontTest
);
function stripActorIDs(obj) {
for (const name in obj) {
if (name == "actor") {
obj[name] = "<actor-id>";
}
if (typeof obj[name] == "object") {
stripActorIDs(obj[name]);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -219,6 +219,9 @@ skip-if = [
["test_interrupt.js"] ["test_interrupt.js"]
["test_javascript_object_actors.js"]
support-files = ["test_javascript_object_actors.snapshot.mjs"]
["test_layout-reflows-observer.js"] ["test_layout-reflows-observer.js"]
["test_listsources-01.js"] ["test_listsources-01.js"]

View File

@@ -314,6 +314,10 @@ function WorkerDebuggerLoader(options) {
} }
var loader = { var loader = {
// There is only one loader in the worker thread.
// This will be used by DevToolsServer to build server prefix and actor IDs.
id: 0,
lazyGetter(object, name, lambda) { lazyGetter(object, name, lambda) {
Object.defineProperty(object, name, { Object.defineProperty(object, name, {
get() { get() {

View File

@@ -29,6 +29,7 @@ DIRS += [
"specs", "specs",
"storage", "storage",
"test-helpers", "test-helpers",
"tests/objects",
"transport", "transport",
"webconsole", "webconsole",
"worker", "worker",

View File

@@ -0,0 +1,521 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/* eslint-disable object-shorthand */
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
// Try replicating real world environment, by using
// * a true HTML document
// * served from http (https isn't yet supported by nsHttpServer)
// * with a regular domain name (example.com)
export const TEST_PAGE_HTML = String.raw`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<span id="span">Hello there.</span>
<script>
globalThis.myPolicy = trustedTypes.createPolicy("myPolicy", {
createHTML: s => "<my-policy>" + s + "</my-policy>",
createScript: s => "/* myPolicy */ " + s,
createScriptURL: s => s + "?myPolicy",
});
</script>
</body>
</html>`;
export const CONTEXTS = {
JS: "js",
PAGE: "page",
CHROME: "chrome"
};
/**
* Manifest covering all the possible JavaScript value types that gecko may create.
* This consist in JavaScript code instantiating one of more example values for all of these types.
*
* See README.md
*/
const BasicPrimitives = [
{
context: CONTEXTS.JS,
expression: "undefined",
},
{
context: CONTEXTS.JS,
expression: "null",
},
{
context: CONTEXTS.JS,
expression: "true",
},
{
context: CONTEXTS.JS,
expression: "false",
},
{
context: CONTEXTS.JS,
expression: "NaN",
},
];
const Strings = [
{
context: CONTEXTS.JS,
expression: `"abc"`,
},
{
context: CONTEXTS.JS,
expression: `"\u9f2c\xFA"`,
},
];
const Numbers = [
{
context: CONTEXTS.JS,
expression: "42",
},
{
context: CONTEXTS.JS,
expression: "-42",
},
{
context: CONTEXTS.JS,
expression: "-0",
},
{
context: CONTEXTS.JS,
expression: "Infinity",
},
{
context: CONTEXTS.JS,
expression: "BigInt(1000000000000000000)",
},
{
context: CONTEXTS.JS,
expression: "1n",
},
{
context: CONTEXTS.JS,
expression: "-2n",
},
{
context: CONTEXTS.JS,
expression: "0n",
},
];
const Primitives = [...BasicPrimitives, ...Strings, ...Numbers];
const PlainObjects = [
{
context: CONTEXTS.JS,
expression: "({})"
},
{
context: CONTEXTS.JS,
expression: `({ foo: "bar"})`
}
];
const Arrays = [
{
context: CONTEXTS.JS,
expression: "[]"
},
{
context: CONTEXTS.JS,
expression: "[1]"
},
{
context: CONTEXTS.JS,
expression: '["foo"]'
}
];
const TypedArrays = [
{
context: CONTEXTS.JS,
expression: "new BigInt64Array()"
},
{
context: CONTEXTS.JS,
expression: `
const a = new BigInt64Array(1);
a[0] = BigInt(42);
a;
`,
},
];
const Maps = [
{
context: CONTEXTS.JS,
expression: `new Map(
Array.from({ length: 2 }).map((el, i) => [
{ key: i },
{ object: 42 },
])
)`,
},
{
context: CONTEXTS.JS,
expression: `new Map(Array.from({ length: 20 }).map((el, i) => [Symbol(i), i]))`,
},
{
context: CONTEXTS.JS,
expression: `new Map(Array.from({ length: 331 }).map((el, i) => [Symbol(i), i]))`,
},
];
const Sets = [
{
context: CONTEXTS.JS,
expression: `new Set(Array.from({ length: 2 }).map((el, i) => ({ value: i })))`,
},
{
context: CONTEXTS.JS,
expression: `new Set(Array.from({ length: 20 }).map((el, i) => i))`
},
{
context: CONTEXTS.JS,
expression: `new Set(Array.from({ length: 222 }).map((el, i) => i))`
},
];
const Temporals = [
{
context: CONTEXTS.JS,
expression: `new Temporal.Instant(355924804000000000n)`
},
{
context: CONTEXTS.JS,
expression: `new Temporal.PlainDate(2021, 7, 1, "coptic")`
},
{
context: CONTEXTS.JS,
expression: `new Temporal.PlainDateTime(2021, 7, 1, 0, 0, 0, 0, 0, 0, "gregory")`,
},
{
context: CONTEXTS.JS,
expression: `new Temporal.PlainMonthDay(7, 1, "chinese")`
},
{
context: CONTEXTS.JS,
expression: `new Temporal.PlainTime(4, 20)`
},
{
context: CONTEXTS.JS,
expression: `new Temporal.PlainYearMonth(2021, 7, "indian")`
},
{
context: CONTEXTS.JS,
expression: `new Temporal.ZonedDateTime(0n, "America/New_York")`
},
{
context: CONTEXTS.JS,
expression: `Temporal.Duration.from({ years: 1 })`
},
];
const DOMAPIs = [
{
context: CONTEXTS.PAGE,
expression: `myPolicy.createHTML("hello")`,
prefs: [["dom.security.trusted_types.enabled", true]],
},
{
context: CONTEXTS.PAGE,
expression: `myPolicy.createScript("const hello = 'world'")`
},
{
context: CONTEXTS.PAGE,
expression: `myPolicy.createScriptURL("https://example.com/trusted")`
},
{
context: CONTEXTS.PAGE,
expression: `
const formData = new FormData();
formData.append("a", 1);
formData.append("a", 2);
formData.append("b", 3);
formData;
`,
},
/* midi API requires https
{
context: CONTEXTS.PAGE,
expression: `
const midiAccess = await navigator.requestMIDIAccess();
midiAccess.inputs;
`,
prefs: [
// This will make it so we'll have stable MIDI devices reported
["midi.testing", true],
["dom.webmidi.enabled", true],
["midi.prompt.testing", true],
["media.navigator.permission.disabled", true],
],
},
*/
{
context: CONTEXTS.PAGE,
expression: `
customElements.define("fx-test", class extends HTMLElement {});
const { states } = document.createElement("fx-test").attachInternals();
states.add("custom-state");
states.add("another-custom-state");
states;
`,
},
{
context: CONTEXTS.PAGE,
expression: `
CSS.highlights.set("search", new Highlight());
CSS.highlights.set("glow", new Highlight());
CSS.highlights.set("anchor", new Highlight());
CSS.highlights;
`,
prefs: [["dom.customHighlightAPI.enabled", true]],
},
{
context: CONTEXTS.PAGE,
expression: `new URLSearchParams([
["a", 1],
["a", 2],
["b", 3],
["b", 3],
["b", 5],
["c", "this is 6"],
["d", 7],
["e", 8],
["f", 9],
["g", 10],
["h", 11],
])`,
},
];
const Errors = [
{
context: CONTEXTS.JS,
expression: `new Error("foo")`
},
{
context: CONTEXTS.JS,
expression: `throw new Error("Long error ".repeat(10000));`,
},
{
context: CONTEXTS.JS,
expression: `
throw \`“https://evil.com/?${"a".repeat(
200
)}“ is evil and “https://not-so-evil.com/?${"b".repeat(
200
)}“ is not good either\`;
`,
},
{
context: CONTEXTS.JS,
expression: `Error("bar")`
},
{
context: CONTEXTS.JS,
expression: `
function bar() {
asdf();
}
function foo() {
bar();
}
foo();
`,
},
{
context: CONTEXTS.JS,
// Use nested `eval()` as syntax error would make the test framework throw on its own eval call
expression: `eval("let a, a")`,
},
{
context: CONTEXTS.JS,
expression: `throw "";`
},
{
context: CONTEXTS.JS,
expression: `throw false;`
},
{
context: CONTEXTS.JS,
expression: `throw undefined;`
},
{
context: CONTEXTS.JS,
expression: `throw 0;`
},
{
context: CONTEXTS.JS,
expression: `throw { vegetable: "cucumber" };`
},
{
context: CONTEXTS.JS,
expression: `throw Symbol("potato");`
},
{
context: CONTEXTS.JS,
expression: `
var err = new Error("pineapple");
err.name = "JuicyError";
err.flavor = "delicious";
throw err;
`,
},
{
context: CONTEXTS.JS,
expression: `
var originalError = new SyntaxError("original error");
var err = new Error("something went wrong", {
cause: originalError,
});
throw err;
`,
},
{
context: CONTEXTS.JS,
expression: `
var a = new Error("err-a");
var b = new Error("err-b", { cause: a });
var c = new Error("err-c", { cause: b });
var d = new Error("err-d", { cause: c });
throw d;
`,
},
{
context: CONTEXTS.JS,
expression: `
var a = new Error("err-a", { cause: b });
var b = new Error("err-b", { cause: a });
throw b;
`,
},
{
context: CONTEXTS.JS,
expression: `throw new Error("null cause", { cause: null });`
},
{
context: CONTEXTS.JS,
expression: `throw new Error("number cause", { cause: 0 });`
},
{
context: CONTEXTS.JS,
expression: `throw new Error("string cause", { cause: "cause message" });`
},
{
context: CONTEXTS.JS,
expression: `
throw new Error("object cause", {
cause: { code: 234, message: "ERR_234" },
});
`,
},
{
context: CONTEXTS.JS,
expression: `Promise.reject("")`
},
{
context: CONTEXTS.JS,
expression: `Promise.reject("tomato")`
},
{
context: CONTEXTS.JS,
expression: `Promise.reject(false)`
},
{
context: CONTEXTS.JS,
expression: `Promise.reject(0)`
},
{
context: CONTEXTS.JS,
expression: `Promise.reject(null)`
},
{
context: CONTEXTS.JS,
expression: `Promise.reject(undefined)`
},
{
context: CONTEXTS.JS,
expression: `Promise.reject(Symbol("potato"))`
},
{
context: CONTEXTS.JS,
expression: `Promise.reject({vegetable: "cucumber"})`
},
{
context: CONTEXTS.JS,
expression: `Promise.reject(new Error("pumpkin"))`
},
{
context: CONTEXTS.JS,
expression: `
var err = new Error("pineapple");
err.name = "JuicyError";
err.flavor = "delicious";
Promise.reject(err);
`,
},
{
context: CONTEXTS.JS,
expression: `Promise.resolve().then(() => {
try {
unknownFunc();
} catch(e) {
throw new Error("something went wrong", { cause: e })
}
})`,
},
{
context: CONTEXTS.JS,
expression: `
throw new SuppressedError(
new Error("foo"),
new Error("bar"),
"the suppressed error message"
);
`,
prefs: [
["javascript.options.experimental.explicit_resource_management", true],
],
disabled: true || !AppConstants.ENABLE_EXPLICIT_RESOURCE_MANAGEMENT,
},
];
const Privileged = [
{
context: CONTEXTS.CHROME,
expression: `Components.Exception("foo")`
}
];
export const AllObjects = [
...Primitives,
...PlainObjects,
...Arrays,
...TypedArrays,
...Maps,
...Sets,
...Temporals,
...DOMAPIs,
...Errors,
...Privileged,
];

View File

@@ -0,0 +1,269 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*/
import { TEST_PAGE_HTML, CONTEXTS, AllObjects } from "resource://testing-common/AllJavascriptTypes.mjs";
export { CONTEXTS } from "resource://testing-common/AllJavascriptTypes.mjs";
import { ObjectUtils } from "resource://gre/modules/ObjectUtils.sys.mjs";
// Name of the environment variable to set while running the test to update the expected values
const UPDATE_SNAPSHOT_ENV = "UPDATE_SNAPSHOT";
const { AddonTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/AddonTestUtils.sys.mjs"
);
// To avoid totally unrelated exceptions about missing appinfo when running from xpcshell tests
AddonTestUtils.createAppInfo(
"xpcshell@tests.mozilla.org",
"XPCShell",
"42",
"42"
);
let gTestScope;
/**
* Initialize the test helper.
*
* @param {Object} testScope
* XPCShell or mochitest test scope (i.e. the global object of the test currently executed)
*/
function init(testScope) {
if (!testScope?.gTestPath && !testScope?.Assert) {
throw new Error("`JSObjectsTestUtils.init()` should be called with the (xpcshell or mochitest) test global object");
}
gTestScope = testScope;
if ("gTestPath" in testScope) {
AddonTestUtils.initMochitest(testScope);
} else {
AddonTestUtils.init(testScope);
}
const server = AddonTestUtils.createHttpServer({
hosts: ["example.com"],
});
server.registerPathHandler("/", (request, response) => {
response.setHeader("Content-Type", "text/html");
response.write(TEST_PAGE_HTML);
});
// Lookup for all preferences to toggle in order to have all the expected objects type functional
let prefValues = new Map();
for (const { prefs } of AllObjects) {
if (!prefs) {
continue;
}
for (const elt of prefs) {
if (elt.length != 2) {
throw new Error("Each pref should be an array of two element [prefName, prefValue]. Got: "+elt);
}
const [ name, value ] = elt;
const otherValue = prefValues.get(name);
if (otherValue && otherValue != value) {
throw new Error(`Two javascript values in AllJavascriptTypes.mjs are expecting different values for '${name}' preference. (${otherValue} vs ${value})`);
}
prefValues.set(name, value);
if (typeof(value) == "boolean") {
Services.prefs.setBoolPref(name, value);
gTestScope.registerCleanupFunction(() => {
Services.prefs.clearUserPref(name);
});
} else {
throw new Error("Unsupported pref type: "+name+" = "+value);
}
}
}
}
let gExpectedValuesFilePath;
let gCurrentTestFolderUrl;
const chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
Ci.nsIChromeRegistry
);
function loadExpectedValues(expectedValuesFileName) {
const isUpdate = Services.env.get(UPDATE_SNAPSHOT_ENV) == "true";
dump(`JS Objects test: ${isUpdate ? "Update" : "Check"} ${expectedValuesFileName}\n`);
// Depending on the test suite, mochitest will expose `gTextPath` which is a chrome://
// for the current test file.
// Otherwise xpcshell will expose `resource://test/` for the current test folder.
gCurrentTestFolderUrl = "gTestPath" in gTestScope
? gTestScope.gTestPath.substr(0, gTestScope.gTestPath.lastIndexOf("/")) + "/"
: "resource://test/";
// Build the URL for the test data file
const url = gCurrentTestFolderUrl + expectedValuesFileName;
// Resolve the test data file URL into a file absolute path
if (url.startsWith("chrome")) {
const chromeURL = Services.io.newURI(url);
gExpectedValuesFilePath = chromeRegistry
.convertChromeURL(chromeURL)
.QueryInterface(Ci.nsIFileURL).file.path;
} else if (url.startsWith("resource")) {
const resURL = Services.io.newURI(url);
const resHandler = Services.io.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler);
gExpectedValuesFilePath = Services.io.newURI(resHandler.resolveURI(resURL)).QueryInterface(Ci.nsIFileURL).file.path;
}
let expectedValues;
if (!isUpdate) {
dump(`Loading test data file: ${url}\n`);
return ChromeUtils.importESModule(url).default;
}
return null;
}
async function mayBeSaveExpectedValues(evaledStrings, newExpectedValues) {
if (!newExpectedValues?.length) {
return;
}
if (evaledStrings.length != newExpectedValues.length) {
throw new Error("Unexpected discrepencies between the reported evaled strings and expected values");
}
const filePath = gExpectedValuesFilePath;
const assertionValues = [];
let i = 0;
for (const value of newExpectedValues) {
let evaled = evaledStrings[i];
// Remove any first empty line
evaled = evaled.replace(/^\s*\n/, "");
// remove the unnecessary indentation
const m = evaled.match(/^( +)/);
if (m && m[1]) {
const regexp = new RegExp("^"+m[1], "gm");
evaled = evaled.replace(regexp, "");
}
// Ensure prefixing all new lines in the evaled string with " //"
// to keep it being in a code comment.
evaled = evaled.replace(/\r?\n/g, "\n // ");
assertionValues.push(
" // " + evaled +
"\n" +
" " +
JSON.stringify(value, null, 2) +
","
);
i++;
}
const fileContent = `/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/*
* THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND.
*
* More info in https://firefox-source-docs.mozilla.org/devtools/tests/js-object-tests.html
*/
export default [
${assertionValues.join("\n\n")}
];`;
dump("Writing: " + fileContent + " in " + filePath + "\n");
await IOUtils.write(filePath, new TextEncoder().encode(fileContent));
}
async function runTest(expectedValuesFileName, testFunction) {
if (!gTestScope) {
throw new Error("`JSObjectsTestUtils.init()` should be called before `runTest()`");
}
if (typeof (expectedValuesFileName) != "string") {
throw new Error("`JSObjectsTestUtils.runTest()` first argument should be a data file name");
}
if (typeof (testFunction) != "function") {
throw new Error("`JSObjectsTestUtils.runTest()` second argument should be a test function");
}
let expectedValues = loadExpectedValues(expectedValuesFileName);
if (expectedValues) {
// Clone the Array as we are going to mutate it via Array.shift().
expectedValues = [...expectedValues];
}
const evaledStrings = [];
const newExpectedValues = [];
let failed = false;
for (const objectDescription of AllObjects) {
if (objectDescription.disabled) {
continue;
}
const { context, expression } = objectDescription;
if (!Object.values(CONTEXTS).includes(context)) {
throw new Error("Missing, or invalid context in: " + JSON.stringify(objectDescription));
}
if (!expression) {
throw new Error("Missing a value in: " + JSON.stringify(objectDescription));
}
const actual = await testFunction({ context, expression });
// Ignore this JS object as the test function did not return any actual value.
// We assume none of the tests would store "undefined" as a target value.
if (actual == undefined) {
continue;
}
const testPath = "gtestPath" in gTestScope ? gTestScope.gTestPath.replace("chrome://mochitest/content/browser/", "") : "path/to/your/xpcshell/test";
const failureMessage = `This is a JavaScript value processing test, which includes an automatically generated snapshot file (${expectedValuesFileName}).\n` +
"You may update this file by running:`\n" +
` $ mach test ${testPath} --headless --setenv ${UPDATE_SNAPSHOT_ENV}=true\n` +
"And then carefuly review if the result is valid regarding your ongoing changes.\n" +
"`More info in https://firefox-source-docs.mozilla.org/devtools/tests/js-object-tests.html\n";
const isMochitest = "gTestPath" in gTestScope;
const isXpcshell = !isMochitest;
// If we aren't in "update" mode, we are reading assertion values from $EXPECTED_VALUES_FILE
// and will assert the current returned values against these values
if (expectedValues) {
const expected = expectedValues.shift();
try {
gTestScope.Assert.deepEqual(actual, expected, `Got expected output for "${expression}"`);
} catch(e) {
// deepEqual only throws in case of differences when running in XPCShell tests. Mochitest won't throw and keep running.
// XPCShell will stop at the first failing assertion, so ensure showing our failure message and ok() will throw and stop the test.
if (isXpcshell) {
gTestScope.Assert.ok(false, failureMessage);
}
throw e;
}
// As mochitest won't throw when calling deepEqual with differences in the objects,
// we have to recompute the difference in order to know if any of the tests failed.
if (isMochitest && !failed && !ObjectUtils.deepEqual(actual, expected)) {
failed = true;
}
} else {
// Otherwise, if we are in update mode, we will collected all current values
// in order to store them in $EXPECTED_VALUES_FILE
//
// Force casting to string, in case this is a function.
evaledStrings.push(String(expression));
newExpectedValues.push(actual);
}
}
if (failed) {
const failureMessage = "This is a JavaScript value processing test, which includes an automatically generated snapshot file.\n" +
"If the change made to that snapshot file makes sense, you may simply update them by running:`\n" +
" $ mach test ${testPath} --headless --setenv UPDATE_EXPECTED_VALUES=true\n" +
"`More info in devtools/shared/tests/objects/README.md\n";
gTestScope.Assert.ok(false, failureMessage);
}
mayBeSaveExpectedValues(evaledStrings, newExpectedValues);
}
export const JSObjectsTestUtils = { init, runTest };

View File

@@ -0,0 +1,9 @@
# vim: set filetype=python:
# 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/.
TESTING_JS_MODULES += [
"AllJavascriptTypes.mjs",
"JSObjectsTestUtils.sys.mjs",
]