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
27 changes: 27 additions & 0 deletions src/runtime/test_runner/diff/printDiff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ pub(crate) fn print_diff_main(
config: &DiffConfig,
) -> std::fmt::Result {
if not {
let mut shown = Vec::new();
let expected_slice = if strings::contains_char(expected_slice, b'\r') {
push_with_visible_carriage_returns(&mut shown, expected_slice);
&shown[..]
} else {
expected_slice
};
match config.enable_ansi_colors {
true => write!(
writer,
Expand All @@ -59,6 +66,8 @@ pub(crate) fn print_diff_main(
limit: config.truncate_threshold.max(config.truncate_context * 2),
config,
chars: diff::CharDiff::default(),
has_carriage_return: strings::contains_char(received_slice, b'\r')
|| strings::contains_char(expected_slice, b'\r'),
};

if strings::index_of_char(received_slice, b'\n').is_none()
Expand All @@ -73,6 +82,18 @@ pub(crate) fn print_diff_main(
p.result
}

/// `␍` and not the two characters `\r`: the value printer writes a backslash as it is, so those could be a backslash and an `r`.
#[optimize(size)]
fn push_with_visible_carriage_returns(out: &mut Vec<u8>, text: &[u8]) {
let mut rest = text;
while let Some(i) = strings::index_of_char_usize(rest, b'\r') {
out.extend_from_slice(&rest[..i]);
out.extend_from_slice("\u{240D}".as_bytes());
rest = &rest[i + 1..];
}
out.extend_from_slice(rest);
}

/// Rendered output is handed to the writer in chunks of about this size, cut
/// at row boundaries.
const FLUSH_AT: usize = 64 << 10;
Expand Down Expand Up @@ -133,6 +154,8 @@ struct Printer<'c, W: Write> {
limit: usize,
config: &'c DiffConfig,
chars: diff::CharDiff,
/// Whether a side holds a `\r`. The diff compares the bytes as they are, and `b` draws them.
has_carriage_return: bool,
}

/// One row-group of the rendered diff. `Equal` covers a whole run of
Expand Down Expand Up @@ -213,8 +236,12 @@ impl<'c, W: Write> Printer<'c, W> {
fn s(&mut self, s: &str) {
self.out.extend_from_slice(s.as_bytes());
}
/// The text of a value.
#[inline]
fn b(&mut self, b: &[u8]) {
if self.has_carriage_return {
return push_with_visible_carriage_returns(&mut self.out, b);
}
self.out.extend_from_slice(b);
}
#[inline]
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/test_runner/dom_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub(crate) fn print_in_snapshot(
out: Vec::new(),
min: formatter.is_inline,
is_inline: formatter.is_inline,
keeps_carriage_returns: format.is_pretty_format(),
keeps_carriage_returns: !formatter.normalize_newlines,
shadow_roots: format == super::snapshot::Format::Vitest,
max_depth: formatter.max_depth,
max_width: formatter.max_width,
Expand Down Expand Up @@ -338,7 +338,7 @@ struct Printer<'a> {
min: bool,
/// See `pretty_format::Formatter::is_inline`.
is_inline: bool,
/// False only for Bun's own snapshot format. `print_snapshot` normalizes Jest and Vitest snapshots as a whole.
/// False only for the text a Bun-format snapshot stores. `print_snapshot` normalizes Jest and Vitest snapshots as a whole.
keeps_carriage_returns: bool,
shadow_roots: bool,
max_depth: u32,
Expand Down
21 changes: 17 additions & 4 deletions src/runtime/test_runner/pretty_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ pub(crate) struct Formatter<'a> {
pub global_this: &'a JSGlobalObject,
pub(crate) indent: u32,
pub(crate) quote_strings: bool,
/// jest-snapshot's `normalizeNewlines`: `\r\n` and `\r` print as `\n`. Only the text that a Bun-format snapshot stores sets it.
pub(crate) normalize_newlines: bool,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
pub(crate) failed: bool,
pub(crate) estimated_line_length: usize,
pub(crate) always_newline_scope: bool,
Expand Down Expand Up @@ -352,6 +354,7 @@ impl<'a> Formatter<'a> {
global_this: global,
indent: 0,
quote_strings: false,
normalize_newlines: false,
failed: false,
estimated_line_length: 0,
always_newline_scope: false,
Expand Down Expand Up @@ -1191,9 +1194,12 @@ impl<'a> Formatter<'a> {

let mut has_newline = false;

if str.index_of_any(b"\n\r").is_some() {
has_newline = true;
writer.write_all(b"\n");
if let Some(first) = str.index_of_any(b"\n\r") {
// A `\r` that prints as itself does not break the line.
if self.normalize_newlines || str.substring(first).index_of_any(b"\n").is_some() {
has_newline = true;
writer.write_all(b"\n");
}
}

writer.write_all(b"\"");
Expand All @@ -1213,7 +1219,12 @@ impl<'a> Formatter<'a> {
remaining = remaining.substring(i + 1);
}
CR => {
if i + 1 < remaining.len
if !self.normalize_newlines {
writer.print(format_args!(
"{}\r",
remaining.substring_with_len(0, i)
));
} else if i + 1 < remaining.len
&& remaining.char_at(i + 1) == LF
{
writer.print(format_args!(
Expand Down Expand Up @@ -3534,6 +3545,7 @@ impl JestPrettyFormat {
let mut expected = ExpectedText { rest: saved, differs: &differs };
let mut formatter = Formatter::new(global);
formatter.quote_strings = true;
formatter.normalize_newlines = true;
formatter.like_older_bun = Some(&differs);
let tag = Tag::get(value, global)?;
let mut bridge = AsFmt::new(&mut expected);
Expand All @@ -3556,6 +3568,7 @@ impl JestPrettyFormat {
formatter.serializers = Self::serializers(global)?;
if !format.is_pretty_format() {
formatter.quote_strings = true;
formatter.normalize_newlines = 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)?;
Expand Down
6 changes: 6 additions & 0 deletions test/js/bun/test/expect-extend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,12 @@ describe("MatcherContext", () => {
'+ "a" => 2,',
...counts(1, 1),
]);
// A line ending is part of the value: "\r\n" against "\n" is a difference, and a row shows the "\r" as "␍".
expect(marked({ a: "b\nc" }, { a: "b\r\nc" })).toEqual(['- "b', '+ "b␍', ...counts(1, 1)]);
expect(marked("b\nc", "b\r\nc")).toEqual(["- b", "+ b␍", ...counts(1, 1)]);
expect(Bun.stripANSI(utils.diff({ a: "b\r\nc" }, { a: "b\r\nc" }))).toBe(
"Compared values have no visual difference.",
);
expect(marked({ a: 1 }, { a: 2 }, { expand: false, contextLines: 0 })).toEqual([
'- "a": 1,',
'+ "a": 2,',
Expand Down
16 changes: 16 additions & 0 deletions test/js/bun/test/printing/__snapshots__/dom-nodes.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots

exports[`snapshots a snapshot file stores the same line ends 1`] = `
<a
title="1
2
3"
>
1
2
3
<!--1
2
3-->
</a>
`;
113 changes: 112 additions & 1 deletion test/js/bun/test/printing/diffexample.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";

function cleanOutput(output: string) {
Expand Down Expand Up @@ -968,3 +968,114 @@ test("stops printing deeper once a value has printed 1,000,000 characters", () =
expect(message).toContain(`"right": [Object],\n`);
expect(message.length).toBeWithin(1_000_000, 1_500_000);
});

// A failure message keeps each "\r", and the diff draws it as "␍". Only the text that a snapshot stores has "\n" for it.
describe("carriage returns", () => {
const footer = (removed: number, inserted: number) => `\n\n- Expected - ${removed}\n+ Received + ${inserted}\n`;
const oneRow = `- "a\n+ "a␍\n b"` + footer(1, 1);
// [an assertion that fails, its message after the line with the matcher's signature]
const failing: [source: string, message: string][] = [
[String.raw`expect("a\r\nb").toBe("a\nb")`, oneRow],
[String.raw`expect("a\r\nb").toStrictEqual("a\nb")`, oneRow],
[
String.raw`expect({ s: "a\r\nb" }).toEqual({ s: "a\nb" })`,
` {\n "s": \n- "a\n+ "a␍\n b"\n ,\n }` + footer(1, 1),
],
[
String.raw`expect({ s: "a\r\nb", t: 1 }).toMatchObject({ s: "a\nb" })`,
` {\n "s": \n- "a\n+ "a␍\n b"\n ,\n+ "t": 1,\n }` + footer(1, 2),
],
[String.raw`expect({ s: "a\r\nb" }).toHaveProperty("s", "a\nb")`, oneRow],
[
String.raw`const fn = mock(); fn("a\r\nb"); expect(fn).toHaveBeenCalledWith("a\nb")`,
` [\n \n- "a\n+ "a␍\n b"\n ,\n ]` + footer(1, 1),
],
[String.raw`const fn = mock(() => "a\r\nb"); fn(); expect(fn).toHaveReturnedWith("a\nb")`, oneRow],
[String.raw`const fn = mock(async () => "a\r\nb"); await fn(); expect(fn).toHaveResolvedWith("a\nb")`, oneRow],
[String.raw`await expect(Promise.resolve("a\r\nb")).resolves.toBe("a\nb")`, oneRow],
// A lone "\r" is not a line break.
[String.raw`expect("a\rb").toBe("a\nb")`, `- "a\n- b"\n+ "a␍b"` + footer(2, 1)],
[String.raw`expect("a\rb").toBe("a\r\nb")`, `- "a␍\n- b"\n+ "a␍b"` + footer(2, 1)],
[String.raw`expect("ab\r").toBe("ab")`, `Expected: "ab"\nReceived: "ab␍"\n`],
[String.raw`expect(["ab\r"]).toEqual(["ab"])`, ` [\n- "ab",\n+ "ab␍",\n ]` + footer(1, 1)],
[
String.raw`expect("x").toEqual(expect.stringContaining("a\rb"))`,
`Expected: StringContaining "a␍b"\nReceived: "x"\n`,
],
// It is not the two characters "\r": the printer writes a backslash as it is.
[String.raw`expect("a\r").toBe("a\\r")`, `Expected: "a\\r"\nReceived: "a␍"\n`],
// A "␍" in a value draws like a "\r". The diff compares the bytes, so the rows are still marked.
[String.raw`expect("a\r\nb").toBe("a␍\nb")`, `- "a␍\n+ "a␍\n b"` + footer(1, 1)],
// A string that is not Latin-1, and strings inside other values.
[String.raw`expect("\u{1F600}\r\nb").toBe("\u{1F600}\nb")`, `- "\u{1F600}\n+ "\u{1F600}␍\n b"` + footer(1, 1)],
[
String.raw`expect(new Map([["k", new Set(["a\r\nb"])]])).toEqual(new Map([["k", new Set(["a\nb"])]]))`,
` Map {\n "k" => \n Set {\n \n- "a\n+ "a␍\n b"\n ,\n }\n ,\n }` + footer(1, 1),
],
[
String.raw`expect([expect.stringContaining("a\r\nb")]).toEqual([expect.stringContaining("a\nb")])`,
` [\n StringContaining \n- "a\n+ "a␍\n b"\n ,\n ]` + footer(1, 1),
],
// Values that do not print as a string.
[
String.raw`expect([new Error("a\r\nb"), Symbol("a\r\nb")]).toEqual([new Error("a\nb"), Symbol("a\nb")])`,
` [\n- [Error: a\n+ [Error: a␍\n b],\n- Symbol(a\n+ Symbol(a␍\n b),\n ]` + footer(2, 2),
],
// `.not` prints the expected value alone.
[String.raw`expect(["a\r\nb"]).not.toEqual(["a\r\nb"])`, `Expected: not [\n \n"a␍\nb"\n,\n]\n`],
];
const expected = Object.fromEntries(failing);
const [crlfAgainstLf, , insideAnObject] = Object.keys(expected);
const againstALiteral = String.raw`expect("a\r\nb").toBe("a␍\nb")`;

// The messages come from a child process, so the colours and the agent mode of this process do not change them.
async function messagesOf(env: Record<string, string>) {
const script = `
import { expect, mock } from "bun:test";
const messages = [];
for (const fail of [${failing.map(([source]) => `async () => { ${source}; }`).join(", ")}]) {
messages.push(await fail().then(() => "did not fail", error => error.message));
}
console.log(JSON.stringify(messages));
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, ...env },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const afterSignature = (message: string) => message.slice(message.indexOf("\n\n") + 2);
const messages: string[] = JSON.parse(stdout).map(afterSignature);
return { messages: Object.fromEntries(failing.map(([source], i) => [source, messages[i]])), exitCode };
}

test.concurrent("a failing matcher shows a line-ending difference", async () => {
const { messages, exitCode } = await messagesOf({ FORCE_COLOR: "0" });
expect(messages).toEqual(expected);
expect(exitCode).toBe(0);
});

test.concurrent("colours only add escape sequences", async () => {
const { messages, exitCode } = await messagesOf({ FORCE_COLOR: "1" });
expect(Object.fromEntries(Object.entries(messages).map(([k, message]) => [k, cleanAnsiEscapes(message)]))).toEqual(
expected,
);
// A "\r" alone has the style of a whitespace change. A "␍" of the value has the colour of its row.
const unchanged = ` \x1B[0m\x1B[2mb"\x1B[0m\n\n\x1B[32m- Expected - 1\x1B[0m\n\x1B[31m+ Received + 1\x1B[0m\n`;
const received = `\x1B[31m+ \x1B[0m\x1B[31m"a\x1B[0m\x1B[31m\x1B[7m␍\x1B[0m\n`;
expect(messages[crlfAgainstLf]).toBe(`\x1B[32m- \x1B[0m\x1B[32m"a\x1B[0m\n` + received + unchanged);
expect(messages[againstALiteral]).toBe(
`\x1B[32m- \x1B[0m\x1B[32m"a\x1B[0m\x1B[32m␍\x1B[0m\n` + received + unchanged,
);
expect(exitCode).toBe(0);
});

test.concurrent("agent mode keeps the changed rows", async () => {
const { messages, exitCode } = await messagesOf({ FORCE_COLOR: "0", AGENT: "1" });
expect(messages[crlfAgainstLf]).toBe(expected[crlfAgainstLf]);
expect(messages[insideAnObject]).toBe(`@@ -2,3 +2,3 @@\n "s": \n- "a\n+ "a␍\n b"` + footer(1, 1));
expect(exitCode).toBe(0);
});
});
21 changes: 21 additions & 0 deletions test/js/bun/test/printing/dom-nodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,22 @@ describe("diffs", () => {
"+ Received + 1",
]);
});

// Only the text that a snapshot stores has its line ends rewritten to \n. A diff shows a \r as ␍.
test("a line-ending difference is marked", () => {
const node = (end: string) => h("a", { title: `1${end}2` }, `3${end}4`, comment(`5${end}6`));
const message = messageOf(() => expect(node("\r\n")).toEqual(node("\n")));
expect(message.split("\n").filter(line => /^[-+]/.test(line))).toEqual([
'- title="1',
'+ title="1␍',
"- 3",
"+ 3␍",
"- <!--5",
"+ <!--5␍",
"- Expected - 3",
"+ Received + 3",
]);
});
});

describe("snapshots", () => {
Expand Down Expand Up @@ -403,6 +419,11 @@ describe("snapshots", () => {
`);
});

// Unlike an inline snapshot, a snapshot file that Bun wrote is not also read as Jest's format, which rewrites line ends.
test("a snapshot file stores the same line ends", () => {
expect(h("a", { title: "1\r\n2\r3" }, "1\r\n2\r3", comment("1\r\n2\r3"))).toMatchSnapshot();
});

test("characters outside ASCII", () => {
expect(h("p", { "é": "ü", title: "日本語 😀" }, "café 日本語 😀")).toMatchInlineSnapshot(`
<p
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,17 @@ exports[`it will work with an existing snapshot file made with bun 7`] = `
"j": Any<RegExp>,
}
`;

exports[`it will work with a string with carriage returns in a snapshot that an older bun wrote 1`] = `
{
"crlf":
"a
b"
,
"getter": [native code],
"lone":
"a
b"
,
}
`;
11 changes: 11 additions & 0 deletions test/js/bun/test/snapshot-tests/existing-snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,14 @@ test("it will work with an existing snapshot file made with bun", () => {
b: expect.any(String),
});
});

test("it will work with a string with carriage returns in a snapshot that an older bun wrote", () => {
// The getter makes the stored text match only as what an older Bun printed for it: [native code].
expect({
get getter() {
return 1;
},
crlf: "a\r\nb",
lone: "a\rb",
}).toMatchSnapshot();
});
Loading