diff --git a/docs/test/snapshots.mdx b/docs/test/snapshots.mdx index 6def3470dde7..6c561a9baad8 100644 --- a/docs/test/snapshots.mdx +++ b/docs/test/snapshots.mdx @@ -290,6 +290,10 @@ Bun reads and writes each snapshot file in the format of the runner that created Bun's format and `pretty-format` print most values the same way. They differ on functions (`[Function: name]` in Bun's format, `[Function]` in Jest's and Vitest's), classes, mock functions, regular expressions, symbol keys, React elements and a few others. +In a file in Jest's format, the stored text also has a `Cause:` line for each error in the `cause` chain, as in Jest 30. Bun stores at most 100 of these lines, and it stops when a cause repeats. Bun has one `Error` for your tests and for the runtime, and Jest on Node.js has two. So a `Proxy` of an error that the runtime created, such as a file system error, gets a `Cause:` line in Bun and none in Jest. + +Jest 29 and earlier versions of Bun stored the message alone. If such an entry fails because of a `Cause:` line, run `bun test --update-snapshots` to rewrite it. + A new snapshot file uses Vitest's format if `test` is imported from `"vitest"`, and Bun's format otherwise. Inline snapshots don't record which runner wrote them, so Bun accepts a match in any runner's format. It writes and updates them in the format of the runner that `test` is imported from. diff --git a/src/jsc/bindings/SnapshotFormat.cpp b/src/jsc/bindings/SnapshotFormat.cpp index d4caf957dc14..5077332e9d19 100644 --- a/src/jsc/bindings/SnapshotFormat.cpp +++ b/src/jsc/bindings/SnapshotFormat.cpp @@ -86,29 +86,41 @@ static size_t tagOf(JSGlobalObject* globalObject, JSValue value) return tag; } -extern "C" SnapshotKind SnapshotFormat__kindOf(JSGlobalObject* globalObject, EncodedJSValue encodedObject) +// `value instanceof Error` without Error[Symbol.hasInstance]. Bun has one Error.prototype where Jest on Node has two, so a Proxy of an error that the runtime made is an Error here only. +static ALWAYS_INLINE bool inheritsErrorPrototype(JSGlobalObject* globalObject, ThrowScope& scope, JSValue value) { - auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); - JSValue object = JSValue::decode(encodedObject); - size_t tag = tagOf(globalObject, object); - RETURN_IF_EXCEPTION(scope, SnapshotKind::Object); - if (tag > 0 && tag < std::size(tags)) - return tags[tag].second; - // `object instanceof Error`, with V8's RangeError when getPrototypeOf traps never end the chain. + // V8's RangeError when getPrototypeOf traps never end the chain. constexpr unsigned maxProxies = 1000; unsigned proxies = 0; - for (JSObject* current = object.getObject(); current;) { + for (JSObject* current = value.getObject(); current;) { if (current->type() == ProxyObjectType && ++proxies > maxProxies) { throwStackOverflowError(globalObject, scope); - return SnapshotKind::Object; + return false; } JSValue prototype = current->getPrototype(globalObject); - RETURN_IF_EXCEPTION(scope, SnapshotKind::Object); + RETURN_IF_EXCEPTION(scope, false); if (prototype == globalObject->errorPrototype()) - return SnapshotKind::Error; + return true; current = prototype.getObject(); } - return SnapshotKind::Object; + return false; +} + +extern "C" SnapshotKind SnapshotFormat__kindOf(JSGlobalObject* globalObject, EncodedJSValue encodedObject) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSValue object = JSValue::decode(encodedObject); + size_t tag = tagOf(globalObject, object); + RETURN_IF_EXCEPTION(scope, SnapshotKind::Object); + if (tag > 0 && tag < std::size(tags)) + return tags[tag].second; + return inheritsErrorPrototype(globalObject, scope, object) ? SnapshotKind::Error : SnapshotKind::Object; +} + +extern "C" bool SnapshotFormat__inheritsErrorPrototype(JSGlobalObject* globalObject, EncodedJSValue encodedValue) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + return inheritsErrorPrototype(globalObject, scope, JSValue::decode(encodedValue)); } // Vitest's `inspect()` truncates Object, Array, Map and Set by dropping items. diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index f24aacfd29cb..0df698209738 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -994,6 +994,7 @@ impl Expect { } /// Mirrors `_toThrowErrorMatchingSnapshot` in jest-snapshot. + /// Its `cause instanceof Error` is the snapshot printer's, `inherits_error_prototype`. #[cold] #[optimize(size)] fn message_with_causes(global_this: &JSGlobalObject, thrown: JSValue) -> JsResult { @@ -1010,9 +1011,9 @@ impl Expect { let mut seen = [thrown; MAX_CAUSES + 1]; let mut count = 1; while !seen[..count].contains(&cause) && count <= MAX_CAUSES { - let line = if cause.is_any_error() { + let line = if cause.is_any_error() || (cause.is_object() && super::pretty_format::inherits_error_prototype(global_this, cause)?) { cause.get(global_this, "message")?.unwrap_or(JSValue::UNDEFINED) - } else if cause.is_string() { + } else if cause.is_string_literal() { cause } else { break; diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 7a065a67c7e7..ce7dad8c722c 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -3134,6 +3134,14 @@ pub(crate) fn to_number(global: &JSGlobalObject, value: JSValue) -> JsResult JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__inheritsErrorPrototype(global: &JSGlobalObject, value: JSValue) -> bool; + } + jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__inheritsErrorPrototype(global, value)) +} + /// `Array.isArray(value)`, which sees through a Proxy. fn is_array(global: &JSGlobalObject, value: JSValue) -> JsResult { unsafe extern "C" { diff --git a/test/js/bun/test/snapshot-tests/formats/jest/__snapshots__/errors.fixture.js.snap b/test/js/bun/test/snapshot-tests/formats/jest/__snapshots__/errors.fixture.js.snap index c1443602a1ef..099126a94a0f 100644 --- a/test/js/bun/test/snapshot-tests/formats/jest/__snapshots__/errors.fixture.js.snap +++ b/test/js/bun/test/snapshot-tests/formats/jest/__snapshots__/errors.fixture.js.snap @@ -45,4 +45,90 @@ exports[`toThrowErrorMatchingSnapshot with a cause 1`] = ` Cause: inner" `; +exports[`toThrowErrorMatchingSnapshot with a cause that is a DOMException 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a Proxy of an Error 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a Proxy that is its own prototype for 500 calls, then answers Error.prototype 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a Proxy whose getPrototypeOf trap answers Error.prototype 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a Proxy whose getPrototypeOf trap throws 1`] = `"the matcher that threw took no entry"`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a String object 1`] = `"outer"`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a String object that inherits Error.prototype 1`] = ` +"outer +Cause: " +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a String object with a cause 1`] = `"outer"`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a function that inherits Error.prototype 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a plain object 1`] = `"outer"`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a plain object with a cause 1`] = `"outer"`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is a string 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is an Error of another realm 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is an Error without a prototype 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is an instance of an ES5 class that inherits from Error 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is an object tagged Error 1`] = `"outer"`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is an object that inherits Error.prototype 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is an object that inherits Error.prototype, through .rejects and .resolves 1`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is an object that inherits Error.prototype, through .rejects and .resolves 2`] = ` +"outer +Cause: inner" +`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is an object that inherits the Error.prototype of another realm 1`] = `"outer"`; + +exports[`toThrowErrorMatchingSnapshot with a cause that is the first of a chain 1`] = ` +"outer +Cause: second +Cause: third +Cause: fourth" +`; + exports[`toThrowErrorMatchingSnapshot with a hint: the hint 1`] = `"hinted"`; diff --git a/test/js/bun/test/snapshot-tests/formats/jest/errors.fixture.js b/test/js/bun/test/snapshot-tests/formats/jest/errors.fixture.js index 626db556eeda..e3651613278e 100644 --- a/test/js/bun/test/snapshot-tests/formats/jest/errors.fixture.js +++ b/test/js/bun/test/snapshot-tests/formats/jest/errors.fixture.js @@ -61,6 +61,82 @@ second throw new Error("outer", { cause: new Error("inner") }); }).toThrowErrorMatchingSnapshot(); }); + describe("with a cause that is", () => { + const inherits = (prototype, members) => Object.assign(Object.create(prototype), members); + const inAnotherRealm = code => require("node:vm").runInNewContext(code); + // What util.inherits(LegacyError, Error) makes: an instance is a plain object, not an Error. + function LegacyError(message) { + this.message = message; + } + Object.setPrototypeOf(LegacyError.prototype, Error.prototype); + const causes = { + "a string": () => "inner", + "a String object": () => new String("inner"), + "a String object with a cause": () => Object.assign(new String("inner"), { cause: new Error("never") }), + "a String object that inherits Error.prototype": () => Object.setPrototypeOf(new String("inner"), Error.prototype), + "a plain object": () => ({ message: "inner" }), + "a plain object with a cause": () => ({ message: "inner", cause: new Error("never") }), + "an object tagged Error": () => ({ [Symbol.toStringTag]: "Error", message: "inner" }), + // Jest prints this line when Node 22 or later runs it. To Node 20 and to Bun, a DOMException is no native error. + "a DOMException": () => new DOMException("inner", "NotFoundError"), + "an object that inherits Error.prototype": () => inherits(Error.prototype, { message: "inner" }), + "an instance of an ES5 class that inherits from Error": () => new LegacyError("inner"), + "a function that inherits Error.prototype": () => + Object.setPrototypeOf(Object.assign(function () {}, { message: "inner" }), Error.prototype), + "a Proxy of an Error": () => new Proxy(new Error("inner"), {}), + "a Proxy whose getPrototypeOf trap answers Error.prototype": () => + new Proxy({ message: "inner" }, { getPrototypeOf: () => Error.prototype }), + "a Proxy that is its own prototype for 500 calls, then answers Error.prototype": (calls = 0) => { + const proxy = new Proxy({ message: "inner" }, { getPrototypeOf: () => (calls++ < 500 ? proxy : Error.prototype) }); + return proxy; + }, + "an Error without a prototype": () => Object.setPrototypeOf(new Error("inner"), null), + "an Error of another realm": () => inAnotherRealm(`new Error("inner")`), + "an object that inherits the Error.prototype of another realm": () => + inAnotherRealm(`Object.assign(Object.create(Error.prototype), { message: "inner" })`), + "the first of a chain": () => + inherits(Error.prototype, { + message: "second", + cause: Object.assign(new DOMException("third"), { cause: "fourth" }), + }), + }; + for (const [name, cause] of Object.entries(causes)) { + test(name, () => { + expect(() => { + throw new Error("outer", { cause: cause() }); + }).toThrowErrorMatchingSnapshot(); + }); + } + test("an object that inherits Error.prototype, through .rejects and .resolves", async () => { + const thrown = () => new Error("outer", { cause: inherits(Error.prototype, { message: "inner" }) }); + await expect(Promise.reject(thrown())).rejects.toThrowErrorMatchingSnapshot(); + await expect(Promise.resolve(thrown())).resolves.toThrowErrorMatchingSnapshot(); + }); + test("a Proxy whose getPrototypeOf trap throws", () => { + const cause = new Proxy( + {}, + { + getPrototypeOf() { + throw new RangeError("from the trap"); + }, + }, + ); + expect(() => { + expect(() => { + throw new Error("outer", { cause }); + }).toThrowErrorMatchingSnapshot(); + }).toThrow(new RangeError("from the trap")); + expect("the matcher that threw took no entry").toMatchSnapshot(); + }); + test("a Proxy that is its own prototype", () => { + const cause = new Proxy({}, { getPrototypeOf: () => cause }); + expect(() => { + expect(() => { + throw new Error("outer", { cause }); + }).toThrowErrorMatchingSnapshot(); + }).toThrow(RangeError); + }); + }); test("rejects", async () => { await expect(Promise.reject(new Error("rejected message"))).rejects.toThrowErrorMatchingSnapshot(); }); diff --git a/test/js/bun/test/snapshot-tests/formats/jest/inline.fixture.js b/test/js/bun/test/snapshot-tests/formats/jest/inline.fixture.js index add732402b1a..dc51d6087ec8 100644 --- a/test/js/bun/test/snapshot-tests/formats/jest/inline.fixture.js +++ b/test/js/bun/test/snapshot-tests/formats/jest/inline.fixture.js @@ -83,6 +83,14 @@ message`); }).toThrowErrorMatchingInlineSnapshot(` "thrown message" +`); + }); + test("thrown with a cause that inherits Error.prototype", () => { + expect(() => { + throw new Error("outer", { cause: Object.assign(Object.create(Error.prototype), { message: "inner" }) }); +}).toThrowErrorMatchingInlineSnapshot(` +"outer +Cause: inner" `); }); test("rejects", async () => { diff --git a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts index d7096456bf07..501207e28aee 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts +++ b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts @@ -1317,7 +1317,7 @@ const headers = { // Vitest 5.0.3 wrote formats/vitest and Jest 30.5.2 wrote formats/jest, both the .snap files and the inline snapshots. // To add a case, run it under that runner. Never update these with Bun. describe.concurrent("the fixtures and snapshot files that another runner wrote", () => { - const tests = { vitest: 971, jest: 601 }; + const tests = { vitest: 971, jest: 623 }; function copyOf(runner: "vitest" | "jest") { const dir = tempDir(`snapshot-formats-${runner}`, {}); @@ -1520,6 +1520,30 @@ describe.concurrent("the first line of a snapshot file says whose format it has" expect(exitCode).toBe(1); }); + test("still passes for a thrown error whatever its cause is, because Bun stored the message alone", async () => { + const causes = [ + `new TypeError("inner")`, + `"inner"`, + `new Legacy("inner")`, + `new DOMException("inner")`, + `new Proxy(new Error("inner"), {})`, + ]; + const thrown = causes.map( + (cause, i) => + `test("cause ${i}", () => { expect(() => { throw new Error("outer", { cause: ${cause} }); }).toThrowErrorMatchingSnapshot(); });`, + ); + const withCauses = + headers[format] + old + causes.map((_, i) => `\nexports[\`cause ${i} 1\`] = \`"outer"\`;\n`).join(""); + using dir = tempDir("snapshot-appended-to", { + "a.test.js": `function Legacy(message) { this.message = message; }\nLegacy.prototype = Object.create(Error.prototype);\n${thrown.join("\n")}`, + "__snapshots__/a.test.js.snap": withCauses, + }); + const { pass, stderr, exitCode } = await runTests(String(dir), { CI: "false" }); + expect({ pass, stderr: pass === causes.length ? "" : stderr }).toEqual({ pass: causes.length, stderr: "" }); + expect(snap(dir)).toBe(withCauses); + expect(exitCode).toBe(0); + }); + test("is still known for what it is after a run that adds an entry and does not see the others", async () => { using dir = tempDir("snapshot-appended-to", { "a.test.js": tests + `test("a new", () => { expect(() => {}).toMatchSnapshot(); });`, @@ -2661,6 +2685,34 @@ describe.concurrent("a snapshot that does not match what the value prints as", ( expect(log).toEqual([`${message} 1`, `${message} 101`]); expect({ stderr: exitCode ? stderr : "", exitCode }).toEqual({ stderr: "", exitCode: 0 }); }); + + test("a failed require() is an Error as a cause, and an inline snapshot keeps its message whatever a cause's getPrototypeOf trap throws", async () => { + const { log, stderr, exitCode } = await logOf({ + "a.test.js": ` + ${firstLine} + const thrower = cause => () => { throw new Error("outer", { cause }); }; + test("t", () => { + let unresolved; + try { require("./missing.js"); } catch (error) { unresolved = error; } + try { + expect(thrower(unresolved)).toThrowErrorMatchingSnapshot(); + } catch (error) { + console.log(error.message.split("\\n").find(line => line.startsWith("Cause: "))); + } + + let asked = 0; + const throws = new Proxy({}, { getPrototypeOf() { asked++; throw new RangeError("from the trap"); } }); + console.log(firstLine(() => expect(thrower(throws)).toThrowErrorMatchingInlineSnapshot(\`"other"\`)), asked); + }); + `, + "__snapshots__/a.test.js.snap": headers.jest, + }); + expect(log).toEqual([ + "Cause: Cannot find module './missing.js'", + "expect(received).toThrowErrorMatchingInlineSnapshot(expected) 1", + ]); + expect({ stderr: exitCode ? stderr : "", exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); }); describe.concurrent("what script hands to the printer of snapshots", () => {