diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index 8f2ee1f522c2..20feab3ce733 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1463,17 +1463,31 @@ impl RefDataValue { } pub(crate) fn entry<'a>(&self, buntest: &'a mut BunTest) -> Option<&'a mut ExecutionEntry> { + let sequence = self.running_sequence(buntest)?; + // SAFETY: `active_entry` is a valid intrusive node while the sequence is live. + unsafe { sequence.active_entry.map(|p| &mut *p.as_ptr()) } + } + + /// The entry a snapshot is named after: the test, also while its beforeEach/afterEach run (jest's currentTestName). + pub(crate) fn snapshot_entry<'a>(&self, buntest: &'a mut BunTest) -> Option<&'a ExecutionEntry> { + let sequence = self.running_sequence(buntest)?; + // beforeAll/afterAll sequences have no test, so their snapshots are named after the hook entry itself. + let entry = sequence.test_entry.or(sequence.active_entry)?; + // SAFETY: `test_entry`/`active_entry` are valid intrusive nodes while the sequence is live. + Some(unsafe { &*entry.as_ptr() }) + } + + /// The sequence this state was captured in, while it is still the one running (unknown inside a concurrent group). + fn running_sequence<'a>(&self, buntest: &'a mut BunTest) -> Option<&'a Execution::ExecutionSequence> { if !matches!(self, RefDataValue::Execution { .. }) { return None; } if buntest.phase != Phase::Execution { return None; } - let (the_sequence, _) = buntest.execution.get_current_and_valid_execution_sequence(self)?; - // SAFETY: `the_sequence` is a NonNull into `execution.sequences`; deref - // at point-of-use only. `active_entry` is a valid intrusive node while - // the sequence is live. - unsafe { the_sequence.as_ref().active_entry.map(|p| &mut *p.as_ptr()) } + let (sequence, _) = buntest.execution.get_current_and_valid_execution_sequence(self)?; + // SAFETY: `sequence` points into `buntest.execution.sequences`, which stays borrowed for `'a`. + Some(unsafe { &*sequence.as_ptr() }) } } diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index e7b3471e39c0..9da62b3398dc 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -625,7 +625,7 @@ impl Expect { let buntest = buntest_strong.get(); let execution_entry = parent .phase - .entry(buntest) + .snapshot_entry(buntest) .ok_or(crate::Error::SnapshotInConcurrentGroup)?; let test_name: &[u8] = execution_entry.base.name.as_deref().unwrap_or(b"(unnamed)"); diff --git a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts index e670e343921f..5a023fa7124d 100644 --- a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts +++ b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, test } from "bun:test"; +import { writeFileSync } from "fs"; +import { bunEnv, bunExe, tempDir } from "harness"; test("it will create a snapshot file if it doesn't exist", () => { expect({ a: { b: { c: false } }, c: 2, jkfje: 99238 }).toMatchSnapshot({ a: { b: { c: expect.any(Boolean) } } }); @@ -62,3 +64,123 @@ describe("toMatchSnapshot errors", () => { }).toThrow(); }); }); + +describe("snapshots taken in hooks", () => { + // Runs /hooks.test.ts and returns the `key = value` entries of the .snap file it wrote, in file order. + async function runHooksFile(dir: string): Promise<{ exitCode: number; stderr: string; entries: string[] }> { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./hooks.test.ts"], + env: { ...bunEnv, CI: "false" }, + cwd: dir, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const snap = await Bun.file(dir + "/__snapshots__/hooks.test.ts.snap").text(); + const entries = [...snap.matchAll(/^exports\[`(.*)`\] = `(.*)`;$/gm)].map(m => `${m[1]} = ${m[2]}`); + return { exitCode, stderr, entries }; + } + + test.concurrent("beforeEach/afterEach/onTestFinished snapshots are named after the running test", async () => { + using dir = tempDir("snapshot-hook-names", { + "hooks.test.ts": /*js*/ ` + import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, onTestFinished, test } from "bun:test"; + beforeEach(() => expect("top").toMatchSnapshot("top beforeEach")); + describe("outer", () => { + beforeEach(() => expect("outer").toMatchSnapshot("outer beforeEach")); + describe("inner", () => { + beforeAll(() => expect("ba").toMatchSnapshot("beforeAll")); + beforeEach(() => expect("inner").toMatchSnapshot("inner beforeEach")); + beforeEach(() => expect("inline").toMatchInlineSnapshot('"inline"')); + afterEach(() => expect("ae").toMatchSnapshot("afterEach")); + afterAll(() => expect("aa").toMatchSnapshot("afterAll")); + test("t1", () => { + afterEach(() => expect("ae in test").toMatchSnapshot("afterEach in test")); + onTestFinished(() => expect("otf").toMatchSnapshot("onTestFinished")); + expect("body").toMatchSnapshot("body"); + expect("body").toMatchSnapshot(); + }); + test("t2", () => expect("body").toMatchSnapshot("body")); + }); + }); + `, + }); + const { exitCode, stderr, entries } = await runHooksFile(String(dir)); + expect(entries).toEqual([ + // beforeAll/afterAll do not run for a test, so they keep the hook's own (unnamed) key + 'outer inner (unnamed): beforeAll 1 = "ba"', + 'outer inner t1: top beforeEach 1 = "top"', + 'outer inner t1: outer beforeEach 1 = "outer"', + 'outer inner t1: inner beforeEach 1 = "inner"', + 'outer inner t1: body 1 = "body"', + // the inline snapshot in beforeEach took number 1 of t1's unhinted counter, as in jest + 'outer inner t1 2 = "body"', + 'outer inner t1: afterEach in test 1 = "ae in test"', + 'outer inner t1: afterEach 1 = "ae"', + 'outer inner t1: onTestFinished 1 = "otf"', + 'outer inner t2: top beforeEach 1 = "top"', + 'outer inner t2: outer beforeEach 1 = "outer"', + 'outer inner t2: inner beforeEach 1 = "inner"', + 'outer inner t2: body 1 = "body"', + 'outer inner t2: afterEach 1 = "ae"', + 'outer inner (unnamed): afterAll 1 = "aa"', + ]); + expect(stderr).toContain(" 2 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); + + test.concurrent("per-test hooks share the test's counter, beforeAll/afterAll share the describe's", async () => { + using dir = tempDir("snapshot-hook-counters", { + "hooks.test.ts": /*js*/ ` + import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; + describe("d", () => { + beforeAll(() => expect("ba").toMatchSnapshot("setup")); + beforeEach(() => expect("be").toMatchSnapshot("setup")); + afterEach(() => expect("ae").toMatchSnapshot("setup")); + afterAll(() => expect("aa").toMatchSnapshot("setup")); + test("t1", () => expect("body").toMatchSnapshot("setup")); + }); + `, + }); + const { exitCode, stderr, entries } = await runHooksFile(String(dir)); + expect(entries).toEqual([ + 'd (unnamed): setup 1 = "ba"', + 'd t1: setup 1 = "be"', + 'd t1: setup 2 = "body"', + 'd t1: setup 3 = "ae"', + 'd (unnamed): setup 2 = "aa"', + ]); + expect(stderr).toContain(" 1 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); + + test.concurrent("adding a test does not invalidate the hook snapshots of the other tests", async () => { + const source = (extraTest: string) => /*js*/ ` + import { afterEach, describe, expect, test } from "bun:test"; + let value; + describe("d", () => { + afterEach(() => expect(value).toMatchSnapshot("afterEach")); + ${extraTest} + test("t1", () => { value = "from t1"; }); + test("t2", () => { value = "from t2"; }); + }); + `; + using dir = tempDir("snapshot-hook-stability", { "hooks.test.ts": source("") }); + const first = await runHooksFile(String(dir)); + expect(first.entries).toEqual(['d t1: afterEach 1 = "from t1"', 'd t2: afterEach 1 = "from t2"']); + expect(first.exitCode).toBe(0); + + writeFileSync(String(dir) + "/hooks.test.ts", source(`test("t0", () => { value = "from t0"; });`)); + const second = await runHooksFile(String(dir)); + expect(second.stderr).toContain(" 3 pass"); + expect(second.stderr).toContain(" 0 fail"); + expect(second.entries).toEqual([ + 'd t1: afterEach 1 = "from t1"', + 'd t2: afterEach 1 = "from t2"', + 'd t0: afterEach 1 = "from t0"', + ]); + expect(second.exitCode).toBe(0); + }); +});