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
4 changes: 4 additions & 0 deletions docs/test/snapshots.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 25 additions & 13 deletions src/jsc/bindings/SnapshotFormat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
robobun marked this conversation as resolved.
Resolved
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.
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/test_runner/expect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSValue> {
Expand All @@ -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)?) {
Comment thread
robobun marked this conversation as resolved.
Resolved
cause.get(global_this, "message")?.unwrap_or(JSValue::UNDEFINED)
Comment thread
robobun marked this conversation as resolved.
Resolved
} else if cause.is_string() {
} else if cause.is_string_literal() {
cause
} else {
break;
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/test_runner/pretty_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3134,6 +3134,14 @@ pub(crate) fn to_number(global: &JSGlobalObject, value: JSValue) -> JsResult<f64
jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__toNumber(global, value))
}

/// `value instanceof Error` as `inheritsErrorPrototype` in SnapshotFormat.cpp answers it, which says where Jest answers otherwise. Over 1,000 Proxies in the chain is a RangeError.
pub(crate) fn inherits_error_prototype(global: &JSGlobalObject, value: JSValue) -> JsResult<bool> {
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<bool> {
unsafe extern "C" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"`;
76 changes: 76 additions & 0 deletions test/js/bun/test/snapshot-tests/formats/jest/errors.fixture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
54 changes: 53 additions & 1 deletion test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, {});
Expand Down Expand Up @@ -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(); });`,
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading