Repository navigation
Conversation
The value printer wrote "\r\n" and a lone "\r" in a string as "\n". That is jest-snapshot's normalizeNewlines, for the text that a snapshot stores. A failing matcher prints both values with the same printer, so two strings that differ only in their line endings printed the same text and the diff was empty. Formatter.normalize_newlines is off by default. Only the two producers of stored Bun-format snapshot text set it. The quoted-string arm and the DOM printer read it, so a failure message keeps each "\r". print_diff_main shows every "\r" of either side as U+240D. A terminal draws what follows a raw "\r" over the start of the row, and a GitHub Actions annotation drops it.
|
Status: ready for review. CI is red only on tests that are also red on main. How it was reproduced
CI
PR: #47816 |
WalkthroughFormatter and snapshot paths now use an explicit newline-normalization setting. Diff output displays carriage returns as ChangesCarriage Return Formatting
Possibly related PRs
Suggested reviewers: Merge Risk:
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the stored-snapshot producers: both print_snapshot branches and did_older_bun_print set normalize_newlines, the has_newline rewrite short-circuits on that flag so the Bun-format text path is unchanged, and the two added .snap files contain only \n. The .not early return in print_diff_main receives the ␍-rewritten expected slice, so that path is covered too.
Extended reasoning...
The change touches the bun:test value printer (pretty_format.rs string arm), the DOM printer flag source, and print_diff_main, adding a per-formatter normalize_newlines flag and a CR-to-U+240D rewrite of both diff inputs; it touches no security-sensitive surface. Two confirmed findings are posted inline (U+240D collision in the diff and raw CR still reaching non-diff DOM messages), so a human look is already signalled; this note only records the snapshot-storage and .not paths that were examined and found consistent.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/runtime/test_runner/dom_format.rs— Users whose DOM-node matcher fails without a diff still get a raw\rin the failure message, which the terminal overdraws and the GitHub Actions annotation drops. The PR's own goal is that a\rnever reaches the terminal, yet print_in_message at dom_format.rs:35 keeps keeps_carriage_returns: true and dom_format.rs:699/:712 then copy\rfrom text, comments and attribute values straight into the message for every matcher built on make_formatter (mod.rs:258-263), none of which pass through print_diff_main. Fix: route the same␍rendering through the message printer for DOM nodes (a non-diff message sibling of print_diff_main), or rewrite\rin the ConsoleObject-based message path, so every producer of failure text is covered.Why this was flagged
Trigger: a DOM node whose text, comment or attribute contains
\r, failing a matcher that prints without a diff: toBeInstanceOf (expect/toBeInstanceOf.rs:25,41), toContain (toContain.rs:118), toBeEmpty (toBeEmpty.rs:73-120) and the list rendering of the CalledWith family (toHaveBeenCalledWith.rs:100). Those use make_formatter at mod.rs:258-263, which installs dom_format::print_in_message as the dom_printer; print_in_message builds its Printer with keeps_carriage_returns: true at dom_format.rs:35, so write_text at :699 uses the needle<>and write_quoted at :712 (min is true) uses"\\, neither of which touches\r, and the raw byte lands in the message. The base branch does the same, but this PR adds with_visible_carriage_returns only to print_diff_main (printDiff.rs:39, :54) and states that a raw\rmust not reach the terminal or an annotation. Population: tests of DOM output containing CRLF text using a non-diff matcher; rate: every such failure. Remedy: apply the␍rewrite in the message printer for DOM nodes as well, or in the shared ConsoleObject message path.Verification: pre-existing. Trigger: a happy-dom/jsdom node whose text, comment or attribute value contains a
\r, failing a matcher that prints the value without going throughprint_diff_main.src/runtime/test_runner/dom_format.rs:35still constructs thePrinterwithkeeps_carriage_returns: true, so a\ris copied verbatim into the message. The base commit already has the same line, so merging makes nothing worse.
… before the diff The rewrite ran on both sides before the diff, so a "\r" and a literal U+240D became equal bytes. A multi-line pair that differs only there got the empty diff again. The diff now compares the bytes as they are. Printer::b, which writes the text of a value, draws each "\r" as U+240D. A "\r" that is the only change keeps the style of a whitespace-only change.
|
Both reviews found the same defect in ca055d2 moves the drawing to
The raw |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline nit, I checked a few more things since the last push: the U+240D substitution now happens only at draw time in Printer::b / the .not path, so a literal ␍ against a \r still diffs on raw bytes and produces marked rows with a 1/1 footer (pinned by the againstALiteral case). Every value-text write in printDiff.rs goes through segment -> b (including truncated_line and the char-highlight path), so no path emits a raw \r. normalize_newlines is set on both Bun-format snapshot producers (print_snapshot, did_older_bun_print) and read by the string arm and dom_format, and the keeps_carriage_returns remap is equivalent to the old is_pretty_format() for Jest/Vitest, changing only failure-message output.
Extended reasoning...
The change touches the bun:test value printer, DOM printer and diff renderer so that failure messages keep carriage returns and draw them as U+240D while stored Bun-format snapshot text is unchanged; no security-sensitive surface is involved. The previously raised ambiguity concern was addressed by moving the substitution after the byte-level diff, and the new subprocess tests assert exact output under three env configurations. A human look is still worthwhile because this is a user-visible change to failure output across many matchers and the description names several open PRs editing the same lines.
There was a problem hiding this comment.
I reviewed the latest push and found no bugs; the two points raised on earlier versions are addressed in the code (the diff now compares raw bytes and only Printer::b draws ␍, and the test comment is one line), but since this changes user-visible failure-message output for every string with a \r, a human look at the design choice is still worthwhile.
What was reviewed:
- Traced
normalize_newlinesthrough everyFormatterconstructor: onlydid_older_bun_printand the Bun-format branch ofprint_snapshotset it, so stored snapshot text is unchanged;dom_format.rsnow keys off the same field. - All value text in
printDiff.rsflows throughb(segment,truncated_line); the.notbranch rewrites into a local buffer. Byte-search goes throughbun_core::strings, nostr::find/memchr. - One behavioural change worth a maintainer's opinion rather than a bug: matchers that print without a diff via
make_formatter(toContain,toMatch,toHaveLength, etc.) now emit a raw\rinReceived:where main printed\n; the PR notesgithub_action_writerdrops a lone\r, and no test pins that path.
Extended reasoning...
The change touches the bun:test value printer (pretty_format.rs), the DOM printer and the diff renderer (printDiff.rs), about 50 lines of Rust, plus exact-message tests under FORCE_COLOR=0/1 and AGENT=1 and new snapshot cases. It touches no security-sensitive surface. The flag defaults off and is set only on the two snapshot-text producers, and the ␍ substitution is applied at output time after the byte diff, which closed the ambiguity raised on an earlier push. Deferring rather than approving because the PR changes user-visible failure output across all matchers, including a raw \r now reaching non-diff messages from make_formatter that no test covers, which is a design choice a maintainer should weigh; no CODEOWNERS entry covers these files and there is no outstanding CHANGES_REQUESTED review.
|
Two points came up in the reviews of 38fad2d. Both were checked against main 6a38049. Neither needs a change. Serializer output in a Bun-format snapshot (the thread on
Messages without a diff (
|
|
A correction to one measurement in the description. The description said that 96 of 8,500 snapshots written by bun 1.4.3 fail on Result (8,800 values,
The corpus
older.test.ts// Which snapshots of an older Bun still pass? Run once with the older Bun to write the file, then with CI=true and the newer one.
// Every value holds a drawn text of \r, \n, \r\n, backslashes, quotes and filler. Half of the texts also hold a 16-bit character.
import { expect, test } from "bun:test";
const perKind = Number(process.env.PER_KIND ?? 100);
// No Math.random: every binary must see the same values.
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash(text: string) {
let h = 2166136261;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
return h >>> 0;
}
const LATIN1 = ["\r", "\n", "\r\n", "\\", '"', "'", "`", "a", "b", " "];
const WIDE = ["\u20ac", "\u2028", "\u240d", "\ud83d\ude00", "\ud800"];
function draw(random: () => number, wide: boolean) {
const signs = wide ? [...LATIN1, ...WIDE] : LATIN1;
const parts: string[] = [];
const length = 1 + Math.floor(random() * 6);
for (let i = 0; i < length; i++) parts.push(signs[Math.floor(random() * signs.length)]);
if (wide && !parts.some(part => WIDE.includes(part))) {
parts[Math.floor(random() * parts.length)] = WIDE[Math.floor(random() * WIDE.length)];
}
return parts.join("");
}
// Stand-ins for DOM nodes, shaped like the ones in test/js/bun/test/printing/dom-nodes.test.ts.
const impl = Symbol("impl");
class ArrayLike {
[index: number]: unknown;
constructor(items: unknown[]) {
items.forEach((item, i) => (this[i] = item));
}
get length() {
return Object.keys(this).length;
}
*[Symbol.iterator]() {
for (let i = 0; i < this.length; i++) yield this[i];
}
}
class NodeList extends ArrayLike {}
class HTMLCollection extends ArrayLike {}
class NamedNodeMap extends ArrayLike {}
class DOMStringMap {}
class DOMTokenList {}
class Attr {
constructor(
public name: string,
public value: unknown,
) {}
}
class Node {
constructor(state: object) {
Object.defineProperty(this, impl, { value: state });
}
get nodeType() {
return (this as any)[impl].nodeType;
}
get data() {
return (this as any)[impl].data;
}
get tagName() {
return (this as any)[impl].tagName;
}
get attributes() {
return new NamedNodeMap((this as any)[impl].attributes);
}
get childNodes() {
return new NodeList((this as any)[impl].childNodes);
}
hasAttribute(name: string) {
return (this as any)[impl].attributes.some((attribute: Attr) => attribute.name === name);
}
get nodeName() {
return (this as any)[impl].tagName ?? "#node";
}
}
class Text extends Node {}
class Comment extends Node {}
class DocumentFragment extends Node {}
class Element extends Node {}
class HTMLElement extends Element {}
const text = (data: string) => new Text({ nodeType: 3, data });
const comment = (data: string) => new Comment({ nodeType: 8, data });
const fragment = (...childNodes: unknown[]) => new DocumentFragment({ nodeType: 11, childNodes });
const h = (tagName: string, attributes: Record<string, unknown> = {}, ...children: unknown[]) =>
new HTMLElement({
nodeType: 1,
tagName: tagName.toUpperCase(),
attributes: Object.entries(attributes).map(([name, value]) => new Attr(name, value)),
childNodes: children.map(child => (typeof child === "string" ? text(child) : child)),
});
class Named {
constructor(public value: unknown) {}
}
class Shout extends String {}
const VALUES: Record<string, (s: string) => unknown> = {
"string": s => s,
"string in an object": s => ({ k: s }),
"string in an array": s => [s],
"string nested": s => ({ a: [{ b: s }, s], c: { d: s } }),
"string in a class instance": s => new Named(s),
"String object": s => new String(s),
"String object in an array": s => [new String(s)],
"String object in an object": s => ({ k: new String(s) }),
"object of a String subclass": s => new Shout(s),
"key": s => ({ [s]: 1 }),
"key nested": s => ({ a: { [s]: [s] } }),
"key of a class instance": s => Object.assign(new Named(1), { [s]: 2 }),
"Symbol key": s => ({ [Symbol(s)]: 1 }),
"Map key": s => new Map([[s, 1]]),
"Map value": s => new Map([["k", s]]),
"Map nested": s => ({ m: new Map([[s, { [s]: s }]]) }),
"Set": s => new Set([s]),
"Set nested": s => [new Set([s, [s]])],
"Error": s => new Error(s),
"Error in an array": s => [new Error(s)],
"Error in an object": s => ({ e: new TypeError(s) }),
"Error with a property": s => Object.assign(new Error(s), { code: s }),
"Error with a cause": s => new Error("outer", { cause: new Error(s) }),
"Error name": s => Object.assign(new Error("m"), { name: s }),
"Symbol": s => Symbol(s),
"Symbol in an array": s => [Symbol(s)],
"Symbol in an object": s => ({ k: Symbol(s) }),
"DOM text": s => text(s),
"DOM comment": s => comment(s),
"DOM attribute": s => h("div", { title: s }),
"DOM child text": s => h("p", { id: "x" }, s),
"DOM nested": s => h("div", { class: "a" }, h("span", { title: s }, s), comment(s)),
"DOM fragment": s => fragment(text(s), h("b", {}, s)),
"DOM node in an object": s => ({ node: h("i", { title: s }, s) }),
"DOM node in an array": s => [h("i", { title: s }, s), text(s)],
"DOM node in a Map": s => new Map([[s, h("i", { title: s }, s)]]),
"DOM node in a Set": s => new Set([h("i", { title: s }, s)]),
"DOM node with ownerDocument": s => Object.assign(h("i", { title: s }, s), { ownerDocument: { title: s } }),
"NodeList": s => new NodeList([text(s), h("b", {}, s), s]),
"HTMLCollection": s => new HTMLCollection([h("a", { href: s })]),
"NamedNodeMap": s => new NamedNodeMap([new Attr("title", s), new Attr("id", "x")]),
"DOMStringMap": s => Object.assign(new DOMStringMap(), { k: s, [s]: "v" }),
"DOMTokenList": s => Object.assign(new DOMTokenList(), [s, "y"]),
};
function each(kind: string, run: (s: string) => void) {
for (const wide of [false, true]) {
const random = rng(hash(kind) ^ Number(wide));
for (let i = 0; i < perKind; i++) {
const s = draw(random, wide);
test(`${kind} | ${wide ? "16-bit" : "Latin-1"} | ${i}`, () => run(s));
}
}
}
for (const [kind, make] of Object.entries(VALUES)) {
each(kind, s => {
expect(make(s)).toMatchSnapshot();
});
}
each("thrown Error", s => {
expect(() => {
throw new Error(s);
}).toThrowErrorMatchingSnapshot();
});Run it once with the older release in an empty directory. That writes |
Problem
expect("a\r\nb").toBe("a\nb")prints- Expected - 0/+ Received + 0. Jest and Vitest print a changed row.src/runtime/test_runner/pretty_format.rs:1206) and DOM printer write\r\nand\ras\n, also in a failure diff.Fix
Formatter.normalize_newlinesis off by default. Only the two producers of stored Bun-format snapshots set it.\ras␍(U+240D).test/js/bun/test/printing/diffexample.test.tsand three more files (5 tests fail on main),snapshot-tests/,expect.test.js. Self-reviewed: 19 concerns raised, 17 addressed.Background
DiffFormatter::newprints both values.print_diff_maindiffs them by line.\nfor every line ending (jest-snapshot'snormalizeNewlines).\r, as in Jest: a terminal overdraws the row, and a GitHub annotation drops the byte. Considered the two characters\r: a backslash prints as it is, so"a\\r"prints the same.Downsides
\rchanges:␍shows where main broke the line or printed nothing. Stored copies need an update.␍in a value draws like a\r: only row marks and colours tell them apart.expect.toSatisfy()still rewrites\rin its message.\rpays +118 to +331 (+0.4% to +1.0%). Release.textgrows 1,024 bytes.Notes
Messages (no colours). No issue reports this. The empty diff shows in every release from 1.2.20 to 1.4.4.
<CR>is the raw byte)expect("a\r\nb").toBe("a\nb")"a/b", footer 0/0- "a/+ "a␍/b", footer 1/1- a/+ a<CR>/bexpect({ s: "a\r\nb" }).toEqual({ s: "a\nb" })- "a/+ "a␍in the object rows, 1/1- "s": "a/+ "s": "a<CR>expect("a\rb").toBe("a\nb")- "a/- b"/+ "a␍b", 2/1- a/- b/+ a<CR>bexpect("ab\r").toBe("ab")- "ab"/+ "ab/+ "Expected: "ab"/Received: "ab␍"<CR>rawexpect("x\r\na\r\ny").toBe("x\r\nb\r\ny")"x/- b/+ a/y""x␍/- b␍/+ a␍/y"x<CR>/- b<CR>/+ a<CR>/ythis.utils.diff({ s: "a\nb" }, { s: "a\r\nb" })Compared values have no visual difference.expect([new Error("a\r\nb")]).toEqual([new Error("a\nb")])+ [Error: a<CR>+ [Error: a␍<CR>rawtoStrictEqual,toMatchObject,toHaveProperty, the CalledWith, ReturnedWith and ResolvedWith matchers,.resolves/.rejects, theExpected: notline, UTF-16 strings, and DOM text, comments and attribute values. It reaches a string inside an array, an object, a Map, a Set, and the sample ofstringContaining,stringMatching,objectContainingand a custom matcher.␍is drawn for every producer of a diff, also for the ones that wrote the raw byte before: an Error message, a Symbol description, the characters of a String object, a 16-bit property key,this.utils.diffof two strings, and the diff of a snapshot that does not match.Received: "ab<CR>"as"eceived: "ab, andgithub_action_writerdrops the byte, so the annotation readExpected: "ab"/Received: "ab".Received: "a\r\nb". Its diff used the console formatter, which escapes a backslash too.␍. Soexpect("a\r\nb").toBe("a␍\nb")prints- "a␍/+ "a␍/b"with a 1/1 footer, and not an empty diff.\rthat is the only change has the inverse style of a whitespace-only change. A␍of the value has the colour of its row.toContain,toMatch,toBeNulland the like) does not change.make_formatterprints it with theconsole.logformatter, which writes a\ras the two characters\r. Of 35 failing assertions on values with a\r, 33 have no diff, and those messages are byte-identical on main and here.What is not in this PR
expect.toSatisfy()(bun test: Vitest compatibility #44833, in no release yet).custom_printprints it throughprint_snapshot, which rewrites\rin a failure message too. Only twotoSatisfymatchers whose messages differ in line endings show it. Its text is also stored in snapshots, so a change there needs the calling formatter's state and a decision on the stored form.undefined(bun:test: print array holes as empty slots in diffs and snapshots #37783),URLandHeaders(bun:test: print Headers entries and the href of a URL in failure messages #47611), two functions or symbols, and a value that this printer does not print (the sample ofexpect.arrayContaining, the cause of an Error). bun:test: say when both values print the same, and when printing stopped, instead of an empty diff #37764 replaces the empty diff for all of them withCompared values have no visual difference.bun test#23158.github_action_writerstill drops a lone\rfrom other messages (GitHub Actions annotations: percent-encode % and a lone CR in the body #38268).toBeNull,toContain, a list of calls).print_in_messagekeeps the raw\rthere, as on main.this.utils.stringify()returns the same text.Open PRs on the same lines
ifof the string arm (has_snapshot_line_breaks()) and moves a nested multi-line string to the line of its key. The second of the two to land keeps both conditions and regenerates the nested rows ofdiffexample.test.tsandexpect-extend.test.js. The rowexpect("x").toEqual(expect.stringContaining("a\rb"))here still needs this PR's condition after it.Compared values have no visual difference.forexpect("a\r\nb").toBe("a\nb")in its row "toBe of two line endings". With both merged, that row has to expect the rows above.output_length. This PR has an explicit field because the DOM printer is in another module and bun:test: say when both values print the same, and when printing stopped, instead of an empty diff #37764 renamesoutput_length. Whichever lands first can carry the one marker.console.logformatter #46729 moves this printer intosrc/jsc/ConsoleObject.rs. It needs the same field there.Self-review: not taken
bun test#23158 and GitHub Actions annotations: percent-encode % and a lone CR in the body #38268 already track them.Measurements (main 6a38049 against this PR, linux x64.
perf,valgrind,straceandbloatyare not installed here. The snapshot comparison ran on the base before, 271b65e, with the same printer code.)toBe+toEqual: 4,224.4 instructions per pair on main, 4,222.3 here (user instructions fromperf_event_open, slope between 1e6 and 2e6 pairs, run-to-run spread 2.5).toEqualwith a visible difference and no\r: one-line strings 33,561 to 33,678 per message (+118, the two\rsearches of the diff printer). Two LF strings 34,423 to 34,753 (+331, also one more\nsearch per multi-line string in the arm).llvm-size -A):.text110,640,793 to 110,641,817 (+1,024),.rodata+0, stripped file 140,482,848 to 140,482,848 (+0). Tag::String arm 5,010 to 5,033 bytes. ThefmtofDiffFormatter, which holdsprint_diff_main, 5,461 to 5,619. Newpush_with_visible_carriage_returns164.size_of::<pretty_format::Formatter>: 304 to 304 bytes (gdb, debug builds).\r,\n,\r\n, backslashes and quotes in strings, keys, Map, Set, DOM nodes, Error, Symbol, with and without a serializer, 573,873 bytes, written by main and by this PR). The PR build reads the main-written files: 11,000 of 11,000 pass.new String("a€b"). No other value fails. Bun 1.4.3 printed bytes of the UTF-16 data for them ("1": "\x00","2": "¬"). 9b4284f (Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021, in 1.4.4) prints the code units, so 1.4.4 fails the same 400. Main and this PR write the same 804,988 bytes for this corpus. An earlier text of this line gave 96 of 8,500 from a corpus that was not kept.\r: 0 differing bytes (the twodiffexamplefixtures, with and without colours).toEqualmessage (gdb hit counts, debug builds, slope between 10,000 and 20,000 messages): 16 to 16 for a visible difference without\r, 15 to 16 when only the line ending differs. Getter runs per side: 2 to 2."10%\r20%"against"10%\r30%","ab\r"against"ab"): stderr 0 to 0, GitHub annotation 0 to 0,junit.xml0 to 0.␍in each: 4, 4 and 8. Main shows the first pair as equal everywhere.\nadds a condition and costs +7.0 per snapshot string.Tests
diffexample.test.ts: 21 failing assertions run in a child process, without colours, with colours and in agent mode. Each message is compared byte for byte.expect-extend.test.js:this.utils.diffof two objects and of two strings.dom-nodes.test.ts: text, comment and attribute rows, and a snapshot file that main wrote for a node with\r.existing-snapshots.test.ts: a snapshot that bun 1.4.2 wrote for strings with\rstill passes.did_older_bun_printthe 1.4.2 snapshot fails, with\rkept in a stored DOM snapshot the snapshot file fails (the inline snapshot of the same node still passes), and without the line-break condition thestringContainingand array rows fail. With the\rrewritten before the diff (the first commit of this PR), the row of a literal␍prints the empty diff and fails.expect.test.js,vitest-apis.test.ts,spyMatchers.test.ts,mock-fn.test.js,expect-deferred.test.ts,jest-extended.test.js,test-test.test.ts,bun_test.test.ts,vitest-flavor.test.ts,dom-nodes-jest.test.js,test/regression/issue/18028.test.ts,test/js/bun/test/expect/,test/js/bun/test/snapshot-tests/,test/js/junit-reporter/junit.test.js,test/cli/test/bun-test.test.ts. In the snapshot suite, "error snapshots" fails with and without this change when stderr is not a terminal.packages/bun-vscodeparses these messages (processErrorData). No row holds a raw\rnow. This is from a read of the code, not from a run.[human-review] gate passed · iteration 0 · 9 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file