diff --git a/test/aot/workers-from-texts-that-the-build-knows.test.ts b/test/aot/workers-from-texts-that-the-build-knows.test.ts index a08c7f8449f3..f7751d2f155f 100644 --- a/test/aot/workers-from-texts-that-the-build-knows.test.ts +++ b/test/aot/workers-from-texts-that-the-build-knows.test.ts @@ -1,10 +1,11 @@ // A Worker that starts from a text or from the path of a file which is known when the program is built runs code that was compiled ahead of time. // ./parallel/workers has the programs that go through the ways to write such a text or path. They run where the interpreter is forbidden. // node:worker_threads brings the streams of Node.js along, so only the programs that are about it import it. -import { expect } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; -import { readFileSync, symlinkSync } from "node:fs"; -import { join } from "node:path"; +// console.log brings the code that formats any value along where the build cannot tell that it is handed texts, so the other programs hand it one text. +import { afterAll, expect } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { existsSync, readFileSync, statSync, symlinkSync } from "node:fs"; +import { basename, join } from "node:path"; import { built, slot, spawned, withoutCoreDump, withoutCrashReport, withoutDebugInformation } from "./harness/commands"; import { compileCommand, runtimeEnvironment } from "./harness/flags"; import { describe, test } from "./supported"; @@ -15,21 +16,47 @@ const timeout = 600_000; const compiledOnly = { ...bunEnv, [runtimeEnvironment.forbidsInterpreter]: "1" }; const listen = ` - worker.onmessage = event => (console.log("message", event.data), worker.terminate()); - worker.onerror = event => (console.log("error", /^[A-Za-z]*Error: .*|Cannot find module/m.exec(event.message)?.[0]), event.preventDefault()); + worker.onmessage = event => (console.log("message " + event.data), worker.terminate()); + worker.onerror = event => (console.log("error " + /^[A-Za-z]*Error: .*|Cannot find module/m.exec(event.message)?.[0]), event.preventDefault()); `; -async function build(folder: string, ...entries: string[]) { - const executable = join(folder, "program"); - const report = join(folder, "report.json"); +/** `file`: the executable on disk. */ +async function build(folder: string, entries: string[], name = "program") { + const executable = join(folder, name); + const report = join(folder, `${name}.report.json`); const flags = [`--aot-report=${report}`, ...withoutDebugInformation()]; const command = [bunExe(), ...compileCommand, ...flags, ...entries, "--outfile", executable]; - return { executable, report, ...(await spawned(command, folder, bunEnv)) }; + const file = executable + (isWindows ? ".exe" : ""); + return { executable, file, report, ...(await spawned(command, folder, bunEnv)) }; } /** How many programs the build has compiled, also those that it then threw away. */ const programsCompiled = (report: string): number => JSON.parse(readFileSync(report, "utf8")).statistics.EngineRuns; +/** What a command comes to that printed `stdout`, said nothing else and ended by itself. */ +const ended = (stdout: string) => ({ stdout, stderr: "", exitCode: 0, signal: null }); + +/** The warnings and the errors of a build, each as its kind, its code and the place that it names: a file without its folder, a text whole. */ +const said = (stderr: string) => + Array.from( + stderr.matchAll(/^(warn|error): (TS\d+): .*\r?\n +at (.*?)\r?$/gm), + ([, kind, code, place]) => `${kind} ${code} at ${place.startsWith("data:") ? place : basename(place)}`, + ); + +/** The line and the column at which `part` begins in `text`, as a build writes them. */ +function placeOf(text: string, part: string) { + const at = text.indexOf(part); + if (at === -1) throw new Error(`the text does not hold ${part}`); + const before = text.slice(0, at).split("\n"); + return `${before.length}:${before.at(-1)!.length + 1}`; +} + +/** The build ended well, and `warnings` is all that it said. */ +function builtSaying(compiled: { stderr: string; exitCode: number }, warnings: string[] = []) { + built(compiled); + expect(said(compiled.stderr)).toEqual(warnings); +} + describe.concurrent("compile/aot/workers from texts that the build knows", () => { const speaks = `import { parentPort } from "node:worker_threads";\nparentPort!.postMessage("spoken");\n`; const startsBoth = `import { Worker } from "node:worker_threads"; @@ -49,43 +76,81 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con (_, files, entries, programsOfExecutable) => slot(async () => { using dir = tempDir("aot-workers-from-texts", { "main.ts": startsBoth, "second.ts": speaks, ...files }); - const compiled = await build(String(dir), ...entries); - built(compiled); + const compiled = await build(String(dir), entries); + builtSaying(compiled); expect(programsCompiled(compiled.report)).toBe(programsOfExecutable); }), timeout, ); - test.each([ - ["a data: URL", `new Worker(made("data:text/javascript,postMessage(1)"))`], - ["an object URL", `new Worker(URL.createObjectURL(new Blob([made("postMessage(2)")])))`], + // What each of these hands to the Worker is made while the program runs, so the build knows none of it. The text that the + // second line of the program starts a Worker from is the only one that the executable holds. + const madeAtRunTime = [ + [ + "a data: URL", + `new Worker(made("data:text/javascript,postMessage(1)"))`, + "message 1\n", + /^crashIfInterpreterIsUsed: Worker data:text\/javascript,postMessage\(1\)$/m, + ], + [ + "an object URL", + `new Worker(URL.createObjectURL(new Blob([made("postMessage(2)")])))`, + "message 2\n", + /^crashIfInterpreterIsUsed: Worker blob:[-0-9a-f]+$/m, + ], [ "an object URL of a text that is compiled, with a type that reads it in another way,", `new Worker(URL.createObjectURL(new Blob(["postMessage(1)"], { type: made("application/typescript") })))`, + "message 1\n", + /^crashIfInterpreterIsUsed: Worker blob:[-0-9a-f]+$/m, ], - ])( + ] as const; + + // One program holds the three, and the first argument of a run says which of them it takes: one build and one new executable + // to start, not three. None of the three tells the build what another hands over. + let folderOfWays: ReturnType | undefined; + let programOfWays: Promise<{ folder: string; executable: string }> | undefined; + afterAll(() => folderOfWays?.[Symbol.dispose]()); + const builtWithEveryWay = () => + (programOfWays ??= slot(async () => { + folderOfWays = tempDir("aot-workers-from-texts", { + "main.js": ` + const made = text => Buffer.from(text).toString(); + new Worker(URL.createObjectURL(new Blob(["postMessage(1)"]))).terminate(); + const ways = [ + ${madeAtRunTime.map(([, start]) => `() => ${start},`).join("\n ")} + () => new Worker(URL.createObjectURL(new Blob(["postMessage(1)"]))), + ]; + const worker = ways[process.argv[2]]();${listen}`, + }); + const folder = String(folderOfWays); + const compiled = await build(folder, ["main.js"]); + builtSaying(compiled); + // The last way is the text of the second line again. It runs where the interpreter is forbidden, so the executable + // holds that text, and a run that stops there has stopped at the Worker of its own way. + const known = await spawned([compiled.executable, String(madeAtRunTime.length)], folder, compiledOnly); + expect(known).toEqual(ended("message 1\n")); + return { folder, executable: compiled.executable }; + })); + + test.each(madeAtRunTime.map(([title, , printed, stopsAt], way) => [title, String(way), printed, stopsAt] as const))( "%s that the program makes at run time is interpreted", - (_, start) => - slot(async () => { - using dir = tempDir("aot-workers-from-texts", { - "main.js": ` - const made = text => Buffer.from(text).toString(); - new Worker(URL.createObjectURL(new Blob(["postMessage(1)"]))).terminate(); - const worker = ${start};${listen}`, - }); - const compiled = await build(String(dir), "main.js"); - built(compiled); - const ran = await spawned([compiled.executable], String(dir), bunEnv); - expect({ stdout: ran.stdout, exitCode: ran.exitCode }).toEqual({ - stdout: expect.stringMatching(/^message [12]\n$/), - exitCode: 0, - }); - const stopped = await spawned(withoutCoreDump([compiled.executable]), String(dir), { + async (_, way, printed, stopsAt) => { + const { folder, executable } = await builtWithEveryWay(); + await slot(async () => { + expect(await spawned([executable, way], folder, bunEnv)).toEqual(ended(printed)); + const stopped = await spawned(withoutCoreDump([executable, way]), folder, { ...compiledOnly, ...withoutCrashReport, }); - expect(stopped.stderr).toContain("crashIfInterpreterIsUsed"); - }), + // It names the Worker whose code is not in the executable, before anything of that code has run. + expect(stopped.stderr).toMatch(stopsAt); + expect({ stdout: stopped.stdout, endedWithZero: stopped.exitCode === 0 }).toEqual({ + stdout: "", + endedWithZero: false, + }); + }); + }, timeout, ); @@ -122,15 +187,24 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con "project/node_modules/pool/lib/worker.js": `postMessage("the worker of a package");`, }); const sources = join(String(dir), "project", "src"); - const plain = await spawned([bunExe(), "run", "main.js"], sources, bunEnv); - const compiled = await build(sources, "main.js"); - built(compiled); - expect(plain.stdout).toContain("a package: the worker of a package\n./no-such-file.ts: error\n"); - const expected = plain.stdout.replace("a package: the worker of a package", "a package: error"); - for (const folder of [sources, join(String(dir), "elsewhere")]) { - const ran = await spawned([compiled.executable], folder, compiledOnly); - expect({ stdout: ran.stdout, exitCode: ran.exitCode }).toEqual({ stdout: expected, exitCode: 0 }); - } + const printed = (ofThePackage: string) => + ended( + [ + "./worker.ts: worker.ts", + "./worker: worker.ts", + "./lib/../worker.ts: worker.ts", + `a package: ${ofThePackage}`, + "./no-such-file.ts: error", + "", + ].join("\n"), + ); + expect(await spawned([bunExe(), "run", "main.js"], sources, bunEnv)).toEqual( + printed("the worker of a package"), + ); + const compiled = await build(sources, ["main.js"]); + builtSaying(compiled); + for (const folder of [sources, join(String(dir), "elsewhere")]) + expect(await spawned([compiled.executable], folder, compiledOnly)).toEqual(printed("error")); }), timeout, ); @@ -156,13 +230,11 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con }); const folder = String(dir); symlinkSync(join(folder, "packages", "pool"), join(folder, "node_modules", "pool"), "junction"); - const compiled = await build(folder, "src/main.js"); - built(compiled); - const ran = await spawned([compiled.executable], folder, compiledOnly); - expect({ stdout: ran.stdout, exitCode: ran.exitCode }).toEqual({ - stdout: "a package of the workspace: its worker\n", - exitCode: 0, - }); + const compiled = await build(folder, ["src/main.js"]); + builtSaying(compiled); + expect(await spawned([compiled.executable], folder, compiledOnly)).toEqual( + ended("a package of the workspace: its worker\n"), + ); }), timeout, ); @@ -176,18 +248,16 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con "worker.js": `postMessage("worker.js");`, }); const folder = String(dir); - const alone = await build(folder, "main.js"); - built(alone); - const command = [bunExe(), ...compileCommand, ...withoutDebugInformation(), "main.js", "worker.js"]; - built(await spawned([...command, "--outfile", join(folder, "listed")], folder, bunEnv)); - for (const executable of [alone.executable, join(folder, "listed")]) { - const ran = await spawned([executable], folder, compiledOnly); - expect({ stdout: ran.stdout, exitCode: ran.exitCode }).toEqual({ - stdout: "listed: worker.js\n", - exitCode: 0, - }); - } - expect(Bun.file(join(folder, "listed")).size).toBe(Bun.file(alone.executable).size); + const alone = await build(folder, ["main.js"]); + builtSaying(alone); + const listed = await build(folder, ["main.js", "worker.js"], "listed"); + builtSaying(listed); + for (const { executable } of [alone, listed]) + expect(await spawned([executable], folder, compiledOnly)).toEqual(ended("listed: worker.js\n")); + expect({ programs: programsCompiled(listed.report), bytes: statSync(listed.file).size }).toEqual({ + programs: programsCompiled(alone.report), + bytes: statSync(alone.file).size, + }); }), timeout, ); @@ -230,14 +300,31 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con "project/node_modules/@scope/tool/main.js": `postMessage("@scope/tool/main.js");`, }); const sources = join(String(dir), "project", "src", "app"); - const plain = await spawned([bunExe(), "run", "main.ts"], sources, bunEnv); - expect(plain.stdout.split("\n").filter(line => line.endsWith(": error"))).toEqual(["no-such-name: error"]); - const compiled = await build(sources, "main.ts"); - built(compiled); - for (const folder of [sources, join(String(dir), "elsewhere")]) { - const ran = await spawned([compiled.executable], folder, compiledOnly); - expect({ stdout: ran.stdout, exitCode: ran.exitCode }).toEqual({ stdout: plain.stdout, exitCode: 0 }); - } + // Plain Bun and the executable find the same file for each of them. + const printed = ended( + [ + "worker.ts: worker.ts", + "worker: worker.ts", + "lib/inner.ts: lib/inner.ts", + "pool: pool/lib/index.js", + "pool/lib/job.js: pool/lib/job.js", + "pool/lib/job: pool/lib/job.js", + "@scope/tool: @scope/tool/main.js", + "no-such-name: error", + "../beside.ts: beside.ts and what it imports", + "../../workers/job.ts: workers/job.ts", + "a URL one folder above: beside.ts and what it imports", + "its href: beside.ts and what it imports", + "a joined path one folder above: beside.ts and what it imports", + "a URL from a folder below: beside.ts and what it imports", + "", + ].join("\n"), + ); + expect(await spawned([bunExe(), "run", "main.ts"], sources, bunEnv)).toEqual(printed); + const compiled = await build(sources, ["main.ts"]); + builtSaying(compiled); + for (const folder of [sources, join(String(dir), "elsewhere")]) + expect(await spawned([compiled.executable], folder, compiledOnly)).toEqual(printed); }), timeout, ); @@ -246,56 +333,65 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con "a file that is named by a path and does not compile is left out, and the build says so", () => slot(async () => { - using dir = tempDir("aot-workers-from-texts", { - "main.js": ` + const start = `new Worker(new URL("./imports.js", import.meta.url))`; + const main = ` ${report} await report("sound", new Worker(new URL("./sound.js", import.meta.url))); - await report("imports what is not there", new Worker(new URL("./imports.js", import.meta.url))); - `, + await report("imports what is not there", ${start}); + `; + using dir = tempDir("aot-workers-from-texts", { + "main.js": main, "sound.js": `postMessage("sound.js");`, "imports.js": `import "./inner.js"; postMessage("imports.js");`, "inner.js": `import "./no-such-file.js";`, }); - const compiled = await build(String(dir), "main.js"); - built(compiled); - expect(compiled.stderr).toContain("warn: TS100150"); - expect(compiled.stderr).toContain("warn: TS100072"); - const ran = await spawned([compiled.executable], String(dir), compiledOnly); - expect({ stdout: ran.stdout, exitCode: ran.exitCode }).toEqual({ - stdout: "sound: sound.js\nimports what is not there: error\n", - exitCode: 0, - }); + const compiled = await build(String(dir), ["main.js"]); + // What is wrong, in the file that is wrong, and then the Worker that starts from what imports it. + builtSaying(compiled, ["warn TS100150 at inner.js:1:1", `warn TS100072 at main.js:${placeOf(main, start)}`]); + expect(await spawned([compiled.executable], String(dir), compiledOnly)).toEqual( + ended("sound: sound.js\nimports what is not there: error\n"), + ); }), timeout, ); test.each([ - ["a syntax error", "postMessage((", "TS1109", "SyntaxError: Unexpected end of file"], - ["words that are no program", "this is no program", "TS1005", "undefined"], - ["an import that is not found", `import "./no-such-file.js"; postMessage(1)`, "TS100150", "Cannot find module"], + ["a syntax error", "postMessage((", "TS1109", "1:14", "SyntaxError: Unexpected end of file"], + ["words that are no program", "this is no program", "TS1005", "1:6", "undefined"], + [ + "an import that is not found", + `import "./no-such-file.js"; postMessage(1)`, + "TS100150", + "1:1", + "Cannot find module", + ], ])( "a text with %s is left out, and the build says so", - (_, text, code, said) => + (_, text, code, place, heard) => slot(async () => { - using dir = tempDir("aot-workers-from-texts", { - "main.js": ` + const url = "data:text/javascript," + text; + const start = `new Worker(${JSON.stringify(url)})`; + const main = ` const sound = new Worker("data:text/javascript,postMessage('sound')"); sound.onmessage = event => { - console.log("message", event.data); + console.log("message " + event.data); sound.terminate(); - const worker = new Worker(${JSON.stringify("data:text/javascript," + text)});${listen} + const worker = ${start};${listen} }; - `, - }); - const compiled = await build(String(dir), "main.js"); + `; + using dir = tempDir("aot-workers-from-texts", { "main.js": main }); + const compiled = await build(String(dir), ["main.js"]); built(compiled); - expect(compiled.stderr).toContain(`warn: ${code}`); - expect(compiled.stderr).toContain("warn: TS100072"); - const ran = await spawned([compiled.executable], String(dir), bunEnv); - expect({ stdout: ran.stdout, exitCode: ran.exitCode }).toEqual({ - stdout: `message sound\nerror ${said}\n`, - exitCode: 0, - }); + // First what is wrong with the text, where it is wrong. Whatever else the build says is about that text too, but for + // one warning: the Worker that starts from it. + const warnings = said(compiled.stderr); + expect(warnings[0]).toBe(`warn ${code} at ${url}:${place}`); + expect(warnings.filter(warning => !warning.includes(` at ${url}:`))).toEqual([ + `warn TS100072 at main.js:${placeOf(main, start)}`, + ]); + expect(await spawned([compiled.executable], String(dir), bunEnv)).toEqual( + ended(`message sound\nerror ${heard}\n`), + ); }), timeout, ); @@ -308,7 +404,7 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con "main.ts": ` const heard = (worker: Worker) => new Promise(done => { - worker.onmessage = event => (console.log("message", event.data), worker.terminate(), done()); + worker.onmessage = event => (console.log("message " + event.data), worker.terminate(), done()); worker.onerror = event => (console.log("error"), event.preventDefault(), done()); worker.postMessage(20); }); @@ -331,38 +427,47 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con "listener.ts": ` self.addEventListener("message", (event: MessageEvent) => { postMessage(event.data + 3); }); self.onerror = null; - console.log(typeof self.dispatchEvent, typeof dispatchEvent, onerror); + console.log(typeof self.dispatchEvent + " " + typeof dispatchEvent + " " + onerror); `, }); - const compiled = await build(String(dir), "main.ts"); - built(compiled); + const compiled = await build(String(dir), ["main.ts"]); + builtSaying(compiled); expect(compiled.stderr).not.toContain("warn:"); - const ran = await spawned([compiled.executable], String(dir), compiledOnly); - expect({ stdout: ran.stdout, exitCode: ran.exitCode }).toEqual({ - stdout: "message 21\nmessage 22\nfunction function null\nmessage 23\nmessage 24\nmessage 25\n", - exitCode: 0, - }); + expect(await spawned([compiled.executable], String(dir), compiledOnly)).toEqual( + ended("message 21\nmessage 22\nfunction function null\nmessage 23\nmessage 24\nmessage 25\n"), + ); }), timeout, ); test.each([ - ["a file that is named by a path", `new URL("./worker.ts", import.meta.url)`], - ["a text", JSON.stringify(`data:text/javascript,let count: number = "text"; postMessage(count)`)], + ["a file that is named by a path", `new URL("./worker.ts", import.meta.url)`, /^error TS2322 at worker\.ts:1:5$/], + [ + "a text", + JSON.stringify(`data:text/javascript,let count: number = "text"; postMessage(count)`), + /^error TS2322 at .+:1:5$/, + ], ])( "a type error in %s stops the build, as it does in an entry point", - (_, code) => + (_, code, error) => slot(async () => { using dir = tempDir("aot-workers-from-texts", { "main.ts": `const worker = new Worker(${code});${listen}`, "worker.ts": `let count: number = "text"; postMessage(count);`, }); - const compiled = await build(String(dir), "main.ts"); - expect({ stderr: compiled.stderr, exitCode: compiled.exitCode }).toEqual({ - stderr: expect.stringContaining("error: TS2322"), + const compiled = await build(String(dir), ["main.ts"]); + // The error is all that the build says: it stops, and leaves nothing out. + expect({ + said: said(compiled.stderr), + exitCode: compiled.exitCode, + signal: compiled.signal, + hasWrittenAnExecutable: existsSync(compiled.file), + }).toEqual({ + said: [expect.stringMatching(error)], exitCode: 1, + signal: null, + hasWrittenAnExecutable: false, }); - expect(compiled.stderr).not.toContain("TS100072"); }), timeout, );