Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions docs/test/snapshots.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ 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 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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 @@ -3126,6 +3126,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
30 changes: 29 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 @@ -2661,6 +2661,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