Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

"use strict";
const { createAppInfo } = AddonTestUtils;
AddonTestUtils.init(this);
createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
const server = createHttpServer();
server.registerDirectory("/data/", do_get_file("data"));
const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
// A small utility function used to test the expected behaviors of the userScripts API method
// wrapper.
async function test_userScript_APIMethod({
apiScript,
userScript,
userScriptMetadata,
testFn,
runtimeMessageListener,
}) {
async function backgroundScript(
userScriptFn,
scriptMetadata,
messageListener
) {
await browser.userScripts.register({
js: [
{
code: `(${userScriptFn})();`,
},
],
runAt: "document_end",
scriptMetadata,
});
if (messageListener) {
browser.runtime.onMessage.addListener(messageListener);
}
browser.test.sendMessage("background-ready");
}
function notifyFinish(failureReason) {
browser.test.assertEq(
undefined,
failureReason,
"should be completed without errors"
);
browser.test.sendMessage("test_userScript_APIMethod:done");
}
function assertTrue(val, message) {
browser.test.assertTrue(val, message);
if (!val) {
browser.test.sendMessage("test_userScript_APIMethod:done");
throw message;
}
}
let extension = ExtensionTestUtils.loadExtension({
manifest: {
user_scripts: {
api_script: "api-script.js",
},
},
// Defines a background script that receives all the needed test parameters.
background: `
const metadata = ${JSON.stringify(userScriptMetadata)};
(${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener})
`,
files: {
"api-script.js": `(${apiScript})({
assertTrue: ${assertTrue},
notifyFinish: ${notifyFinish}
})`,
},
});
// Load a page in a content process, register the user script and then load a
// new page in the existing content process.
let url = `${BASE_URL}/file_sample.html`;
let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
await extension.startup();
await extension.awaitMessage("background-ready");
await contentPage.loadURL(url);
// Run any additional test-specific assertions.
if (testFn) {
await testFn({ extension, contentPage, url });
}
await extension.awaitMessage("test_userScript_APIMethod:done");
await extension.unload();
await contentPage.close();
}
add_task(async function test_apiScript_exports_simple_sync_method() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
const scriptMetadata = script.metadata;
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethod(
stringParam,
numberParam,
boolParam,
nullParam,
undefinedParam,
arrayParam
) {
browser.test.assertEq(
"test-user-script-exported-apis",
scriptMetadata.name,
"Got the expected value for a string scriptMetadata property"
);
browser.test.assertEq(
null,
scriptMetadata.nullProperty,
"Got the expected value for a null scriptMetadata property"
);
browser.test.assertTrue(
scriptMetadata.arrayProperty &&
scriptMetadata.arrayProperty.length === 1 &&
scriptMetadata.arrayProperty[0] === "el1",
"Got the expected value for an array scriptMetadata property"
);
browser.test.assertTrue(
scriptMetadata.objectProperty &&
scriptMetadata.objectProperty.nestedProp === "nestedValue",
"Got the expected value for an object scriptMetadata property"
);
browser.test.assertEq(
"param1",
stringParam,
"Got the expected string parameter value"
);
browser.test.assertEq(
123,
numberParam,
"Got the expected number parameter value"
);
browser.test.assertEq(
true,
boolParam,
"Got the expected boolean parameter value"
);
browser.test.assertEq(
null,
nullParam,
"Got the expected null parameter value"
);
browser.test.assertEq(
undefined,
undefinedParam,
"Got the expected undefined parameter value"
);
browser.test.assertEq(
3,
arrayParam.length,
"Got the expected length on the array param"
);
browser.test.assertTrue(
arrayParam.includes(1),
"Got the expected result when calling arrayParam.includes"
);
return "returned_value";
},
});
});
}
function userScript() {
const { assertTrue, notifyFinish, testAPIMethod } = this;
// Redefine the includes method on the Array prototype, to explicitly verify that the method
// redefined in the userScript is not used when accessing arrayParam.includes from the API script.
// eslint-disable-next-line no-extend-native
Array.prototype.includes = () => {
throw new Error("Unexpected prototype leakage");
};
const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor
const result = testAPIMethod(
"param1",
123,
true,
null,
undefined,
arrayParam
);
assertTrue(
result === "returned_value",
`userScript got an unexpected result value: ${result}`
);
notifyFinish();
}
const userScriptMetadata = {
name: "test-user-script-exported-apis",
arrayProperty: ["el1"],
objectProperty: { nestedProp: "nestedValue" },
nullProperty: null,
};
await test_userScript_APIMethod({
userScript,
apiScript,
userScriptMetadata,
});
});
add_task(async function test_apiScript_async_method() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethod(param, cb, cb2) {
browser.test.assertEq(
"function",
typeof cb,
"Got a callback function parameter"
);
browser.test.assertTrue(
cb === cb2,
"Got the same cloned function for the same function parameter"
);
browser.runtime.sendMessage(param).then(bgPageRes => {
const cbResult = cb(script.export(bgPageRes));
browser.test.sendMessage("user-script-callback-return", cbResult);
});
return "resolved_value";
},
});
});
}
async function userScript() {
// Redefine Promise to verify that it doesn't break the WebExtensions internals
// that are going to use them.
const { Promise } = this;
Promise.resolve = function () {
throw new Error("Promise.resolve poisoning");
};
this.Promise = function () {
throw new Error("Promise constructor poisoning");
};
const { assertTrue, notifyFinish, testAPIMethod } = this;
const cb = cbParam => {
return `callback param: ${JSON.stringify(cbParam)}`;
};
const cb2 = cb;
const asyncAPIResult = await testAPIMethod("param3", cb, cb2);
assertTrue(
asyncAPIResult === "resolved_value",
`userScript got an unexpected resolved value: ${asyncAPIResult}`
);
notifyFinish();
}
async function runtimeMessageListener(param) {
if (param !== "param3") {
browser.test.fail(`Got an unexpected message: ${param}`);
}
return { bgPageReply: true };
}
await test_userScript_APIMethod({
userScript,
apiScript,
runtimeMessageListener,
async testFn({ extension }) {
const res = await extension.awaitMessage("user-script-callback-return");
equal(
res,
`callback param: ${JSON.stringify({ bgPageReply: true })}`,
"Got the expected userScript callback return value"
);
},
});
});
add_task(async function test_apiScript_method_with_webpage_objects_params() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethod(windowParam, documentParam) {
browser.test.assertEq(
window,
windowParam,
"Got a reference to the native window as first param"
);
browser.test.assertEq(
window.document,
documentParam,
"Got a reference to the native document as second param"
);
// Return an uncloneable webpage object, which checks that if the returned object is from a principal
// that is subsumed by the userScript sandbox principal, it is returned without being cloned.
return windowParam;
},
});
});
}
async function userScript() {
const { assertTrue, notifyFinish, testAPIMethod } = this;
const result = testAPIMethod(window, document);
// We expect the returned value to be the uncloneable window object.
assertTrue(
result === window,
`userScript got an unexpected returned value: ${result}`
);
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
});
add_task(async function test_apiScript_method_got_param_with_methods() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
const scriptGlobal = script.global;
const ScriptFunction = scriptGlobal.Function;
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethod(objWithMethods) {
browser.test.assertEq(
"objPropertyValue",
objWithMethods && objWithMethods.objProperty,
"Got the expected property on the object passed as a parameter"
);
browser.test.assertEq(
undefined,
objWithMethods?.objMethod,
"XrayWrapper should deny access to a callable property"
);
browser.test.assertTrue(
objWithMethods &&
objWithMethods.wrappedJSObject &&
objWithMethods.wrappedJSObject.objMethod instanceof
ScriptFunction.wrappedJSObject,
"The callable property is accessible on the wrappedJSObject"
);
browser.test.assertEq(
"objMethodResult: p1",
objWithMethods &&
objWithMethods.wrappedJSObject &&
objWithMethods.wrappedJSObject.objMethod("p1"),
"Got the expected result when calling the method on the wrappedJSObject"
);
return true;
},
});
});
}
async function userScript() {
const { assertTrue, notifyFinish, testAPIMethod } = this;
let result = testAPIMethod({
objProperty: "objPropertyValue",
objMethod(param) {
return `objMethodResult: ${param}`;
},
});
assertTrue(
result === true,
`userScript got an unexpected returned value: ${result}`
);
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
});
add_task(async function test_apiScript_method_throws_errors() {
function apiScript({ notifyFinish }) {
let proxyTrapsCount = 0;
browser.userScripts.onBeforeScript.addListener(script => {
const scriptGlobals = {
Error: script.global.Error,
TypeError: script.global.TypeError,
Proxy: script.global.Proxy,
};
script.defineGlobals({
notifyFinish,
testAPIMethod(errorTestName, returnRejectedPromise) {
let err;
switch (errorTestName) {
case "apiScriptError":
err = new Error(`${errorTestName} message`);
break;
case "apiScriptThrowsPlainString":
err = `${errorTestName} message`;
break;
case "apiScriptThrowsNull":
err = null;
break;
case "userScriptError":
err = new scriptGlobals.Error(`${errorTestName} message`);
break;
case "userScriptTypeError":
err = new scriptGlobals.TypeError(`${errorTestName} message`);
break;
case "userScriptProxyObject":
let proxyTarget = script.export({
name: "ProxyObject",
message: "ProxyObject message",
});
let proxyHandlers = script.export({
get(target, prop) {
proxyTrapsCount++;
switch (prop) {
case "name":
return "ProxyObjectGetName";
case "message":
return "ProxyObjectGetMessage";
}
return undefined;
},
getPrototypeOf() {
proxyTrapsCount++;
return scriptGlobals.TypeError;
},
});
err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers);
break;
default:
browser.test.fail(`Unknown ${errorTestName} error testname`);
return undefined;
}
if (returnRejectedPromise) {
return Promise.reject(err);
}
throw err;
},
assertNoProxyTrapTriggered() {
browser.test.assertEq(
0,
proxyTrapsCount,
"Proxy traps should not be triggered"
);
},
resetProxyTrapCounter() {
proxyTrapsCount = 0;
},
sendResults(results) {
browser.test.sendMessage("test-results", results);
},
});
});
}
async function userScript() {
const {
assertNoProxyTrapTriggered,
notifyFinish,
resetProxyTrapCounter,
sendResults,
testAPIMethod,
} = this;
let apiThrowResults = {};
let apiThrowTestCases = [
"apiScriptError",
"apiScriptThrowsPlainString",
"apiScriptThrowsNull",
"userScriptError",
"userScriptTypeError",
"userScriptProxyObject",
];
for (let errorTestName of apiThrowTestCases) {
try {
testAPIMethod(errorTestName);
} catch (err) {
// We expect that no proxy traps have been triggered by the WebExtensions internals.
if (errorTestName === "userScriptProxyObject") {
assertNoProxyTrapTriggered();
}
if (err instanceof Error) {
apiThrowResults[errorTestName] = {
name: err.name,
message: err.message,
};
} else {
apiThrowResults[errorTestName] = {
name: err && err.name,
message: err && err.message,
typeOf: typeof err,
value: err,
};
}
}
}
sendResults(apiThrowResults);
resetProxyTrapCounter();
let apiRejectsResults = {};
for (let errorTestName of apiThrowTestCases) {
try {
await testAPIMethod(errorTestName, true);
} catch (err) {
// We expect that no proxy traps have been triggered by the WebExtensions internals.
if (errorTestName === "userScriptProxyObject") {
assertNoProxyTrapTriggered();
}
if (err instanceof Error) {
apiRejectsResults[errorTestName] = {
name: err.name,
message: err.message,
};
} else {
apiRejectsResults[errorTestName] = {
name: err && err.name,
message: err && err.message,
typeOf: typeof err,
value: err,
};
}
}
}
sendResults(apiRejectsResults);
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
async testFn({ extension }) {
const expectedResults = {
// Any error not explicitly raised as a userScript objects or error instance is
// expected to be turned into a generic error message.
apiScriptError: {
name: "Error",
message: "An unexpected apiScript error occurred",
},
// When the api script throws a primitive value, we expect to receive it unmodified on
// the userScript side.
apiScriptThrowsPlainString: {
typeOf: "string",
value: "apiScriptThrowsPlainString message",
name: undefined,
message: undefined,
},
apiScriptThrowsNull: {
typeOf: "object",
value: null,
name: undefined,
message: undefined,
},
// Error messages that the apiScript has explicitly created as userScript's Error
// global instances are expected to be passing through unmodified.
userScriptError: { name: "Error", message: "userScriptError message" },
userScriptTypeError: {
name: "TypeError",
message: "userScriptTypeError message",
},
// Error raised from the apiScript as userScript proxy objects are expected to
// be passing through unmodified.
userScriptProxyObject: {
typeOf: "object",
name: "ProxyObjectGetName",
message: "ProxyObjectGetMessage",
},
};
info(
"Checking results from errors raised from an apiScript exported function"
);
const apiThrowResults = await extension.awaitMessage("test-results");
for (let [key, expected] of Object.entries(expectedResults)) {
Assert.deepEqual(
apiThrowResults[key],
expected,
`Got the expected error object for test case "${key}"`
);
}
Assert.deepEqual(
Object.keys(expectedResults).sort(),
Object.keys(apiThrowResults).sort(),
"the expected and actual test case names matches"
);
info(
"Checking expected results from errors raised from an apiScript exported function"
);
// Verify expected results from rejected promises returned from an apiScript exported function.
const apiThrowRejections = await extension.awaitMessage("test-results");
for (let [key, expected] of Object.entries(expectedResults)) {
Assert.deepEqual(
apiThrowRejections[key],
expected,
`Got the expected rejected object for test case "${key}"`
);
}
Assert.deepEqual(
Object.keys(expectedResults).sort(),
Object.keys(apiThrowRejections).sort(),
"the expected and actual test case names matches"
);
},
});
});
add_task(
async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethod(...args) {
// Proxies are opaque when wrapped in Xrays, and the proto of an opaque object
// is supposed to be Object.prototype.
browser.test.assertEq(
script.global.Object.prototype,
Object.getPrototypeOf(args[0]),
"Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap"
);
browser.test.assertTrue(
Array.isArray(args[0]),
"Got an array object for the XrayWrapped proxy object param"
);
browser.test.assertEq(
undefined,
args[0].length,
"XrayWrappers deny access to the length property"
);
browser.test.assertEq(
undefined,
args[0][0],
"Got the expected item in the array object"
);
return true;
},
});
});
}
async function userScript() {
const { assertTrue, notifyFinish, testAPIMethod } = this;
let proxy = new Proxy(["expectedArrayValue"], {
getPrototypeOf() {
throw new Error("Proxy's getPrototypeOf trap");
},
get() {
throw new Error("Proxy's get trap");
},
});
let result = testAPIMethod(proxy);
assertTrue(
result,
`userScript got an unexpected returned value: ${result}`
);
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
}
);
add_task(async function test_apiScript_method_return_proxy_object() {
function apiScript(sharedTestAPIMethods) {
let proxyTrapsCount = 0;
let scriptTrapsCount = 0;
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethodError() {
return new Proxy(["expectedArrayValue"], {
getPrototypeOf(target) {
proxyTrapsCount++;
return Object.getPrototypeOf(target);
},
});
},
testAPIMethodOk() {
return new script.global.Proxy(
script.export(["expectedArrayValue"]),
script.export({
getPrototypeOf(target) {
scriptTrapsCount++;
return script.global.Object.getPrototypeOf(target);
},
})
);
},
assertNoProxyTrapTriggered() {
browser.test.assertEq(
0,
proxyTrapsCount,
"Proxy traps should not be triggered"
);
},
assertScriptProxyTrapsCount(expected) {
browser.test.assertEq(
expected,
scriptTrapsCount,
"Script Proxy traps should have been triggered"
);
},
});
});
}
async function userScript() {
const {
assertTrue,
assertNoProxyTrapTriggered,
assertScriptProxyTrapsCount,
notifyFinish,
testAPIMethodError,
testAPIMethodOk,
} = this;
let error;
try {
let result = testAPIMethodError();
notifyFinish(
`Unexpected returned value while expecting error: ${result}`
);
return;
} catch (err) {
error = err;
}
assertTrue(
error &&
error.message.includes("Return value not accessible to the userScript"),
`Got an unexpected error message: ${error}`
);
error = undefined;
try {
let result = testAPIMethodOk();
assertScriptProxyTrapsCount(0);
if (!(result instanceof Array)) {
notifyFinish(`Got an unexpected result: ${result}`);
return;
}
assertScriptProxyTrapsCount(1);
} catch (err) {
error = err;
}
assertTrue(!error, `Got an unexpected error: ${error}`);
assertNoProxyTrapTriggered();
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
});
add_task(async function test_apiScript_returns_functions() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIReturnsFunction() {
// Return a function with provides the same kind of behavior
// of the API methods exported as globals.
return script.export(() => window);
},
testAPIReturnsObjWithMethod() {
return script.export({
getWindow() {
return window;
},
});
},
});
});
}
async function userScript() {
const {
assertTrue,
notifyFinish,
testAPIReturnsFunction,
testAPIReturnsObjWithMethod,
} = this;
let resultFn = testAPIReturnsFunction();
assertTrue(
typeof resultFn === "function",
`userScript got an unexpected returned value: ${typeof resultFn}`
);
let fnRes = resultFn();
assertTrue(
fnRes === window,
`Got an unexpected value from the returned function: ${fnRes}`
);
let resultObj = testAPIReturnsObjWithMethod();
let actualTypeof = resultObj && typeof resultObj.getWindow;
assertTrue(
actualTypeof === "function",
`Returned object does not have the expected getWindow method: ${actualTypeof}`
);
let methodRes = resultObj.getWindow();
assertTrue(
methodRes === window,
`Got an unexpected value from the returned method: ${methodRes}`
);
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
});
add_task(
async function test_apiScript_method_clone_non_subsumed_returned_values() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethodReturnOk() {
return script.export({
objKey1: {
nestedProp: "nestedvalue",
},
window,
});
},
testAPIMethodExplicitlyClonedError() {
let result = script.export({ apiScopeObject: undefined });
browser.test.assertThrows(
() => {
result.apiScopeObject = { disallowedProp: "disallowedValue" };
},
/Not allowed to define cross-origin object as property on .* XrayWrapper/,
"Assigning a property to a xRayWrapper is expected to throw"
);
// Let the exception to be raised, so that we check that the actual underlying
// error message is not leaking in the userScript (replaced by the generic
// "An unexpected apiScript error occurred" error message).
result.apiScopeObject = { disallowedProp: "disallowedValue" };
},
});
});
}
async function userScript() {
const {
assertTrue,
notifyFinish,
testAPIMethodReturnOk,
testAPIMethodExplicitlyClonedError,
} = this;
let result = testAPIMethodReturnOk();
assertTrue(
result &&
"objKey1" in result &&
result.objKey1.nestedProp === "nestedvalue",
`userScript got an unexpected returned value: ${result}`
);
assertTrue(
result.window === window,
`userScript should have access to the window property: ${result.window}`
);
let error;
try {
result = testAPIMethodExplicitlyClonedError();
notifyFinish(
`Unexpected returned value while expecting error: ${result}`
);
return;
} catch (err) {
error = err;
}
// We expect the generic "unexpected apiScript error occurred" to be raised to the
// userScript code.
assertTrue(
error &&
error.message.includes("An unexpected apiScript error occurred"),
`Got an unexpected error message: ${error}`
);
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
}
);
add_task(async function test_apiScript_method_export_primitive_types() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethod(typeToExport) {
switch (typeToExport) {
case "boolean":
return script.export(true);
case "number":
return script.export(123);
case "string":
return script.export("a string");
case "symbol":
return script.export(Symbol("a symbol"));
}
return undefined;
},
});
});
}
async function userScript() {
const { assertTrue, notifyFinish, testAPIMethod } = this;
let v = testAPIMethod("boolean");
assertTrue(v === true, `Should export a boolean`);
v = testAPIMethod("number");
assertTrue(v === 123, `Should export a number`);
v = testAPIMethod("string");
assertTrue(v === "a string", `Should export a string`);
v = testAPIMethod("symbol");
assertTrue(typeof v === "symbol", `Should export a symbol`);
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
});
add_task(
async function test_apiScript_method_avoid_unnecessary_params_cloning() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethodReturnsParam(param) {
return param;
},
testAPIMethodReturnsUnwrappedParam(param) {
return param.wrappedJSObject;
},
});
});
}
async function userScript() {
const {
assertTrue,
notifyFinish,
testAPIMethodReturnsParam,
testAPIMethodReturnsUnwrappedParam,
} = this;
let obj = {};
let result = testAPIMethodReturnsParam(obj);
assertTrue(
result === obj,
`Expect returned value to be strictly equal to the API method parameter`
);
result = testAPIMethodReturnsUnwrappedParam(obj);
assertTrue(
result === obj,
`Expect returned value to be strictly equal to the unwrapped API method parameter`
);
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
}
);
add_task(async function test_apiScript_method_export_sparse_arrays() {
function apiScript(sharedTestAPIMethods) {
browser.userScripts.onBeforeScript.addListener(script => {
script.defineGlobals({
...sharedTestAPIMethods,
testAPIMethod() {
const sparseArray = [];
sparseArray[3] = "third-element";
sparseArray[5] = "fifth-element";
return script.export(sparseArray);
},
});
});
}
async function userScript() {
const { assertTrue, notifyFinish, testAPIMethod } = this;
const result = testAPIMethod(window, document);
// We expect the returned value to be the uncloneable window object.
assertTrue(
result && result.length === 6,
`the returned value should be an array of the expected length: ${result}`
);
assertTrue(
result[3] === "third-element",
`the third array element should have the expected value: ${result[3]}`
);
assertTrue(
result[5] === "fifth-element",
`the fifth array element should have the expected value: ${result[5]}`
);
assertTrue(
result[0] === undefined,
`the first array element should have the expected value: ${result[0]}`
);
assertTrue(!("0" in result), "Holey array should still be holey");
notifyFinish();
}
await test_userScript_APIMethod({
userScript,
apiScript,
});
});