rea/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts

633 lines
25 KiB
TypeScript

import { expect } from "vitest";
import type { JavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
const workerReferences = (graph: JavaScriptApplicationGraph) =>
graph.nodes
.filter(({ kind }) => kind === "worker")
.flatMap((node) =>
node.observations.map(({ properties, evidence }) => ({
source:
evidence.location.available &&
evidence.location.value.kind === "source-range"
? evidence.location.value.source
: null,
declared_specifier: properties.declared_specifier,
expression: properties.expression,
resolved_path: properties.resolved_path,
mapped_paths: graph.edges
.filter(
({ relation, source_node_id }) =>
relation === "maps_to" && source_node_id === node.node_id,
)
.map(({ properties: edge }) => edge.resolved_path),
})),
);
cliTest(
"promotes Worker URLs only for known constructor bindings through the CLI",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-worker-identity-");
const identityCases = [
...["globalThis", "window", "self"].map((namespace) => ({
prefix: "",
constructor: `new ${namespace}.Worker`,
suffix: "",
resolves: true,
})),
...["worker_threads", "node:worker_threads"].flatMap((specifier) => [
{
prefix: `import { Worker } from "${specifier}";`,
constructor: "new Worker",
suffix: "",
resolves: true,
},
{
prefix: `import * as threads from "${specifier}";`,
constructor: "new threads.Worker",
suffix: "",
resolves: true,
},
]),
{
prefix: 'import { Worker as ThreadWorker } from "node:worker_threads";',
constructor: "new ThreadWorker",
suffix: "",
resolves: true,
},
{
prefix: 'import { Worker } from "node:worker_threads";',
constructor: "new (Worker as typeof Worker)",
suffix: "",
resolves: true,
},
{
prefix: 'import * as threads from "node:worker_threads";',
constructor: "new ((threads as typeof threads).Worker!)",
suffix: "",
resolves: true,
},
{
prefix:
'import { Worker } from "node:worker_threads"; function start(globalThis) { globalThis.URL = Fake;',
constructor: "new Worker",
suffix: "}",
resolves: true,
},
{
prefix: 'globalThis.unrelated = Fake; URL.extra = "metadata";',
constructor: "new Worker",
suffix: "",
resolves: true,
},
...[
["class Worker {}", "new Worker", ""],
["globalThis.Worker = Fake;", "new Worker", ""],
["globalThis.Worker = Fake;", "new window.Worker", ""],
["globalThis = replacement;", "new globalThis.Worker", ""],
["function start(Worker) {", "new Worker", "}"],
['import { Worker } from "./custom.js";', "new Worker", ""],
["const custom = { Worker: class {} };", "new custom.Worker", ""],
["function start(window) {", "new window.Worker", "}"],
["function start(globalThis) {", "new globalThis.Worker", "}"],
[
'import type { Worker } from "node:worker_threads";',
"new Worker",
"",
],
[
'import { type Worker } from "node:worker_threads";',
"new Worker",
"",
],
[
'import type * as threads from "node:worker_threads";',
"new threads.Worker",
"",
],
[
'import threads from "node:worker_threads"; threads.Worker = Fake;',
"new threads.Worker",
"",
],
[
'const threads = require("node:worker_threads"); threads.Worker = Fake;',
"new threads.Worker",
"",
],
[
'import threads = require("node:worker_threads"); threads.Worker = Fake;',
"new threads.Worker",
"",
],
[
'import { Worker } from "node:worker_threads"; Worker = Fake;',
"new Worker",
"",
],
["", "Worker", ""],
].map(([prefix, constructor, suffix]) => ({
prefix,
constructor,
suffix,
resolves: false,
})),
].map(({ prefix, constructor, suffix, resolves }, index) => ({
source: `identity-${index}.ts`,
prefix,
expression: `${constructor}(new URL("./worker.js", import.meta.url))`,
suffix,
resolves,
}));
await writeFixtureFiles(root, {
...Object.fromEntries(
identityCases.map(({ source, prefix, expression, suffix }) => [
source,
`${prefix}\n${expression};${suffix}`,
]),
),
"worker.js": "self.postMessage('existing worker file');",
"custom.js": "export class Worker {}",
"custom-alias.mjs":
'import { Worker as ThreadWorker } from "./custom.js"; new ThreadWorker(new URL("./worker.js", import.meta.url));',
});
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
expect(result.statistics.parse_failures).toBe(0);
const references = workerReferences(result.graph);
expect(references).toHaveLength(identityCases.length);
expect(references.some(({ source }) => source === "custom-alias.mjs")).toBe(
false,
);
for (const { source, expression, resolves } of identityCases)
expect(references).toContainEqual({
source,
declared_specifier: resolves ? "./worker.js" : null,
expression: resolves ? null : expression,
resolved_path: resolves ? "worker.js" : null,
mapped_paths: resolves ? ["worker.js"] : [],
});
},
);
cliTest(
"resolves module-relative Worker URL constructors through the compiled CLI",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-worker-module-url-");
const cases = [
{
source: "nested/relative.mjs",
expression: 'new Worker(new URL("./worker.js", import.meta.url));',
specifier: "./worker.js",
resolved: "nested/worker.js",
},
...[
'import.meta.unrelated = "metadata";',
'globalThis["import.meta"] = { url: "file:///other/entry.mjs" };',
'const meta = import.meta; meta.unrelated = "metadata";',
'const ambient = globalThis; ambient.unrelated = "metadata";',
'const copy = { ...import.meta }; Object.defineProperty(copy, "url", { value: "file:///other/entry.mjs" });',
"const copy = { ...globalThis }; copy.URL = replacement;",
"function update(globalThis) { const ambient = globalThis; ambient.URL = replacement; } update({});",
"const define = Object.defineProperty.bind(Object);",
'Object.defineProperty(...[import.meta, "unrelated", { value: "metadata" }]);',
'let meta = import.meta; meta = {}; let base = import.meta.url; base = "file:///other/entry.mjs";',
].map((mutation, index) => ({
source: `nested/unrelated-meta-${index}.mjs`,
expression: `import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`,
specifier: "./worker.js",
resolved: "nested/worker.js",
})),
{
source: "nested/parent.mjs",
expression:
'new Worker(new URL("../parent-worker.js", import.meta.url));',
specifier: "../parent-worker.js",
resolved: "parent-worker.js",
},
{
source: "nested/dot-segments.mjs",
expression:
'new Worker(new URL("./child/.././worker.js", import.meta.url));',
specifier: "./child/.././worker.js",
resolved: "nested/worker.js",
},
{
source: "nested/template.mjs",
expression:
"new Worker(new URL(`./template-worker.js`, import.meta.url));",
specifier: "./template-worker.js",
resolved: "nested/template-worker.js",
},
...[
'new URL("./worker.js", import.meta.url) as URL',
'<URL>new URL("./worker.js", import.meta.url)',
'new URL("./worker.js", import.meta.url)!',
'new URL("./worker.js", import.meta.url) satisfies URL',
'((new URL("./worker.js", import.meta.url) as URL)!) satisfies URL',
'new (URL as typeof URL)("./worker.js", import.meta.url)',
'new URL("./worker.js" as string, import.meta.url as string)',
'new URL("./worker.js", (import.meta as ImportMeta).url)',
'new globalThis.URL("./worker.js", import.meta.url)',
'new window.URL("./worker.js", import.meta.url)',
'new self.URL("./worker.js", import.meta.url)',
'new global.URL("./worker.js", import.meta.url)',
'new ((globalThis as typeof globalThis)["URL"]!)("./worker.js", import.meta.url)',
].map((urlExpression, index) => ({
source: `nested/typed-${index}.ts`,
expression: `new Worker(${urlExpression});`,
specifier: "./worker.js",
resolved: "nested/worker.js",
})),
{
source: "nested/space.mjs",
expression:
'new Worker(new URL("./space%20worker.js?worker=1#entry", import.meta.url));',
specifier: "./space%20worker.js?worker=1#entry",
resolved: "nested/space worker.js",
},
{
source: "nested/percent.mjs",
expression:
'new Worker(new URL("./percent%2520worker.js", import.meta.url));',
specifier: "./percent%2520worker.js",
resolved: "nested/percent%20worker.js",
},
{
source: "nested/literal-percent.mjs",
expression:
'new Worker(new URL("./percent%25worker.js", import.meta.url));',
specifier: "./percent%25worker.js",
resolved: "nested/percent%worker.js",
},
...["?value=", "#value="].map((suffix, index) => {
const specifier = `./worker.js${suffix}%2F%2E%5C%00%FF%GG%2%\\\0`;
return {
source: `nested/suffix-${index}.mjs`,
expression: `new Worker(new URL(${JSON.stringify(specifier)}, import.meta.url));`,
specifier,
resolved: "nested/worker.js",
};
}),
{
source: "bundled/entry.mjs",
expression:
'self.webpackChunkdemo.push([[1], {1: function(module, exports, require) { new Worker(new URL("./worker.js", import.meta.url)); }, "./worker.js": function(module) { module.exports = 42; }}]);',
specifier: "./worker.js",
resolved: "bundled/worker.js",
},
{
source: "nested/literal.mjs",
expression: 'new Worker("./literal-worker.js");',
specifier: "./literal-worker.js",
resolved: "nested/literal-worker.js",
},
];
await writeFixtureFiles(root, {
...Object.fromEntries(
cases.flatMap(({ source, expression, resolved }) => [
[source, expression],
[resolved, `self.postMessage(${JSON.stringify(resolved)});`],
]),
),
"nested/percent worker.js": "self.postMessage('double-decoding decoy');",
});
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
expect(result.statistics.parse_failures).toBe(0);
expect(workerReferences(result.graph)).toEqual(
expect.arrayContaining(
cases.map(({ source, specifier, resolved }) => ({
source,
declared_specifier: specifier,
expression: null,
resolved_path: resolved,
mapped_paths: [resolved],
})),
),
);
expect(workerReferences(result.graph)).toHaveLength(cases.length);
expect(result.graph.nodes).toContainEqual(
expect.objectContaining({
kind: "javascript-module",
observations: expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({ module_key: "./worker.js" }),
}),
]),
}),
);
},
);
cliTest(
"keeps unsupported Worker URL expressions and missing exact files unresolved through the CLI",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-worker-url-unknown-");
const malformedPercentPaths = [
["./worker%.js", "worker%.js"],
["./worker%2.js", "worker%2.js"],
["./worker%GG.js", "worker%GG.js"],
["./space%20worker%GG.js", "space worker%GG.js"],
];
const expressions = {
"dynamic-path.mjs": "new Worker(new URL(path, import.meta.url));",
"absolute.mjs":
'import { Worker } from "node:worker_threads"; new Worker(new URL("/worker.js", import.meta.url));',
"typed-apply-meta.ts":
'import { Worker } from "node:worker_threads"; Object.defineProperty.apply(Object, ([import.meta, "url", { value: "file:///other.mjs" }] as const)); new Worker(new URL("./worker.js", import.meta.url));',
"overwritten-qualified-url.mjs":
'globalThis.URL = replacement; new Worker(new globalThis.URL("./worker.js", import.meta.url));',
"shadowed-qualified-url.mjs":
'function start(window) { new Worker(new window.URL("./worker.js", import.meta.url)); }',
...Object.fromEntries(
["globalThis.URL", "URL", 'globalThis["URL"]'].map((target, index) => [
`overwritten-url-${index}.mjs`,
`import { Worker } from "node:worker_threads"; const RealURL = URL; ${target} = class { constructor() { return new RealURL("./other.js", import.meta.url); } }; new Worker(new URL("./worker.js", import.meta.url));`,
]),
),
...Object.fromEntries(
[
"delete globalThis.URL;",
"({ URL: globalThis.URL } = replacements);",
"globalThis[key] = replacement;",
'Object.defineProperty(globalThis, "URL", { value: replacement });',
'Reflect.set(globalThis, "URL", replacement);',
"globalThis.globalThis.URL = replacement;",
"globalThis.global.URL = replacement;",
"const ambient = globalThis; ambient.URL = replacement;",
"const { globalThis: ambient } = globalThis; ambient.URL = replacement;",
].map((mutation, index) => [
`mutated-url-${index}.mjs`,
`import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`,
]),
),
...Object.fromEntries(
[
'import.meta.url = "file:///other/entry.mjs";',
"delete import.meta.url;",
'Object.defineProperty(import.meta, "url", { value: "file:///other/entry.mjs" });',
"import.meta[key] = replacement;",
'const meta = import.meta; const alias = meta; alias.url = "file:///other/entry.mjs";',
'let meta = import.meta; meta.url = "file:///other/entry.mjs"; meta = {};',
'const define = Object.defineProperty; define(import.meta, "url", { value: "file:///other/entry.mjs" });',
'const set = Reflect.set.bind(Reflect); set(import.meta, "url", "file:///other/entry.mjs");',
'Object.defineProperty.bind(Object, import.meta, "url")({ value: "file:///other/entry.mjs" });',
'Object.assign(...[import.meta, { url: "file:///other/entry.mjs" }]);',
'Object.defineProperty(...[import.meta, "url", { value: "file:///other/entry.mjs" }]);',
].map((mutation, index) => [
`mutated-import-meta-${index}.mjs`,
`import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`,
]),
),
...Object.fromEntries(
malformedPercentPaths.map(([specifier], index) => [
`malformed-percent-${index}.mjs`,
`import { Worker } from "node:worker_threads"; new Worker(new URL(${JSON.stringify(specifier)}, import.meta.url));`,
]),
),
"dynamic-base.mjs": 'new Worker(new URL("./worker.js", base));',
"empty-worker-url.mjs": 'new Worker("");',
"query-only-worker-url.mjs": 'new Worker("?cache=1");',
"fragment-only-worker-url.mjs": 'new Worker("#worker");',
"shadowed.mjs":
'function start(URL) { return new Worker(new URL("./worker.js", import.meta.url)); }',
"typed-shadowed.ts":
'function start(URL: typeof globalThis.URL) { return new Worker((new (URL as typeof globalThis.URL)("./worker.js", import.meta.url) as URL)!); }',
"typed-dynamic-path.ts":
"new Worker((new URL(path as string, import.meta.url) as URL)!);",
"typed-dynamic-base.ts":
'new Worker((new URL("./worker.js", base as string) as URL)!);',
"call.mjs": 'new Worker(URL("./worker.js", import.meta.url));',
"custom.mjs":
'new Worker(new custom.URL("./worker.js", import.meta.url));',
"extension.mjs":
'new Worker(new URL("./extensionless", import.meta.url));',
"directory.mjs": 'new Worker(new URL("./directory", import.meta.url));',
"directory-dot.mjs":
'new Worker(new URL("./worker.js/.", import.meta.url));',
"directory-parent.mjs":
'new Worker(new URL("./worker.js/child/..", import.meta.url));',
"empty-segment.mjs":
'new Worker(new URL("./child//../worker.js", import.meta.url));',
"package.mjs":
'new Worker(new URL("./package-worker", import.meta.url));',
"external.mjs":
'new Worker(new URL("https://example.test/worker.js", import.meta.url));',
"empty.mjs": 'new Worker(new URL("", import.meta.url));',
"query.mjs": 'new Worker(new URL("?worker=1", import.meta.url));',
"whitespace.mjs":
'new Worker(new URL(" ./worker.js ", import.meta.url));',
"control.mjs": 'new Worker(new URL("./worker\\t.js", import.meta.url));',
"leading-control.mjs":
'new Worker(new URL("\\u0001./worker.js", import.meta.url));',
};
const knownSpecifiers: Readonly<Record<string, string>> = {
"extension.mjs": "./extensionless",
"directory.mjs": "./directory",
"directory-dot.mjs": "./worker.js/.",
"directory-parent.mjs": "./worker.js/child/..",
"empty-segment.mjs": "./child//../worker.js",
"empty-worker-url.mjs": "",
"query-only-worker-url.mjs": "?cache=1",
"fragment-only-worker-url.mjs": "#worker",
"package.mjs": "./package-worker",
"external.mjs": "https://example.test/worker.js",
};
await writeFixtureFiles(root, {
...expressions,
...Object.fromEntries(
malformedPercentPaths.map(([, decoy]) => [
decoy,
"self.postMessage('malformed percent pathname decoy');",
]),
),
"worker.js": "self.postMessage('existing decoy');",
"other.js": "self.postMessage('custom URL result decoy');",
"extensionless.js": "self.postMessage('extension decoy');",
"directory/index.js": "self.postMessage('index decoy');",
"package-worker/package.json": JSON.stringify({ main: "selected.js" }),
"package-worker/selected.js": "self.postMessage('package main decoy');",
});
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
expect(result.statistics.parse_failures).toBe(0);
const references = workerReferences(result.graph);
expect(references).toHaveLength(Object.keys(expressions).length);
for (const [source, expression] of Object.entries(expressions)) {
const specifier = knownSpecifiers[source] ?? null;
expect(references).toContainEqual(
expect.objectContaining({
source,
declared_specifier: specifier,
expression:
specifier === null
? expression.slice(
expression.indexOf("new Worker("),
expression.lastIndexOf(";"),
)
: null,
resolved_path: null,
mapped_paths: [],
}),
);
}
},
);
cliTest(
"bounds repeated ambient alias branches through the compiled CLI",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-worker-alias-branches-");
const aliases = [
"const alias0 = globalThis;",
...Array.from(
{ length: 40 },
(_, index) =>
`const alias${index + 1} = flag${index} ? alias${index} : alias${index};`,
),
"alias40.URL = replacement;",
].join("\n");
const splitAliases = [
"const alias0 = globalThis;",
...Array.from(
{ length: 40 },
(_, index) =>
`const left${index} = alias${index}; const right${index} = alias${index}; const alias${index + 1} = flag${index} ? left${index} : right${index};`,
),
"alias40.URL = replacement;",
].join("\n");
const memberAliases = [
"const alias0 = globalThis;",
...Array.from(
{ length: 40 },
(_, index) =>
`const alias${index + 1} = flag${index} ? alias${index}.globalThis : alias${index}.window;`,
),
"alias40.URL = replacement;",
].join("\n");
const expression = 'new Worker(new URL("./worker.js", import.meta.url))';
await writeFixtureFiles(root, {
"entry.mjs": `import { Worker } from "node:worker_threads";\n${aliases}\n${expression};`,
"split.mjs": `import { Worker } from "node:worker_threads";\n${splitAliases}\n${expression};`,
"member.mjs": `import { Worker } from "node:worker_threads";\n${memberAliases}\n${expression};`,
"worker.js": "self.postMessage('existing alias-mutation decoy');",
});
const result = await analyzeJavaScriptDirectory(
"CLI",
{ run: (invocation) => cli.run({ ...invocation, timeoutMs: 20_000 }) },
root,
);
expect(result.statistics.parse_failures).toBe(0);
const references = workerReferences(result.graph);
expect(references).toHaveLength(3);
expect(references).toEqual(
expect.arrayContaining(
["entry.mjs", "split.mjs", "member.mjs"].map((source) => ({
source,
declared_specifier: null,
expression,
resolved_path: null,
mapped_paths: [],
})),
),
);
},
);
cliTest(
"keeps Node Worker filesystem strings unresolved through the compiled CLI",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-node-worker-filesystem-");
const cases = [
{
prefix: 'import { Worker } from "node:worker_threads";',
expression: 'new Worker("./worker.js")',
},
{
prefix: 'import { Worker as ThreadWorker } from "worker_threads";',
expression: 'new ThreadWorker("/worker.js")',
},
{
prefix: 'import * as threads from "node:worker_threads";',
expression: 'new threads.Worker("./worker.js")',
},
{
prefix: 'import { Worker } from "node:worker_threads";',
expression: 'new (Worker as typeof Worker)("/worker.js")',
},
{
prefix: 'import * as threads from "node:worker_threads";',
expression: 'new ((threads as typeof threads).Worker!)("./worker.js")',
},
].map((fixture, index) => ({
...fixture,
source: `nested/node-literal-${index}.ts`,
}));
await writeFixtureFiles(root, {
...Object.fromEntries(
cases.map(({ prefix, expression, source }) => [
source,
`${prefix}\n${expression};`,
]),
),
"worker.js": "self.postMessage('filesystem-root decoy');",
"nested/worker.js": "self.postMessage('source-sibling decoy');",
});
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
expect(result.statistics.parse_failures).toBe(0);
const references = workerReferences(result.graph);
expect(references).toHaveLength(cases.length);
for (const { source, expression } of cases)
expect(references).toContainEqual({
source,
declared_specifier: null,
expression,
resolved_path: null,
mapped_paths: [],
});
},
);
// Windows cannot materialize the pipe-named directory needed for this decoy.
cliTest.skipIf(process.platform === "win32")(
"keeps file-URL drive letters distinct from encoded POSIX paths through the compiled CLI",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-worker-url-drive-");
await writeFixtureFiles(root, {
"dist/drive.mjs": 'new Worker(new URL("c|/worker.js", import.meta.url));',
"dist/encoded.mjs":
'new Worker(new URL("c%7C/worker.js", import.meta.url));',
"dist/c|/worker.js": "self.postMessage('existing relative-path decoy');",
});
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
expect(result.statistics.parse_failures).toBe(0);
const references = workerReferences(result.graph);
expect(references).toHaveLength(2);
expect(references).toEqual(
expect.arrayContaining([
{
source: "dist/drive.mjs",
declared_specifier: "c|/worker.js",
expression: null,
resolved_path: null,
mapped_paths: [],
},
{
source: "dist/encoded.mjs",
declared_specifier: "c%7C/worker.js",
expression: null,
resolved_path: "dist/c|/worker.js",
mapped_paths: ["dist/c|/worker.js"],
},
]),
);
},
);