Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/runtime/cli/test/parallel/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -701,8 +701,7 @@ fn worker_flush_aggregates(
ctx: &Command::ContextData,
cmds: &mut WorkerCommands,
) {
// Snapshots flush lazily when the next file opens its snapshot file; the
// last file each worker ran has no successor to trigger that.
// Inline snapshots are written once per process; each `.snap` was written as its file finished.
if let Some(runner) = crate::test_runner::jest::Jest::runner() {
let _ = runner.snapshots.write_inline_snapshots().unwrap_or(false);
let _ = runner.snapshots.write_snapshot_file();
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,7 @@ impl CommandLineReporter {
if this.jest.bail == 1 { "" } else { "s" }
);
Output::flush();
this.write_snapshots_before_bail();
this.write_junit_report_if_needed();
this.write_timings_if_needed();
Global::exit(1);
Expand Down Expand Up @@ -1552,6 +1553,15 @@ impl CommandLineReporter {
Output::print_start_end(bun::start_time(), bun::time::nano_timestamp());
}

pub(crate) fn write_snapshots_before_bail(&mut self) {
if let Err(err) = self.jest.snapshots.write_inline_snapshots() {
Output::err(err, "Failed to write inline snapshots", ());
}
if let Err(err) = self.jest.snapshots.write_snapshot_file() {
Output::err(err, "Failed to write snapshot file", ());
}
}

/// Like the JUnit report, called before every exit path (including bail) so measured durations aren't lost.
pub(crate) fn write_timings_if_needed(&mut self) {
if self.jest.test_options.update_timings
Expand Down Expand Up @@ -3300,6 +3310,7 @@ impl TestCommand {
reporter.jest.bail,
if reporter.jest.bail == 1 { "" } else { "s" }
);
reporter.write_snapshots_before_bail();
reporter.write_junit_report_if_needed();
reporter.write_timings_if_needed();

Expand Down Expand Up @@ -3406,6 +3417,11 @@ impl TestCommand {
}
junit.current_file = Box::default();
}
// Per file, so this file's snapshots are on disk however a later file ends.
if let Err(err) = reporter.jest.snapshots.write_snapshot_file() {
Output::err(err.name(), "Failed to write snapshot file", ());
return Err(err);
}
Comment on lines +3420 to +3424

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The per-file write_snapshot_file() at line 3421 both prints via Output::err and return Err(err); every caller of TestCommand::run routes that Err to handle_top_level_test_error_before_javascript_start (-> !, Global::exit(1)), so a pwrite/ftruncate failure after a file's tests have run hard-exits without JUnit/timings/summary or remaining files, and a --parallel worker exits before sending FileDone (which the coordinator reads as a crash — see the comment in build_worker_argv). This is inconsistent with write_snapshots_before_bail in the same PR, which prints the identical error and continues; drop the return Err(err) to match.

Extended reasoning...

What the bug is

The new per-file snapshot flush at test_command.rs:3421-3424 handles a write_snapshot_file() failure by printing it (Output::err(err.name(), "Failed to write snapshot file", ())) and propagating it (return Err(err)). But TestCommand::run's callers do not handle a returned Err gracefully:

  • run_all_tests at test_command.rs:3125 and test_command.rs:3154
  • WorkerLoop::begin at parallel/runner.rs:571

All three route it to handle_top_level_test_error_before_javascript_start (test_command.rs:3429-3436), which is -> ! and calls Global::exit(1) unconditionally. So an I/O failure writing the .snap — after the file's tests have already run to completion — aborts the whole process.

The code path

  1. A test file finishes; TestCommand::run reaches line 3421 and calls reporter.jest.snapshots.write_snapshot_file().
  2. pwrite_all or ftruncate fails (e.g. ENOSPC, EIO, filesystem gone read-only mid-run) → Err(FailedToWriteSnapshotFile).
  3. Line 3422 prints "Failed to write snapshot file"; line 3423 returns Err.
  4. The caller passes it to handle_top_level_test_error_before_javascript_start, which Global::exit(1) — before write_junit_report_if_needed / write_timings_if_needed / the summary, and before any remaining test files run. In a --parallel worker the process exits before the FileDone frame is sent, which build_worker_argv's own comment says the coordinator "would misread as a crash".
  5. In debug builds the error is also double-reported: once by Output::err at 3422, again by debug_warn! at 3432.

Why existing code doesn't prevent it

The handler is named ..._before_javascript_start and was designed for module-resolution-style failures that happen before a file's JS runs. This new Err is returned after the file's tests have all executed, so it lands in a handler semantically not meant for it. There is no other exit-time flush for JUnit/timings on this path — contrast the two bail exits at lines 1527-1529 and 3313-3315, which explicitly call write_junit_report_if_needed / write_timings_if_needed before Global::exit.

Why it's inconsistent within this PR

write_snapshots_before_bail (test_command.rs:1556-1563), added in the same PR, handles the identical error from the identical function with print-and-continue:

if let Err(err) = self.jest.snapshots.write_snapshot_file() {
    Output::err(err, "Failed to write snapshot file", ());
}

— no propagation, so JUnit/timings still get written on the very next lines. The per-file site should do the same.

It is also a behavior change vs. main: before this PR the equivalent write happened lazily inside get_snapshot_file() on the next file's first toMatchSnapshot(), where an error surfaced through get_or_put as a per-test JS exception (one assertion fails, the run continues). This PR upgrades the same underlying I/O failure to a whole-process abort.

Impact

Rare in practice — pwrite_all/ftruncate failing on an fd that was successfully opened O_RDWR moments earlier requires disk-full/quota/EIO/EROFS mid-run. Nothing breaks in normal operation, hence nit. But when it does trigger: the JUnit report and --timings file are lost (both of which the bail paths in this PR go out of their way to preserve), remaining test files are silently skipped, and under --parallel the coordinator reports the file as a worker crash rather than a snapshot-write failure.

Fix

Drop the return Err(err); at line 3423 to match write_snapshots_before_bail:

// Per file, so this file's snapshots are on disk however a later file ends.
if let Err(err) = reporter.jest.snapshots.write_snapshot_file() {
    Output::err(err.name(), "Failed to write snapshot file", ());
}
Ok(())

If a nonzero exit is desired for this case, bump a failure counter on the reporter/summary instead so the run finishes and JUnit/timings are still written.

Ok(())
}
}
Expand Down
14 changes: 5 additions & 9 deletions src/runtime/test_runner/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,10 @@ impl Snapshots {

pub(crate) fn write_snapshot_file(&mut self) -> Result<(), Error> {
if let Some(file) = self._current_file.take() {
// The open in `get_snapshot_file` does not truncate, so drop the old tail here.
file.file
.write_all(&self.file_buf)
.pwrite_all(&self.file_buf, 0)
.and_then(|()| bun_sys::ftruncate(file.file.handle, self.file_buf.len() as i64))
.map_err(|_| crate::Error::FailedToWriteSnapshotFile)?;
let _ = file.file.close();
self.file_buf.clear();
Expand Down Expand Up @@ -886,10 +888,8 @@ impl Snapshots {
// SAFETY: buf[pos] == 0 written above
let snapshot_file_path = ZStr::from_buf(&buf[..], pos);

let mut flags: i32 = bun_sys::O::CREAT | bun_sys::O::RDWR;
if self.update_snapshots {
flags |= bun_sys::O::TRUNC;
}
// Never O_TRUNC: the old contents stay on disk until `write_snapshot_file` replaces them.
let flags: i32 = bun_sys::O::CREAT | bun_sys::O::RDWR;
let fd = match bun_sys::open(snapshot_file_path, flags, 0o644) {
bun_sys::Result::Ok(fd) => fd,
bun_sys::Result::Err(err) => return Ok(bun_sys::Result::Err(err)),
Expand All @@ -909,10 +909,6 @@ impl Snapshots {
} else {
let mut tmp = vec![0u8; length];
let _ = file.file.pread_all(&mut tmp, 0).map_err(Error::from)?;
#[cfg(windows)]
{
file.file.seek_to(0).map_err(Error::from)?;
}
self.file_buf.extend_from_slice(&tmp);
}
}
Expand Down
143 changes: 141 additions & 2 deletions test/js/bun/test/snapshot-tests/new-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import fs from "fs";
import { bunEnv, bunExe, tmpdirSync } from "harness";
import { bunEnv, bunExe, tempDir, tmpdirSync } from "harness";

test("it will create a snapshot file and directory if they don't exist", () => {
const tempDir = tmpdirSync();
Expand Down Expand Up @@ -28,3 +28,142 @@ test("it will create a snapshot file and directory if they don't exist", () => {
expect(exitCode2).toBe(0);
expect(fs.existsSync(tempDir + "/__snapshots__/new-snapshot.test.ts.snap")).toBe(true);
});

const header = "// Bun Snapshot v1, https://bun.sh/docs/test/snapshots\n";
const snapEntry = (value: string) => "\nexports[`snap 1`] = `" + value + "`;\n";
const writtenSnap = header + snapEntry('"value"');
// Longer than what a run of the fixtures below writes, so a rewrite has to shrink the file.
const staleSnap = header + snapEntry('"stale"') + '\nexports[`gone 1`] = `"stale"`;\n';

const snapTest = /*js*/ `
import { test, expect } from "bun:test";
test("snap", () => { expect("value").toMatchSnapshot(); });
`;
const snapThenFailingTest = /*js*/ `
import { test, expect } from "bun:test";
test("snap", () => { expect("value").toMatchSnapshot(); });
test("fails", () => { expect(1).toBe(2); });
`;
const snapThenExitTest = /*js*/ `
import { test, expect } from "bun:test";
test("snap", () => { expect("value").toMatchSnapshot(); process.exit(0); });
`;
const failingTest = /*js*/ `
import { test, expect } from "bun:test";
test("fails", () => { expect(1).toBe(2); });
`;
const inlineTest = (snapshot: string) => /*js*/ `
import { test, expect } from "bun:test";
test("inline", () => { expect("value").toMatchInlineSnapshot(${snapshot}); });
`;
const inlineThenFailingTest = (snapshot: string) => /*js*/ `
import { test, expect } from "bun:test";
test("inline", () => { expect("value").toMatchInlineSnapshot(${snapshot}); });
test("fails", () => { expect(1).toBe(2); });
`;

async function runBunTest(dir: string, args: string[], env: Record<string, string> = {}) {
await using proc = Bun.spawn({
cmd: [bunExe(), "test", ...args],
cwd: String(dir),
env: { ...bunEnv, CI: "false", ...env },
stdout: "pipe",
stderr: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stderr, exitCode };
}

describe("writing the .snap file", () => {
test.concurrent("--update-snapshots keeps the existing file until the new contents are written", async () => {
using dir = tempDir("snapshot-exit-before-write", {
"a.test.ts": snapThenExitTest,
"__snapshots__": { "a.test.ts.snap": staleSnap },
});
const { exitCode } = await runBunTest(dir, ["--update-snapshots", "./a.test.ts"]);
expect(fs.readFileSync(`${dir}/__snapshots__/a.test.ts.snap`, "utf8")).toBe(staleSnap);
expect(exitCode).toBe(0);
});

test.concurrent("--update-snapshots removes what the existing file had beyond the new contents", async () => {
using dir = tempDir("snapshot-update-shrinks", {
"a.test.ts": snapTest,
"__snapshots__": { "a.test.ts.snap": staleSnap },
});
const { stderr, exitCode } = await runBunTest(dir, ["--update-snapshots", "./a.test.ts"]);
expect(fs.readFileSync(`${dir}/__snapshots__/a.test.ts.snap`, "utf8")).toBe(writtenSnap);
expect(stderr).toContain("1 pass");
expect(exitCode).toBe(0);
});

test.concurrent("a file's snapshots are written when it finishes, not when the run ends", async () => {
using dir = tempDir("snapshot-written-per-file", {
"a.test.ts": snapTest,
"b.test.ts": `process.exit(1);`,
});
const { exitCode } = await runBunTest(dir, ["./a.test.ts", "./b.test.ts"]);
expect(fs.readFileSync(`${dir}/__snapshots__/a.test.ts.snap`, "utf8")).toBe(writtenSnap);
expect(exitCode).toBe(1);
});
});

describe("--bail", () => {
async function runUntilBail(dir: string, ...args: string[]) {
const { stderr, exitCode } = await runBunTest(dir, ["--bail", ...args], {
// Bailing on a failed test exits the process without tearing the VM down, so the
// test runner's JSC-owned objects show up as leaks. These tests cover what reaches
// disk before that exit, so keep ASAN on but leave LSAN off for the child.
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"),
});
expect(stderr).toContain("Bailed out after 1 failure");
expect(exitCode).toBe(1);
}

test.concurrent("writes the snapshots recorded in the file that bailed", async () => {
using dir = tempDir("snapshot-bail", { "a.test.ts": snapThenFailingTest });
await runUntilBail(dir, "./a.test.ts");
expect(fs.readFileSync(`${dir}/__snapshots__/a.test.ts.snap`, "utf8")).toBe(writtenSnap);
});

test.concurrent("--update-snapshots replaces the existing file with the snapshots recorded so far", async () => {
using dir = tempDir("snapshot-bail-update", {
"a.test.ts": snapThenFailingTest,
"__snapshots__": { "a.test.ts.snap": staleSnap },
});
await runUntilBail(dir, "--update-snapshots", "./a.test.ts");
expect(fs.readFileSync(`${dir}/__snapshots__/a.test.ts.snap`, "utf8")).toBe(writtenSnap);
});

test.concurrent("writes the pending inline snapshots of the file that bailed", async () => {
using dir = tempDir("snapshot-bail-inline", { "a.test.ts": inlineThenFailingTest("") });
await runUntilBail(dir, "./a.test.ts");
expect(fs.readFileSync(`${dir}/a.test.ts`, "utf8")).toBe(inlineThenFailingTest('`"value"`'));
});

test.concurrent("keeps the snapshots of an earlier file when a later file's test fails", async () => {
using dir = tempDir("snapshot-bail-earlier-file", { "a.test.ts": snapTest, "b.test.ts": failingTest });
await runUntilBail(dir, "./a.test.ts", "./b.test.ts");
expect(fs.readFileSync(`${dir}/__snapshots__/a.test.ts.snap`, "utf8")).toBe(writtenSnap);
});

test.concurrent("keeps the snapshots of an earlier file when a later file fails to load", async () => {
using dir = tempDir("snapshot-bail-load-error", {
"a.test.ts": snapTest,
"b.test.ts": `throw new Error("failed to load");`,
});
await runUntilBail(dir, "./a.test.ts", "./b.test.ts");
expect(fs.readFileSync(`${dir}/__snapshots__/a.test.ts.snap`, "utf8")).toBe(writtenSnap);
});

test.concurrent(
"writes the pending inline snapshots of an earlier file when a later file fails to load",
async () => {
using dir = tempDir("snapshot-bail-load-error-inline", {
"a.test.ts": inlineTest(""),
"b.test.ts": `throw new Error("failed to load");`,
});
await runUntilBail(dir, "./a.test.ts", "./b.test.ts");
expect(fs.readFileSync(`${dir}/a.test.ts`, "utf8")).toBe(inlineTest('`"value"`'));
},
);
});
Loading