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
2 changes: 1 addition & 1 deletion docs/test/snapshots.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ Inline snapshots don't record which runner wrote them, so Bun accepts a match in

With `--update-snapshots`, Bun removes entries that no test uses anymore from a file in Jest's or Vitest's format. It keeps entries for tests that were skipped, filtered out or failed. A file in Bun's format keeps only the entries of tests that ran.

Earlier versions of Bun printed `[native code]` for an accessor instead of its value, included non-enumerable own properties, and printed DOM nodes as plain objects. Snapshots written that way still pass. Bun leaves them alone until `--update-snapshots` rewrites them.
Earlier versions of Bun printed `[native code]` for an accessor instead of its value, included non-enumerable own properties, and printed DOM nodes as plain objects. Snapshots written that way still pass. Bun leaves them alone until `--update-snapshots` rewrites them. An inline snapshot written that way also passes when its value can no longer be printed, for example when an accessor throws in a test from `"vitest"`. `--update-snapshots` cannot rewrite that snapshot, so the test fails with the accessor's error. To update it, import `test` from `"bun:test"`.

## Best Practices

Expand Down
104 changes: 79 additions & 25 deletions src/runtime/test_runner/expect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use super::diff_format::DiffFormatter;
use self::expect_deferred::{Asked, ExpectDeferred, MatcherCall, Pass, Question};
use super::execution::ExpectAssertions;
use super::jest::Jest;
use super::pretty_format::JestPrettyFormat;
use super::pretty_format::{JestPrettyFormat, PrintError};
use super::snapshot::{Format as SnapshotFormat, Outcome as SnapshotOutcome};
use super::expect::{JSValueTestExt, FormatterTestExt, make_formatter};
use crate::expect_throw as throw;
Expand Down Expand Up @@ -1149,25 +1149,25 @@ impl Expect {
let is_vitest = this.is_vitest();
let format = if is_vitest { SnapshotFormat::Vitest } else { SnapshotFormat::Bun };
let mut pretty_value: Vec<u8> = Vec::new();
// Trimmed before the print, so `print_threw` has the text to compare.
let mut buf = vec![0u8; result.map_or(0, <[u8]>::len)];
let saved = result.map(|saved_value| Self::trim_leading_whitespace_for_inline_snapshot(saved_value, &mut buf));
let value = Self::value_to_snapshot(global_this, received, format)?;
let (shown, same_as_older_bun) =
this.match_and_fmt_snapshot(global_this, value, property_matchers, &mut pretty_value, fn_name, format)?;
if this.bun_test().is_some() {
match runner.snapshots.format_of(&runner.files, this).and_then(|format| runner.snapshots.add_count(this, format, b"")) {
Ok(_) => {}
Err(crate::Error::NoTest | crate::Error::SnapshotInConcurrentGroup | crate::Error::TestNotActive) => {}
// Inline snapshots don't need the snapshot file, so leave these errors to a matcher that does.
Err(crate::Error::FailedToOpenSnapshotFile | crate::Error::ParseError) => {}
Err(err) => return Err(this.throw_snapshot_error(global_this, &err, b"")),
}
}
let shown = this.with_property_matchers(global_this, value, property_matchers, fn_name)?;
let printed = if format.is_pretty_format() { value } else { shown };
let same_as_older_bun =
match JestPrettyFormat::print_snapshot(global_this, printed, property_matchers, &mut pretty_value, format) {
Ok(same_as_older_bun) => same_as_older_bun,
Err(thrown) => {
let saved = saved.as_ref().map(|trim_res| trim_res.trimmed);
return this.print_threw(global_this, thrown, saved, received, shown, property_matchers);
}
};
this.count_inline_snapshot(global_this, runner)?;

let mut start_indent: Option<Box<[u8]>> = None;
let mut end_indent: Option<Box<[u8]>> = None;
if let Some(saved_value) = result {
let mut buf = vec![0u8; saved_value.len()];
let trim_res = Self::trim_leading_whitespace_for_inline_snapshot(saved_value, &mut buf);

if let Some(trim_res) = saved {
if format.matches(trim_res.trimmed, &pretty_value) {
runner.snapshots.passed += 1;
return Ok(JSValue::UNDEFINED);
Expand Down Expand Up @@ -1257,6 +1257,48 @@ impl Expect {
Ok(JSValue::UNDEFINED)
}

#[inline(always)]
fn count_inline_snapshot(&self, global_this: &JSGlobalObject, runner: &mut super::jest::TestRunner<'static>) -> JsResult<()> {
if self.bun_test().is_some() {
match runner.snapshots.format_of(&runner.files, self).and_then(|format| runner.snapshots.add_count(self, format, b"")) {
Ok(_) => {}
Err(crate::Error::NoTest | crate::Error::SnapshotInConcurrentGroup | crate::Error::TestNotActive) => {}
// Inline snapshots don't need the snapshot file, so leave these errors to a matcher that does.
Err(crate::Error::FailedToOpenSnapshotFile | crate::Error::ParseError) => {}
Err(err) => return Err(self.throw_snapshot_error(global_this, &err, b"")),
}
}
Ok(())
}

/// The print threw. What an older Bun saved can still match: it called no getter at a key and no `toJSON`.
#[cold]
#[optimize(size)]
fn print_threw(
&self,
global_this: &JSGlobalObject,
thrown: PrintError,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
saved: Option<&[u8]>,
received: Received,
shown: JSValue,
property_matchers: Option<JSValue>,
) -> JsResult<JSValue> {
let PrintError { error, is_of_serializer } = thrown;
// `--update-snapshots` has to print the value, and no older Bun ran a snapshot serializer.
let runner = Jest::runner().filter(|runner| !runner.snapshots.update_snapshots && !is_of_serializer);
let (Some(runner), Some(saved)) = (runner, saved) else { return Err(error) };
Comment thread
robobun marked this conversation as resolved.
Resolved
if !matches!(error, JsError::Thrown) || global_this.has_pending_termination_exception() {
return Err(error);
}
let exception = global_this.take_exception(error);
if !Self::was_written_by_another(&[], true, global_this, received, shown, property_matchers, saved)? {
return Err(global_this.throw_value(exception));
}
self.count_inline_snapshot(global_this, runner)?;
runner.snapshots.passed += 1;
Ok(JSValue::UNDEFINED)
}

#[cold]
#[optimize(size)]
fn was_written_by_another(
Expand Down Expand Up @@ -1290,22 +1332,20 @@ impl Expect {
Ok(false)
}

/// Returns `value` with `property_matchers` substituted in, and `print_snapshot`'s result.
#[optimize(size)]
pub(crate) fn match_and_fmt_snapshot(
/// Returns `value` with `property_matchers` substituted in.
#[inline(always)]
fn with_property_matchers(
&self,
global_this: &JSGlobalObject,
value: JSValue,
property_matchers: Option<JSValue>,
pretty_value: &mut Vec<u8>,
fn_name: &'static str,
format: SnapshotFormat,
) -> JsResult<(JSValue, bool)> {
) -> JsResult<JSValue> {
let mut shown = value;
if let Some(_prop_matchers) = property_matchers {
if !value.is_object() {
let signature = Self::get_signature(fn_name, "<green>properties<r><d>, <r>hint", false);
return throw!(self, global_this, signature, "\n\n<b>Matcher error: <red>received<r> values must be an object when the matcher has <green>properties<r>\n").map(|_| (value, true));
return throw!(self, global_this, signature, "\n\n<b>Matcher error: <red>received<r> values must be an object when the matcher has <green>properties<r>\n").map(|_| value);
}

let prop_matchers = _prop_matchers;
Expand All @@ -1319,11 +1359,25 @@ impl Expect {
self, global_this, signature,
"\n\nExpected <green>propertyMatchers<r> to match properties from received object\n\nReceived: {}\n",
value.to_fmt(&mut formatter),
).map(|_| (value, true));
).map(|_| value);
}
shown = with_matchers;
}
Ok(shown)
}

/// Returns `value` with `property_matchers` substituted in, and `print_snapshot`'s result.
#[optimize(size)]
pub(crate) fn match_and_fmt_snapshot(
&self,
global_this: &JSGlobalObject,
value: JSValue,
property_matchers: Option<JSValue>,
pretty_value: &mut Vec<u8>,
fn_name: &'static str,
format: SnapshotFormat,
) -> JsResult<(JSValue, bool)> {
let shown = self.with_property_matchers(global_this, value, property_matchers, fn_name)?;
let printed = if format.is_pretty_format() { value } else { shown };
let same_as_older_bun = JestPrettyFormat::print_snapshot(global_this, printed, property_matchers, pretty_value, format)?;
Ok((shown, same_as_older_bun))
Expand Down Expand Up @@ -2649,7 +2703,7 @@ impl ExpectCustomAsymmetricMatcher {
&mut printed,
SnapshotFormat::Vitest,
) {
return Self::maybe_clear(global_this, e, dont_throw);
return Self::maybe_clear(global_this, e.error, dont_throw);
}
writer.write_all(&printed)?;
return Ok(true);
Expand Down
37 changes: 34 additions & 3 deletions src/runtime/test_runner/pretty_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ pub(crate) struct Formatter<'a> {
print_basic_prototype: bool,
print_function_name: bool,
serializers: Option<JSValue>,
/// A snapshot serializer threw what the print returns.
serializer_threw: bool,
config: Option<JSValue>,
/// Print the way Vitest's `inspect()` does: one line, single quotes, unsorted unquoted keys, function names.
pub(crate) is_inline: bool,
Expand Down Expand Up @@ -364,6 +366,7 @@ impl<'a> Formatter<'a> {
print_basic_prototype: false,
print_function_name: false,
serializers: None,
serializer_threw: false,
config: None,
is_inline: false,
depth: 0,
Expand Down Expand Up @@ -3531,6 +3534,26 @@ impl bun_io::Write for ExpectedText<'_> {
}
}

/// What `JestPrettyFormat::print_snapshot` threw.
#[derive(Copy, Clone)]
pub(crate) struct PrintError {
pub(crate) error: JsError,
/// A snapshot serializer threw it, and not the value.
pub(crate) is_of_serializer: bool,
}

impl From<JsError> for PrintError {
fn from(error: JsError) -> Self {
PrintError { error, is_of_serializer: false }
}
}

impl From<PrintError> for JsError {
fn from(thrown: PrintError) -> Self {
thrown.error
}
}

impl JestPrettyFormat {
/// True if `saved` is what older versions of Bun wrote for `value`, so their snapshots still pass.
/// They printed DOM nodes as plain objects, accessors as `[native code]`, and non-enumerable properties.
Expand All @@ -3556,15 +3579,17 @@ impl JestPrettyFormat {
property_matchers: Option<JSValue>,
out: &mut Vec<u8>,
format: SnapshotFormat,
) -> JsResult<bool> {
) -> Result<bool, PrintError> {
let mut formatter = Formatter::new(global);
formatter.snapshot_format = format;
formatter.serializers = Self::serializers(global)?;
let thrown = |formatter: &Formatter<'_>, error| PrintError { error, is_of_serializer: formatter.serializer_threw };
if !format.is_pretty_format() {
formatter.quote_strings = true;
let tag = Tag::get(value, global)?;
let mut bridge = AsFmt::new(out);
formatter.format::<_, false>(tag, &mut bun_io::write::FmtAdapter::new(&mut bridge), value, global)?;
let printed = formatter.format::<_, false>(tag, &mut bun_io::write::FmtAdapter::new(&mut bridge), value, global);
printed.map_err(|error| thrown(&formatter, error))?;
return Ok(formatter.same_as_older_bun && formatter.serializers.is_none());
}

Expand All @@ -3575,7 +3600,7 @@ impl JestPrettyFormat {

// jest-snapshot's `addExtraLineBreaks(normalizeNewlines(format(value)))`.
let mut printed = Vec::new();
formatter.print_like_pretty_format(&mut printed, value, false)?;
formatter.print_like_pretty_format(&mut printed, value, false).map_err(|error| thrown(&formatter, error))?;
let multiline = strings::index_of_any(&printed, b"\r\n").is_some();
if multiline {
out.push(b'\n');
Expand Down Expand Up @@ -4811,6 +4836,12 @@ impl Formatter<'_> {
#[cold]
#[inline(never)]
fn print_with_serializer(&mut self, out: &mut Vec<u8>, value: JSValue) -> JsResult<bool> {
let printed = self.ask_serializers(out, value);
self.serializer_threw |= printed.is_err();
Comment thread
robobun marked this conversation as resolved.
Resolved
printed
}

fn ask_serializers(&mut self, out: &mut Vec<u8>, value: JSValue) -> JsResult<bool> {
let global = self.global_this;
let Some(serializers) = self.serializers else { return Ok(false) };
let mut iter = serializers.array_iterator(global)?;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots

exports[`two calls and a snapshot in the file 3`] = `"the third of the test"`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Bun 1.4.4 wrote the inline snapshots of this file. It printed `[native code]` for a getter and did not call it.
// Bun's format reads these getters now, and throws what the read or the print of its result throws.
// To add a case, run it under that version. Never update them with a later Bun.
import { expect, test } from "bun:test";

const revoked = () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
return proxy;
};
class HTMLDivElement {
nodeType = 1;
tagName = "DIV";
attributes = [];
}

test("a getter that returns a revoked Proxy", () => {
expect({
get proxy() {
return revoked();
},
}).toMatchInlineSnapshot(`
{
"proxy": [native code],
}
`);
});
test("a getter that returns a Proxy whose ownKeys throws", () => {
expect({
get proxy() {
return new Proxy(
{},
{
ownKeys() {
throw new Error("not ready");
},
},
);
},
}).toMatchInlineSnapshot(`
{
"proxy": [native code],
}
`);
});
test("what looks like a DOM node, with a getter that throws", () => {
const node = new HTMLDivElement();
Object.defineProperty(node, "childNodes", {
enumerable: true,
get() {
throw new Error("not ready");
},
});
expect(node).toMatchInlineSnapshot(`
HTMLDivElement {
"attributes": [],
"childNodes": [native code],
"nodeType": 1,
"tagName": "DIV",
}
`);
});
test("what looks like a React 19 element, with a getter that throws", () => {
expect({
$$typeof: Symbol.for("react.transitional.element"),
type: "b",
key: null,
get props() {
throw new Error("not ready");
},
_owner: null,
}).toMatchInlineSnapshot(`
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"key": null,
"props": [native code],
"type": "b",
}
`);
});
Loading
Loading