779 lines
23 KiB
TypeScript
779 lines
23 KiB
TypeScript
import { spawn } from "bun";
|
||
import { beforeEach, expect, it } from "bun:test";
|
||
import { copyFileSync, cpSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs";
|
||
import { bunEnv, bunExe, isDebug, isWindows, tmpdirSync, waitForFileToExist } from "harness";
|
||
import { join } from "path";
|
||
|
||
const timeout = isDebug ? Infinity : 10_000;
|
||
const longTimeout = isDebug ? Infinity : 30_000;
|
||
|
||
/**
|
||
* Helper to parse stderr from a --hot process that throws errors.
|
||
* Drives the reload cycle: reads error lines from stderr, verifies them,
|
||
* and calls onReload to trigger the next file change.
|
||
*
|
||
* This fixes the original `continue outer` pattern which discarded any
|
||
* remaining buffered lines from the current chunk when a duplicate error
|
||
* was encountered, potentially losing data and causing test hangs.
|
||
*/
|
||
async function driveErrorReloadCycle(
|
||
runner: ReturnType<typeof spawn>,
|
||
opts: {
|
||
targetCount: number;
|
||
onReload: (counter: number) => void;
|
||
verifyLine?: (errorLine: string, nextLine: string | undefined, counter: number) => void | "retry";
|
||
},
|
||
): Promise<number> {
|
||
const { targetCount, onReload, verifyLine } = opts;
|
||
let reloadCounter = 0;
|
||
let str = "";
|
||
|
||
for await (const chunk of runner.stderr) {
|
||
str += new TextDecoder().decode(chunk);
|
||
// Need at least one error line followed by a newline, then another line followed by a newline
|
||
if (!/error: .*[0-9]\n.*?\n/g.test(str)) continue;
|
||
|
||
const lines = str.split("\n");
|
||
// Preserve trailing partial line for the next chunk
|
||
str = lines.pop() ?? "";
|
||
let triggered = false;
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
if (!line.includes("error:")) {
|
||
// Don't silently swallow a watcher-thread death-rattle — surface it so the
|
||
// post-loop "Expected 50, Received N" becomes an actionable failure.
|
||
if (/Watcher crashed|panic:|oh no:/.test(line)) {
|
||
throw new Error("child --hot died: " + line);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (reloadCounter >= targetCount) {
|
||
runner.kill();
|
||
return reloadCounter;
|
||
}
|
||
|
||
// Windows: writeHotFileAtomicSync's rm+rename has a brief gap where the
|
||
// entry file doesn't exist. A reload that lands in it prints one of
|
||
// "Module not found" / "ENOENT reading" / "EPERM reading" (delete
|
||
// pending). Skip it; the rename's own watcher event drives the real
|
||
// reload, so re-saving here would only race that. POSIX rename is
|
||
// atomic, so these showing up there would be a real bug.
|
||
if (isWindows && /Module not found|\w+ reading "/.test(line)) continue;
|
||
|
||
// If we see the previous error repeated, the pending reload hasn't
|
||
// taken effect yet. Re-save the file and put remaining unprocessed
|
||
// lines back into the buffer so they aren't lost.
|
||
if (line.includes(`error: ${reloadCounter - 1}`)) {
|
||
const remaining = lines.slice(i + 1).join("\n");
|
||
if (remaining) {
|
||
str = `${remaining}\n${str}`;
|
||
}
|
||
onReload(reloadCounter);
|
||
triggered = false; // onReload already called; skip post-loop call
|
||
break;
|
||
}
|
||
|
||
expect(line).toContain(`error: ${reloadCounter}`);
|
||
|
||
const nextLine = lines[i + 1];
|
||
if (verifyLine) {
|
||
const result = verifyLine(line, nextLine, reloadCounter);
|
||
if (result === "retry") {
|
||
// Partial bundle read (e.g. --hot picked up the outfile before the
|
||
// inline sourcemap trailer was flushed). Re-trigger the write and
|
||
// re-buffer remaining lines, same as the stale-counter branch above.
|
||
const remaining = lines.slice(i + 1).join("\n");
|
||
if (remaining) {
|
||
str = `${remaining}\n${str}`;
|
||
}
|
||
onReload(reloadCounter);
|
||
triggered = false;
|
||
break;
|
||
}
|
||
i++; // Skip the next line (stack trace)
|
||
}
|
||
|
||
reloadCounter++;
|
||
triggered = true;
|
||
|
||
if (reloadCounter >= targetCount) {
|
||
runner.kill();
|
||
return reloadCounter;
|
||
}
|
||
}
|
||
|
||
if (triggered) {
|
||
onReload(reloadCounter);
|
||
}
|
||
}
|
||
|
||
return reloadCounter;
|
||
}
|
||
|
||
let hotRunnerRoot: string = "",
|
||
cwd = "";
|
||
beforeEach(() => {
|
||
const hotPath = tmpdirSync();
|
||
hotRunnerRoot = join(hotPath, "hot-runner-root.js");
|
||
rmSync(hotPath, { recursive: true, force: true });
|
||
cpSync(import.meta.dir, hotPath, { recursive: true, force: true });
|
||
cwd = hotPath;
|
||
});
|
||
|
||
it("preload not found should exit with code 1 and not time out", async () => {
|
||
const root = hotRunnerRoot;
|
||
const runner = spawn({
|
||
cmd: [bunExe(), "--preload=/dev/foobarbarbar", "--hot", root],
|
||
env: bunEnv,
|
||
stdout: "inherit",
|
||
stderr: "pipe",
|
||
stdin: "ignore",
|
||
});
|
||
await runner.exited;
|
||
expect(runner.signalCode).toBe(null);
|
||
expect(runner.exitCode).toBe(1);
|
||
expect(await new Response(runner.stderr).text()).toContain("preload not found");
|
||
});
|
||
|
||
it(
|
||
"should hot reload when file is overwritten",
|
||
async () => {
|
||
const root = hotRunnerRoot;
|
||
try {
|
||
var runner = spawn({
|
||
cmd: [bunExe(), "--hot", "run", root],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "pipe",
|
||
stderr: "inherit",
|
||
stdin: "ignore",
|
||
});
|
||
|
||
var reloadCounter = 0;
|
||
|
||
async function onReload() {
|
||
writeFileSync(root, readFileSync(root, "utf-8"));
|
||
}
|
||
|
||
var str = "";
|
||
for await (const line of runner.stdout) {
|
||
str += new TextDecoder().decode(line);
|
||
var any = false;
|
||
if (!/\[#!root\].*[0-9]\n/g.test(str)) continue;
|
||
|
||
for (let line of str.split("\n")) {
|
||
if (!line.includes("[#!root]")) continue;
|
||
reloadCounter++;
|
||
str = "";
|
||
|
||
if (reloadCounter === 3) {
|
||
runner.unref();
|
||
runner.kill();
|
||
break;
|
||
}
|
||
|
||
expect(line).toContain(`[#!root] Reloaded: ${reloadCounter}`);
|
||
any = true;
|
||
}
|
||
|
||
if (any) await onReload();
|
||
}
|
||
|
||
expect(reloadCounter).toBeGreaterThanOrEqual(3);
|
||
} finally {
|
||
// @ts-ignore
|
||
runner?.unref?.();
|
||
// @ts-ignore
|
||
runner?.kill?.(9);
|
||
}
|
||
},
|
||
timeout,
|
||
);
|
||
|
||
it.each(["hot-file-loader.file", "hot-file-loader.css"])(
|
||
"should hot reload when `%s` is overwritten",
|
||
async (targetFilename: string) => {
|
||
const root = hotRunnerRoot;
|
||
const target = join(cwd, targetFilename);
|
||
try {
|
||
var runner = spawn({
|
||
cmd: [bunExe(), "--hot", "run", root],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "pipe",
|
||
stderr: "inherit",
|
||
stdin: "ignore",
|
||
});
|
||
|
||
var reloadCounter = 0;
|
||
|
||
async function onReload() {
|
||
writeFileSync(target, readFileSync(target, "utf-8"));
|
||
}
|
||
|
||
var str = "";
|
||
for await (const line of runner.stdout) {
|
||
str += new TextDecoder().decode(line);
|
||
var any = false;
|
||
if (!/\[#!root\].*[0-9]\n/g.test(str)) continue;
|
||
|
||
for (let line of str.split("\n")) {
|
||
if (!line.includes("[#!root]")) continue;
|
||
reloadCounter++;
|
||
str = "";
|
||
|
||
if (reloadCounter === 3) {
|
||
runner.unref();
|
||
runner.kill();
|
||
break;
|
||
}
|
||
|
||
expect(line).toContain(`[#!root] Reloaded: ${reloadCounter}`);
|
||
any = true;
|
||
}
|
||
|
||
if (any) await onReload();
|
||
}
|
||
|
||
expect(reloadCounter).toBeGreaterThanOrEqual(3);
|
||
} finally {
|
||
// @ts-ignore
|
||
runner?.unref?.();
|
||
// @ts-ignore
|
||
runner?.kill?.(9);
|
||
}
|
||
},
|
||
timeout,
|
||
);
|
||
|
||
it(
|
||
"should recover from errors",
|
||
async () => {
|
||
const root = hotRunnerRoot;
|
||
try {
|
||
var runner = spawn({
|
||
cmd: [bunExe(), "--hot", "run", root],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "pipe",
|
||
stderr: "pipe",
|
||
stdin: "ignore",
|
||
});
|
||
|
||
let reloadCounter = 0;
|
||
const input = readFileSync(root, "utf-8");
|
||
function onReloadGood() {
|
||
writeFileSync(root, input);
|
||
}
|
||
|
||
function onReloadError() {
|
||
writeFileSync(root, "throw new Error('error');\n");
|
||
}
|
||
|
||
var queue = [onReloadError, onReloadGood, onReloadError, onReloadGood];
|
||
var errors: string[] = [];
|
||
var onError: (...args: any[]) => void;
|
||
(async () => {
|
||
for await (let line of runner.stderr) {
|
||
var str = new TextDecoder().decode(line);
|
||
errors.push(str);
|
||
// @ts-ignore
|
||
onError && onError(str);
|
||
}
|
||
})();
|
||
|
||
var str = "";
|
||
for await (const line of runner.stdout) {
|
||
str += new TextDecoder().decode(line);
|
||
var any = false;
|
||
if (!/\[#!root\].*[0-9]\n/g.test(str)) continue;
|
||
|
||
for (let line of str.split("\n")) {
|
||
if (!line.includes("[#!root]")) continue;
|
||
reloadCounter++;
|
||
str = "";
|
||
|
||
if (reloadCounter === 3) {
|
||
runner.unref();
|
||
runner.kill();
|
||
break;
|
||
}
|
||
|
||
expect(line).toContain(`[#!root] Reloaded: ${reloadCounter}`);
|
||
any = true;
|
||
}
|
||
|
||
if (any) {
|
||
queue.shift()!();
|
||
await new Promise<void>((resolve, reject) => {
|
||
if (errors.length > 0) {
|
||
errors.length = 0;
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
onError = resolve;
|
||
});
|
||
|
||
queue.shift()!();
|
||
}
|
||
}
|
||
|
||
expect(reloadCounter).toBe(3);
|
||
} finally {
|
||
// @ts-ignore
|
||
runner?.unref?.();
|
||
// @ts-ignore
|
||
runner?.kill?.(9);
|
||
}
|
||
},
|
||
timeout,
|
||
);
|
||
|
||
it(
|
||
"should not hot reload when a random file is written",
|
||
async () => {
|
||
const root = hotRunnerRoot;
|
||
try {
|
||
var runner = spawn({
|
||
cmd: [bunExe(), "--hot", "run", root],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "pipe",
|
||
stderr: "inherit",
|
||
stdin: "ignore",
|
||
});
|
||
|
||
// First await the initial run's output — racing this against a fixed
|
||
// sleep meant a slow CI box's >200 ms subprocess startup lost the race
|
||
// and `reloadCounter` stayed 0.
|
||
const reader = runner.stdout.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = "";
|
||
while (!/\[#!root\] Reloaded: 1\n/.test(buf)) {
|
||
const { value, done } = await reader.read();
|
||
if (done) throw new Error("subprocess exited before initial run output");
|
||
buf += dec.decode(value);
|
||
}
|
||
// Now write+unlink an unrelated file and assert it does NOT trigger a
|
||
// second reload. Only the bounded "did anything else arrive?" check is
|
||
// time-based; the condition we care about (initial output) is awaited.
|
||
const code = readFileSync(root, "utf-8");
|
||
writeFileSync(root + ".another.yet.js", code);
|
||
unlinkSync(root + ".another.yet.js");
|
||
buf = "";
|
||
const sawSecond = await Promise.race([
|
||
Bun.sleep(200).then(() => false),
|
||
(async () => {
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) return false;
|
||
buf += dec.decode(value);
|
||
if (/\[#!root\] Reloaded: 2/.test(buf)) return true;
|
||
}
|
||
})(),
|
||
]);
|
||
reader.releaseLock();
|
||
runner.kill(0);
|
||
runner.unref();
|
||
|
||
expect(sawSecond).toBe(false);
|
||
} finally {
|
||
// @ts-ignore
|
||
runner?.unref?.();
|
||
// @ts-ignore
|
||
runner?.kill?.(9);
|
||
}
|
||
},
|
||
timeout,
|
||
);
|
||
|
||
it(
|
||
"should hot reload when a file is deleted and rewritten",
|
||
async () => {
|
||
try {
|
||
const root = hotRunnerRoot + ".tmp.js";
|
||
copyFileSync(hotRunnerRoot, root);
|
||
var runner = spawn({
|
||
cmd: [bunExe(), "--hot", "run", root],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "pipe",
|
||
stderr: "inherit",
|
||
stdin: "ignore",
|
||
});
|
||
|
||
var reloadCounter = 0;
|
||
|
||
async function onReload() {
|
||
const contents = readFileSync(root, "utf-8");
|
||
rmSync(root);
|
||
writeFileSync(root, contents);
|
||
}
|
||
|
||
var str = "";
|
||
for await (const line of runner.stdout) {
|
||
str += new TextDecoder().decode(line);
|
||
var any = false;
|
||
if (!/\[#!root\].*[0-9]\n/g.test(str)) continue;
|
||
|
||
for (let line of str.split("\n")) {
|
||
if (!line.includes("[#!root]")) continue;
|
||
reloadCounter++;
|
||
str = "";
|
||
|
||
if (reloadCounter === 3) {
|
||
runner.unref();
|
||
runner.kill();
|
||
break;
|
||
}
|
||
|
||
expect(line).toContain(`[#!root] Reloaded: ${reloadCounter}`);
|
||
any = true;
|
||
}
|
||
|
||
if (any) await onReload();
|
||
}
|
||
rmSync(root);
|
||
expect(reloadCounter).toBe(3);
|
||
} finally {
|
||
// @ts-ignore
|
||
runner?.unref?.();
|
||
// @ts-ignore
|
||
runner?.kill?.(9);
|
||
}
|
||
},
|
||
timeout,
|
||
);
|
||
|
||
it(
|
||
"should hot reload when a file is renamed() into place",
|
||
async () => {
|
||
const root = hotRunnerRoot + ".tmp.js";
|
||
copyFileSync(hotRunnerRoot, root);
|
||
try {
|
||
var runner = spawn({
|
||
cmd: [bunExe(), "--hot", "run", root],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "pipe",
|
||
stderr: "inherit",
|
||
stdin: "ignore",
|
||
});
|
||
|
||
var reloadCounter = 0;
|
||
|
||
async function onReload() {
|
||
const contents = readFileSync(root, "utf-8");
|
||
rmSync(root + ".tmpfile", { force: true });
|
||
await 1;
|
||
writeFileSync(root + ".tmpfile", contents);
|
||
await 1;
|
||
rmSync(root);
|
||
await 1;
|
||
renameSync(root + ".tmpfile", root);
|
||
await 1;
|
||
}
|
||
|
||
var str = "";
|
||
for await (const line of runner.stdout) {
|
||
str += new TextDecoder().decode(line);
|
||
var any = false;
|
||
if (!/\[#!root\].*[0-9]\n/g.test(str)) continue;
|
||
|
||
for (let line of str.split("\n")) {
|
||
if (!line.includes("[#!root]")) continue;
|
||
reloadCounter++;
|
||
str = "";
|
||
|
||
if (reloadCounter === 3) {
|
||
runner.unref();
|
||
runner.kill();
|
||
break;
|
||
}
|
||
|
||
expect(line).toContain(`[#!root] Reloaded: ${reloadCounter}`);
|
||
any = true;
|
||
}
|
||
|
||
if (any) await onReload();
|
||
}
|
||
rmSync(root);
|
||
expect(reloadCounter).toBe(3);
|
||
} finally {
|
||
// @ts-ignore
|
||
runner?.unref?.();
|
||
// @ts-ignore
|
||
runner?.kill?.(9);
|
||
}
|
||
},
|
||
timeout,
|
||
);
|
||
|
||
const comment_line = "//" + Buffer.alloc(2000, "B").toString() + "\n";
|
||
const comment_spam = Buffer.alloc(comment_line.length * 1000, comment_line).toString();
|
||
|
||
// writeFileSync of a ~2MB file is non-atomic (truncate + N×write); each write
|
||
// emits a watcher event so --hot can re-read mid-write (Linux: EBADF /
|
||
// "Unexpected ..." / :1:12 mis-remap; Windows: ReadDirectoryChangesW
|
||
// internal-buffer overflow → nbytes==0 → WindowsWatcher.next() ESHUTDOWN →
|
||
// watcher thread dies → child exits → reloadCounter<50). Atomic write+rename
|
||
// so the watched path only ever flips between complete versions.
|
||
function writeHotFileAtomicSync(path: string, content: string) {
|
||
const tmp = path + ".next";
|
||
writeFileSync(tmp, content);
|
||
// rmSync first on Windows so renameSync doesn't EPERM on the existing target.
|
||
// driveErrorReloadCycle skips the transient "Module not found"/ENOENT/EPERM
|
||
// that --hot can print when a reload lands in the gap between rm and rename.
|
||
if (process.platform === "win32") {
|
||
try {
|
||
rmSync(path);
|
||
} catch {}
|
||
}
|
||
renameSync(tmp, path);
|
||
}
|
||
|
||
it(
|
||
"should work with sourcemap generation",
|
||
async () => {
|
||
writeFileSync(
|
||
hotRunnerRoot,
|
||
`// source content
|
||
${comment_spam}
|
||
throw new Error('0');`,
|
||
);
|
||
await using runner = spawn({
|
||
cmd: [bunExe(), "--smol", "--hot", "run", hotRunnerRoot],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "ignore",
|
||
stderr: "pipe",
|
||
stdin: "ignore",
|
||
});
|
||
const reloadCounter = await driveErrorReloadCycle(runner, {
|
||
targetCount: 50,
|
||
onReload: counter => {
|
||
writeHotFileAtomicSync(
|
||
hotRunnerRoot,
|
||
`// source content
|
||
${comment_spam}
|
||
${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
|
||
);
|
||
},
|
||
verifyLine: (errorLine, nextLine, counter) => {
|
||
if (!nextLine) throw new Error(errorLine);
|
||
const match = nextLine.match(/\s*at.*?:1003:(\d+)$/);
|
||
if (!match) throw new Error("invalid string: " + nextLine);
|
||
const col = match[1];
|
||
expect(Number(col)).toBe(1 + "throw new ".length + counter * 2);
|
||
},
|
||
});
|
||
await runner.exited;
|
||
expect(reloadCounter).toBe(50);
|
||
},
|
||
timeout,
|
||
);
|
||
|
||
it(
|
||
"should not remap against a stale sourcemap after a partial-file reload",
|
||
async () => {
|
||
// Regression: the watcher can deliver a second reload Task between the
|
||
// moment a module's eval rejects and the moment that rejection is
|
||
// printed. The second reload re-transpiles and overwrites
|
||
// source_mappings[path] in place, so the still-unreported error gets
|
||
// remapped against the wrong map and transpiled coordinates leak
|
||
// through — or, since the new pending promise replaces the old one,
|
||
// the error is dropped entirely.
|
||
//
|
||
// To make the window deterministic the hot file truncates itself to a
|
||
// comment-only stub immediately before throwing, guaranteeing a fresh
|
||
// watcher event lands between reject and report.
|
||
const writeFull = (counter: number) =>
|
||
writeHotFileAtomicSync(
|
||
hotRunnerRoot,
|
||
`// source content
|
||
${comment_spam}require("fs").writeFileSync(__filename, "// stub ${counter}\\n");
|
||
${Buffer.alloc(counter * 2, " ").toString()}throw new Error('${counter}');`,
|
||
);
|
||
writeFull(0);
|
||
await using runner = spawn({
|
||
cmd: [bunExe(), "--smol", "--hot", "run", hotRunnerRoot],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "ignore",
|
||
stderr: "pipe",
|
||
stdin: "ignore",
|
||
});
|
||
const reloadCounter = await driveErrorReloadCycle(runner, {
|
||
targetCount: 20,
|
||
onReload: writeFull,
|
||
verifyLine: (errorLine, nextLine, counter) => {
|
||
if (!nextLine) throw new Error(errorLine);
|
||
const match = nextLine.match(/\s*at.*?:(\d+):(\d+)\)?$/);
|
||
if (!match) throw new Error("no :line:col in: " + JSON.stringify(nextLine));
|
||
if (match[1] !== "1003") throw new Error("expected :1003: but got: " + JSON.stringify(nextLine));
|
||
expect(Number(match[2])).toBe(1 + "throw new ".length + counter * 2);
|
||
},
|
||
});
|
||
await runner.exited;
|
||
expect(reloadCounter).toBe(20);
|
||
},
|
||
longTimeout,
|
||
);
|
||
|
||
it(
|
||
"should work with sourcemap loading",
|
||
async () => {
|
||
let bundleIn = join(cwd, "bundle_in.ts");
|
||
rmSync(hotRunnerRoot);
|
||
writeFileSync(
|
||
bundleIn,
|
||
`// source content
|
||
//
|
||
//
|
||
throw new Error('0');`,
|
||
);
|
||
await using bundler = spawn({
|
||
cmd: [bunExe(), "build", "--watch", bundleIn, "--target=bun", "--sourcemap=inline", "--outfile", hotRunnerRoot],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "ignore",
|
||
stderr: "ignore",
|
||
stdin: "ignore",
|
||
});
|
||
waitForFileToExist(hotRunnerRoot, 20);
|
||
await using runner = spawn({
|
||
cmd: [bunExe(), "--hot", "run", hotRunnerRoot],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "ignore",
|
||
stderr: "pipe",
|
||
stdin: "ignore",
|
||
});
|
||
let done = false;
|
||
const reloadCounter = await Promise.race([
|
||
driveErrorReloadCycle(runner, {
|
||
targetCount: 50,
|
||
onReload: counter => {
|
||
writeFileSync(
|
||
bundleIn,
|
||
`// source content
|
||
// etc etc
|
||
// etc etc
|
||
${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
|
||
);
|
||
},
|
||
verifyLine: (_errorLine, nextLine, counter) => {
|
||
if (!nextLine) throw new Error(_errorLine);
|
||
// Partial bundle read: --hot picked up the outfile before --watch finished
|
||
// writing the inline sourcemap trailer. Retry the write.
|
||
if (nextLine.includes("hot-runner-root.js")) return "retry";
|
||
expect(nextLine).toInclude("bundle_in.ts");
|
||
const match = nextLine.match(/\s*at.*?:4:(\d+)$/);
|
||
if (!match) throw new Error("invalid stack trace: " + nextLine);
|
||
const col = match[1];
|
||
expect(Number(col)).toBe(1 + "throw ".length + counter * 2);
|
||
},
|
||
}).finally(() => {
|
||
done = true;
|
||
}),
|
||
bundler.exited.then(code => {
|
||
if (!done) throw new Error(`bundler exited early with code ${code}`);
|
||
return -1; // Ignored — race already resolved
|
||
}),
|
||
]);
|
||
expect(reloadCounter).toBe(50);
|
||
bundler.kill();
|
||
},
|
||
timeout,
|
||
);
|
||
|
||
const long_comment = Buffer.alloc(400000, "BBBB").toString();
|
||
|
||
it(
|
||
"should work with sourcemap loading with large files",
|
||
async () => {
|
||
let bundleIn = join(cwd, "bundle_in.ts");
|
||
rmSync(hotRunnerRoot);
|
||
writeFileSync(
|
||
bundleIn,
|
||
`// ${long_comment}
|
||
//
|
||
console.error("RSS: %s", process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint() : process.memoryUsage.rss());
|
||
throw new Error('0');`,
|
||
);
|
||
await using bundler = spawn({
|
||
cmd: [
|
||
//
|
||
bunExe(),
|
||
"build",
|
||
"--watch",
|
||
bundleIn,
|
||
"--target=bun",
|
||
"--sourcemap=inline",
|
||
"--outfile",
|
||
hotRunnerRoot,
|
||
],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "ignore",
|
||
stderr: "ignore",
|
||
stdin: "ignore",
|
||
});
|
||
waitForFileToExist(hotRunnerRoot, 20);
|
||
await using runner = spawn({
|
||
cmd: [
|
||
//
|
||
bunExe(),
|
||
"--hot",
|
||
"run",
|
||
hotRunnerRoot,
|
||
],
|
||
env: bunEnv,
|
||
cwd,
|
||
stdout: "ignore",
|
||
stderr: "pipe",
|
||
stdin: "ignore",
|
||
});
|
||
let done2 = false;
|
||
const reloadCounter = await Promise.race([
|
||
driveErrorReloadCycle(runner, {
|
||
targetCount: 50,
|
||
onReload: counter => {
|
||
writeHotFileAtomicSync(
|
||
bundleIn,
|
||
`// ${long_comment}
|
||
console.error("RSS: %s", process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint() : process.memoryUsage.rss());
|
||
//
|
||
${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
|
||
);
|
||
},
|
||
verifyLine: (_errorLine, nextLine, counter) => {
|
||
if (!nextLine) throw new Error(_errorLine);
|
||
// Partial bundle read: --hot picked up the outfile before --watch finished
|
||
// writing the inline sourcemap trailer. Retry the write.
|
||
if (nextLine.includes("hot-runner-root.js")) return "retry";
|
||
expect(nextLine).toInclude("bundle_in.ts");
|
||
const match = nextLine.match(/\s*at.*?:4:(\d+)$/);
|
||
if (!match) throw new Error("invalid stack trace: " + nextLine);
|
||
const col = match[1];
|
||
expect(Number(col)).toBe(1 + "throw ".length + counter * 2);
|
||
},
|
||
}).finally(() => {
|
||
done2 = true;
|
||
}),
|
||
bundler.exited.then(code => {
|
||
if (!done2) throw new Error(`bundler exited early with code ${code}`);
|
||
return -1; // Ignored — race already resolved
|
||
}),
|
||
]);
|
||
expect(reloadCounter).toBe(50);
|
||
bundler.kill();
|
||
await runner.exited;
|
||
// TODO: bun has a memory leak when --hot is used on very large files
|
||
},
|
||
longTimeout,
|
||
);
|