199 lines
7.8 KiB
TypeScript
199 lines
7.8 KiB
TypeScript
import { expect, test } from "bun:test";
|
|||
|
|
import { bunEnv, bunExe, tempDir } from "harness";
|
||
|
|
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||
|
|
import { join } from "node:path";
|
||
|
|
|
||
|
|
// https://github.com/oven-sh/bun/issues/30887
|
||
|
|
//
|
||
|
|
// Under `--parallel` / `--isolate`, `bun test` reads transpiled preloads from
|
||
|
|
// the isolation source provider cache. That path dropped the `has_tla` flag
|
||
|
|
// on the cached module record, so JSC treated TLA preloads as having none
|
||
|
|
// and let their evaluation promise resolve before the top-level-await
|
||
|
|
// actually completed. The preload's side effects (like `Bun.env.X = ...`)
|
||
|
|
// then happened *after* the test file started running.
|
||
|
|
//
|
||
|
|
// These tests assert that the preload's top-level await is awaited to
|
||
|
|
// completion before the test file runs, across all three modes, and that
|
||
|
|
// the on-disk runtime transpiler cache survives a round-trip with TLA
|
||
|
|
// intact.
|
||
|
|
|
||
|
|
async function runTest(cwd: string, extraArgs: string[], extraEnv?: Record<string, string>) {
|
||
|
|
await using proc = Bun.spawn({
|
||
|
|
cmd: [bunExe(), "test", ...extraArgs],
|
||
|
|
env: { ...bunEnv, ...extraEnv },
|
||
|
|
cwd,
|
||
|
|
stdout: "pipe",
|
||
|
|
stderr: "pipe",
|
||
|
|
});
|
||
|
|
|
||
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||
|
|
|
||
|
|
return { stdout, stderr, exitCode };
|
||
|
|
}
|
||
|
|
|
||
|
|
test.concurrent.each([
|
||
|
|
["sequential (default)", []],
|
||
|
|
["--isolate", ["--isolate"]],
|
||
|
|
["--parallel", ["--parallel"]],
|
||
|
|
])("async preload is awaited before tests run (%s)", async (_name, flags) => {
|
||
|
|
using dir = tempDir("bun-test-30887-", {
|
||
|
|
"preload.ts": `
|
||
|
|
await Promise.resolve();
|
||
|
|
await Promise.resolve();
|
||
|
|
await Promise.resolve();
|
||
|
|
Bun.env.MY_ENV = "MUST_NOT_BE_UNDEFINED";
|
||
|
|
`,
|
||
|
|
"my.test.ts": `
|
||
|
|
import { test, expect } from "bun:test";
|
||
|
|
test("MY_ENV must not be undefined", () => {
|
||
|
|
expect(Bun.env.MY_ENV).toBe("MUST_NOT_BE_UNDEFINED");
|
||
|
|
});
|
||
|
|
`,
|
||
|
|
"bunfig.toml": `
|
||
|
|
[test]
|
||
|
|
preload = ["./preload.ts"]
|
||
|
|
`,
|
||
|
|
});
|
||
|
|
|
||
|
|
const { stderr, exitCode } = await runTest(String(dir), flags);
|
||
|
|
|
||
|
|
expect(stderr).toContain("1 pass");
|
||
|
|
expect(stderr).toContain("0 fail");
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Runtime transpiler cache round-trip: a ≥4 KiB preload with top-level-await
|
||
|
|
// is cached on the first run, then read back from the on-disk cache on the
|
||
|
|
// second run. The cache-HIT path deserializes `module_info` straight from the
|
||
|
|
// stored `esm_record` without touching the AST, so if the serializer or the
|
||
|
|
// cache version doesn't preserve the `has_tla` flag, the second run will
|
||
|
|
// fail exactly like the original #30887 repro.
|
||
|
|
test("async preload survives runtime transpiler cache round-trip (--isolate)", async () => {
|
||
|
|
// Pad the preload past MINIMUM_CACHE_SIZE (4 KiB). Declared variables are
|
||
|
|
// enough — we just need byte-length, not any particular JS behavior.
|
||
|
|
const padding = "\n" + Array.from({ length: 400 }, (_, i) => `const pad_${i} = ${i};`).join("\n");
|
||
|
|
|
||
|
|
using dir = tempDir("bun-test-30887-cache-", {
|
||
|
|
"preload.ts": `
|
||
|
|
await Promise.resolve();
|
||
|
|
await Promise.resolve();
|
||
|
|
await Promise.resolve();
|
||
|
|
Bun.env.MY_ENV = "MUST_NOT_BE_UNDEFINED";
|
||
|
|
${padding}
|
||
|
|
`,
|
||
|
|
"my.test.ts": `
|
||
|
|
import { test, expect } from "bun:test";
|
||
|
|
test("MY_ENV must not be undefined", () => {
|
||
|
|
expect(Bun.env.MY_ENV).toBe("MUST_NOT_BE_UNDEFINED");
|
||
|
|
});
|
||
|
|
`,
|
||
|
|
"bunfig.toml": `
|
||
|
|
[test]
|
||
|
|
preload = ["./preload.ts"]
|
||
|
|
`,
|
||
|
|
});
|
||
|
|
|
||
|
|
const cachePath = join(String(dir), ".bun-cache");
|
||
|
|
// `BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE=1` is required on debug
|
||
|
|
// builds so `RuntimeTranspilerCache::get()` actually returns the loaded
|
||
|
|
// entry instead of discarding it; without it the cache-HIT branch in
|
||
|
|
// `RuntimeTranspilerStore` is never taken (precedent:
|
||
|
|
// `test/cli/run/transpiler-cache.test.ts`).
|
||
|
|
const extraEnv = {
|
||
|
|
BUN_RUNTIME_TRANSPILER_CACHE_PATH: cachePath,
|
||
|
|
BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1",
|
||
|
|
};
|
||
|
|
|
||
|
|
// First run populates the cache.
|
||
|
|
const first = await runTest(String(dir), ["--isolate"], extraEnv);
|
||
|
|
expect(first.stderr).toContain("1 pass");
|
||
|
|
expect(first.stderr).toContain("0 fail");
|
||
|
|
expect(first.exitCode).toBe(0);
|
||
|
|
|
||
|
|
// Second run reads from the cache. The cache-HIT branch builds
|
||
|
|
// `module_info` via `create_from_cached_record`, never touching the AST —
|
||
|
|
// so `has_tla` here comes entirely from the serialized bytes.
|
||
|
|
const second = await runTest(String(dir), ["--isolate"], extraEnv);
|
||
|
|
expect(second.stderr).toContain("1 pass");
|
||
|
|
expect(second.stderr).toContain("0 fail");
|
||
|
|
expect(second.exitCode).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// On-disk cache entries written before #30888 have `has_tla=false` baked
|
||
|
|
// into their serialized ESM record; without bumping `EXPECTED_VERSION`, the
|
||
|
|
// post-fix binary would happily read them back and reinstate the original
|
||
|
|
// bug. Simulate the stale-entry scenario by populating the cache, rewriting
|
||
|
|
// the 4-byte LE version header to the prior version (20), and asserting the
|
||
|
|
// next run overwrites it with the current version rather than silently
|
||
|
|
// accepting the stale entry.
|
||
|
|
test("stale transpiler cache entries are rejected (version bump)", async () => {
|
||
|
|
const padding = "\n" + Array.from({ length: 400 }, (_, i) => `const pad_${i} = ${i};`).join("\n");
|
||
|
|
|
||
|
|
using dir = tempDir("bun-test-30887-stale-", {
|
||
|
|
"preload.ts": `
|
||
|
|
await Promise.resolve();
|
||
|
|
Bun.env.MY_ENV = "MUST_NOT_BE_UNDEFINED";
|
||
|
|
${padding}
|
||
|
|
`,
|
||
|
|
"my.test.ts": `
|
||
|
|
import { test, expect } from "bun:test";
|
||
|
|
test("MY_ENV must not be undefined", () => {
|
||
|
|
expect(Bun.env.MY_ENV).toBe("MUST_NOT_BE_UNDEFINED");
|
||
|
|
});
|
||
|
|
`,
|
||
|
|
"bunfig.toml": `
|
||
|
|
[test]
|
||
|
|
preload = ["./preload.ts"]
|
||
|
|
`,
|
||
|
|
});
|
||
|
|
|
||
|
|
const cachePath = join(String(dir), ".bun-cache");
|
||
|
|
const extraEnv = {
|
||
|
|
BUN_RUNTIME_TRANSPILER_CACHE_PATH: cachePath,
|
||
|
|
BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1",
|
||
|
|
};
|
||
|
|
|
||
|
|
// First run populates the cache (so the filename and path layout exist).
|
||
|
|
const first = await runTest(String(dir), ["--isolate"], extraEnv);
|
||
|
|
expect(first.stderr).toContain("1 pass");
|
||
|
|
expect(first.exitCode).toBe(0);
|
||
|
|
|
||
|
|
// Locate the written `.pile` file(s) (covers `.debug.pile` on debug builds
|
||
|
|
// and `.pile` on release builds). Read the current cache version from the
|
||
|
|
// first file, then downgrade every entry's 4-byte LE version header to
|
||
|
|
// `current - 1`. A binary that reverted the #30888 bump (back to 20)
|
||
|
|
// would accept a `has_tla=false` entry verbatim; the fix requires the
|
||
|
|
// decode to fail and the file to be re-transpiled and rewritten at the
|
||
|
|
// current version. Reading the current version from the file (rather
|
||
|
|
// than hardcoding 21) means this test doesn't need editing on every
|
||
|
|
// future `EXPECTED_VERSION` bump — it still fails iff the version is
|
||
|
|
// ever reverted to ≤ 20.
|
||
|
|
const entries = readdirSync(cachePath).filter(n => n.endsWith(".pile"));
|
||
|
|
expect(entries.length).toBeGreaterThan(0);
|
||
|
|
const firstBytes = readFileSync(join(cachePath, entries[0]));
|
||
|
|
expect(firstBytes.length).toBeGreaterThan(4);
|
||
|
|
const currentVersion = firstBytes.readUInt32LE(0);
|
||
|
|
// Guard against an accidental revert of the #30888 bump (20 → 21).
|
||
|
|
expect(currentVersion).toBeGreaterThan(20);
|
||
|
|
for (const name of entries) {
|
||
|
|
const p = join(cachePath, name);
|
||
|
|
const bytes = readFileSync(p);
|
||
|
|
expect(bytes.readUInt32LE(0)).toBe(currentVersion);
|
||
|
|
bytes.writeUInt32LE(currentVersion - 1, 0);
|
||
|
|
writeFileSync(p, bytes);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Second run must reject the stale entry, re-transpile, and overwrite.
|
||
|
|
const second = await runTest(String(dir), ["--isolate"], extraEnv);
|
||
|
|
expect(second.stderr).toContain("1 pass");
|
||
|
|
expect(second.stderr).toContain("0 fail");
|
||
|
|
expect(second.exitCode).toBe(0);
|
||
|
|
|
||
|
|
// The cache file must now carry the current version byte, proving the
|
||
|
|
// decode path rejected the stale header and forced a rewrite.
|
||
|
|
for (const name of entries) {
|
||
|
|
const bytes = readFileSync(join(cachePath, name));
|
||
|
|
expect(bytes.readUInt32LE(0)).toBe(currentVersion);
|
||
|
|
}
|
||
|
|
});
|