Skip to content

Commit cc5c6f5

Browse files
Add strategy-aware diff output for failing tests
New src/format-diff.js picks an output strategy by value shape: - Short values: inline char-level diff (`Got X, expected Y`) - Long single-line text (>40 chars, contains spaces): word-level diff in two-line ` Actual:` / ` Expected:` layout - Multi-line stringified values: unified -/+ block under ` Actual ↔ Expected:` header, with token highlighting on isolated -/+ pairs and elision of matching runs (2 lines context) - Type mismatches: short header plus both values, no diff - Whitespace-only changes get background highlight TestResult.js delegates to the new module; legacy formatDiff helper in src/util.js removed. Tests in tests/format-diff.js cover strategy boundaries and edge cases.
1 parent d0e3e0c commit cc5c6f5

4 files changed

Lines changed: 382 additions & 86 deletions

File tree

src/classes/TestResult.js

Lines changed: 13 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import Test from "./Test.js";
22
import BubblingEventTarget from "./BubblingEventTarget.js";
3-
import format, { stripFormatting } from "../format-console.js";
4-
import { delay, formatDuration, interceptConsole, pluralize, stringify, formatDiff } from "../util.js";
5-
import { IS_NODEJS } from "../util.js";
6-
7-
// Make the diff package available both in Node.js and the browser
8-
const { diffChars } = await import(IS_NODEJS ? "diff" : "https://cdn.jsdelivr.net/npm/diff@7.0.0/lib/index.es6.js");
3+
import { stripFormatting } from "../format-console.js";
4+
import { delay, formatDuration, interceptConsole, pluralize, stringify } from "../util.js";
5+
import { formatDiff } from "../format-diff.js";
96

107
/**
118
* Represents the result of a test or group of tests.
@@ -294,54 +291,23 @@ ${ this.error.stack }`);
294291
}
295292
else {
296293
let actual = this.mapped?.actual ?? this.actual;
297-
let actualString = stringify(actual);
298-
299294
let message;
300-
if ("expect" in test) {
301-
let expect = this.mapped?.expect ?? test.expect;
302-
let expectString = stringify(expect);
303-
304-
let changes = diffChars(actualString, expectString);
305-
306-
// Calculate output lengths to determine formatting style
307-
let actualLength = actualString.length;
308-
if (this.mapped && actual !== this.actual) {
309-
actualLength += stringify(this.actual).length;
310-
}
311-
312-
let expectedLength = expectString.length;
313-
if (this.mapped && expect !== test.expect) {
314-
expectedLength += stringify(test.expect).length;
315-
}
316-
317-
// TODO: Use global (?) option instead of the magic number 40
318-
let inline = Math.max(actualLength, expectedLength) <= 40;
319-
if (inline) {
320-
message = `Got ${ formatDiff(changes) }`;
321-
if (this.mapped && actual !== this.actual) {
322-
message += ` <dim>(${ stringify(this.actual) } unmapped)</dim>`;
323-
}
324295

325-
message += `, expected ${ formatDiff(changes, { expected: true }) }`;
326-
if (this.mapped && expect !== test.expect) {
327-
message += ` <dim>(${ stringify(test.expect) } unmapped)</dim>`;
328-
}
329-
}
330-
else {
331-
// Vertical format for long values
332-
message = "\n Actual: " + formatDiff(changes);
333-
if (this.mapped && actual !== this.actual) {
334-
message += `\n\t\t <dim>${ stringify(this.actual) } unmapped</dim>`;
296+
if ("expect" in test) {
297+
let expected = this.mapped?.expect ?? test.expect;
298+
let unmapped = {};
299+
if (this.mapped) {
300+
if (actual !== this.actual) {
301+
unmapped.actual = this.actual;
335302
}
336-
337-
message += "\n Expected: " + formatDiff(changes, { expected: true });
338-
if (this.mapped && expect !== test.expect) {
339-
message += `\n\t\t <dim>${ stringify(test.expect) } unmapped</dim>`;
303+
if (expected !== test.expect) {
304+
unmapped.expected = test.expect;
340305
}
341306
}
307+
message = formatDiff(actual, expected, unmapped);
342308
}
343309
else {
344-
message = `Got ${ actualString }`;
310+
message = `Got ${ stringify(actual) }`;
345311
if (this.mapped && actual !== this.actual) {
346312
message += ` <dim>(${ stringify(this.actual) } unmapped)</dim>`;
347313
}

src/format-diff.js

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { IS_NODEJS, getType, pluralize, stringify } from "./util.js";
2+
import { stripFormatting } from "./format-console.js";
3+
4+
// Dual Node/browser import. Kept version in the CDN URL in sync with package.json.
5+
// `diffWordsWithSpace` keeps whitespace as part of token boundaries so the
6+
// reconstructed string matches the input; plain `diffWords` collapses it.
7+
const { diffChars, diffLines, diffWordsWithSpace: diffWords } = await import(
8+
IS_NODEJS
9+
? "diff"
10+
: "https://cdn.jsdelivr.net/npm/diff@8.0.4/lib/index.es6.js"
11+
);
12+
13+
/** @typedef {{value: string, added?: boolean, removed?: boolean}} Change */
14+
15+
/** Unchanged lines kept around each change (diff context). */
16+
const CONTEXT = 2;
17+
18+
/** Longer single-line text switches to two-line word-diff layout. */
19+
const INLINE_MAX = 40;
20+
21+
/** Per-side color, change-action key, and output label for diff formatting. */
22+
const sides = {
23+
actual: { color: "red", action: "removed", label: " Actual: " },
24+
expected: { color: "green", action: "added", label: " Expected: " },
25+
};
26+
27+
export function formatDiff (actual, expected, unmapped = {}) {
28+
let actualType = getType(actual);
29+
let expectedType = getType(expected);
30+
31+
if (actualType !== expectedType) {
32+
return typeMismatch(actual, expected, actualType, expectedType, unmapped);
33+
}
34+
35+
let actualString = stringify(actual);
36+
let expectedString = stringify(expected);
37+
38+
if (actualString === expectedString) {
39+
let actualLabel = sides.actual.label;
40+
let expectedLabel = sides.expected.label;
41+
42+
return [
43+
`${ actualLabel }${ actualString } <dim>(${ actualType })</dim>`,
44+
`${ expectedLabel }${ expectedString } <dim>(${ expectedType })</dim>`,
45+
` <dim>(values are stringified identically but not equal)</dim>`,
46+
].join("\n");
47+
}
48+
49+
if (actualString.includes("\n") || expectedString.includes("\n")) {
50+
return lineDiff(actualString, expectedString, unmapped);
51+
}
52+
53+
let long =
54+
Math.max(actualString.length, expectedString.length) > INLINE_MAX
55+
&& actualString.includes(" ") && expectedString.includes(" ");
56+
57+
return sideBySide(actualString, expectedString, unmapped, !long);
58+
}
59+
60+
/**
61+
* Format a user-supplied value as a dim "unmapped" annotation. Four styles
62+
* match the four layouts that carry unmapped hints.
63+
*/
64+
function formatUnmapped (value, style = "inline") {
65+
value = stripFormatting(stringify(value));
66+
let ret = ` <dim>(${value} unmapped)</dim>`;
67+
68+
if (style === "gutter") {
69+
ret = ` <dim>${value} unmapped</dim>`;
70+
}
71+
else if (style === "actual") {
72+
ret = ` <dim>Actual unmapped: ${value}</dim>`;
73+
}
74+
else if (style === "expected") {
75+
ret = ` <dim>Expected unmapped: ${value}</dim>`;
76+
}
77+
78+
return ret;
79+
}
80+
81+
function typeMismatch (actual, expected, actualType, expectedType, unmapped) {
82+
let values = { actual, expected };
83+
let lines = [`Got ${ actualType }, expected ${ expectedType }`];
84+
85+
for (let side in sides) {
86+
let { label } = sides[side];
87+
let line = label + stringify(values[side]);
88+
89+
if (side in unmapped) {
90+
line += formatUnmapped(unmapped[side]);
91+
}
92+
93+
lines.push(line);
94+
}
95+
96+
return lines.join("\n");
97+
}
98+
99+
/**
100+
* Inline (`Got X, expected Y`) or two-line (` Actual:` / ` Expected:`) layout,
101+
* driven by char-diff or word-diff depending on `inline`.
102+
*/
103+
function sideBySide (actualString, expectedString, unmapped, inline) {
104+
let changes = (inline ? diffChars : diffWords)(actualString, expectedString);
105+
let actual = colorize(changes, "actual");
106+
let expected = colorize(changes, "expected");
107+
108+
if (inline) {
109+
let left = `Got ${ actual }`;
110+
if ("actual" in unmapped) {
111+
left += formatUnmapped(unmapped.actual);
112+
}
113+
114+
let right = `expected ${ expected }`;
115+
if ("expected" in unmapped) {
116+
right += formatUnmapped(unmapped.expected);
117+
}
118+
119+
return `${ left }, ${ right }`;
120+
}
121+
122+
let formatted = { actual, expected };
123+
let lines = [];
124+
125+
for (let side in sides) {
126+
let { label } = sides[side];
127+
128+
lines.push(label + formatted[side]);
129+
if (side in unmapped) {
130+
lines.push(formatUnmapped(unmapped[side], "gutter"));
131+
}
132+
}
133+
134+
return "\n" + lines.join("\n");
135+
}
136+
137+
/**
138+
* Format one side of a change array. Whitespace-only runs get `<bg>` so
139+
* whitespace-only diffs stay visible.
140+
*
141+
* Without `prefix`: per-token `<c color>` wrap, common parts uncolored.
142+
* With `prefix`: one outer `<c color>` wraps the whole line with `prefix` in front,
143+
* tokens inside use plain `<b>`. Use this when a line is already committed to one side.
144+
*/
145+
function colorize (changes, side, prefix) {
146+
let { color, action } = sides[side];
147+
let ret = "";
148+
149+
for (let change of changes) {
150+
if ((change.added || change.removed) && !change[action]) {
151+
continue;
152+
}
153+
154+
if (!change[action]) {
155+
ret += change.value;
156+
continue;
157+
}
158+
159+
for (let part of change.value.split(/(\s+)/)) {
160+
if (!part) {
161+
continue;
162+
}
163+
if (/^\s+$/.test(part)) {
164+
ret += `<bg ${ color }>${ part }</bg>`;
165+
}
166+
else {
167+
ret += prefix ? `<b>${ part }</b>` : `<c ${ color }><b>${ part }</b></c>`;
168+
}
169+
}
170+
}
171+
172+
return prefix ? `<c ${ color }>${ prefix } ${ ret }</c>` : ret;
173+
}
174+
175+
function lineDiff (actualString, expectedString, unmapped) {
176+
// Flatten diffLines output to one entry per line. Each chunk ends with a
177+
// trailing `\n`; splitting produces a spurious empty tail we drop.
178+
let entries = [];
179+
for (let change of diffLines(actualString, expectedString)) {
180+
let prefix = change.added ? "+" : change.removed ? "-" : " ";
181+
let side = change.added ? "expected" : change.removed ? "actual" : "common";
182+
let texts = change.value.split("\n");
183+
if (texts.at(-1) === "") {
184+
texts.pop();
185+
}
186+
for (let text of texts) {
187+
entries.push({ prefix, text, side });
188+
}
189+
}
190+
191+
let hunks = extractHunks(entries);
192+
let lines = [" Actual ↔ Expected:"];
193+
194+
for (let hunk of hunks) {
195+
if (hunk.elidedBefore > 0) {
196+
lines.push(elision(hunk.elidedBefore));
197+
}
198+
199+
for (let i = 0; i < hunk.lines.length; i++) {
200+
let entry = hunk.lines[i];
201+
let prev = hunk.lines[i - 1];
202+
let next = hunk.lines[i + 1];
203+
let after = hunk.lines[i + 2];
204+
205+
// Pair only a lone `-` with a lone `+`. Multi-line blocks stay plain:
206+
// token-level highlighting across arbitrary alignments would mislead.
207+
let isolatedPair =
208+
entry.prefix === "-" && next?.prefix === "+"
209+
&& (!prev || prev.prefix !== "-")
210+
&& (!after || after.prefix !== "+");
211+
212+
if (isolatedPair) {
213+
let changes = diffWords(entry.text, next.text);
214+
lines.push(colorize(changes, "actual", "-"));
215+
lines.push(colorize(changes, "expected", "+"));
216+
i++;
217+
}
218+
else if (entry.side === "common") {
219+
lines.push(` ${ entry.text }`);
220+
}
221+
else {
222+
let { color } = sides[entry.side];
223+
lines.push(`<c ${ color }>${ entry.prefix } ${ entry.text }</c>`);
224+
}
225+
}
226+
}
227+
228+
let lastHunk = hunks.at(-1);
229+
if (lastHunk?.elidedAfter > 0) {
230+
lines.push(elision(lastHunk.elidedAfter));
231+
}
232+
233+
for (let [side, value] of Object.entries(unmapped)) {
234+
lines.push(formatUnmapped(value, side));
235+
}
236+
237+
return "\n" + lines.join("\n");
238+
}
239+
240+
function elision (count) {
241+
return ` <dim>… ${ count } matching ${ pluralize(count, "line", "lines") } …</dim>`;
242+
}
243+
244+
/**
245+
* Group changed entries with up to `CONTEXT` common lines on each side.
246+
* Overlapping windows merge; gaps ≤ `2 * CONTEXT + 1` stay in place (an
247+
* elision marker would take as many rows as the lines it hides).
248+
*/
249+
function extractHunks (entries) {
250+
let last = entries.length - 1;
251+
let merged = [];
252+
for (let i = 0; i < entries.length; i++) {
253+
if (entries[i].side === "common") {
254+
continue;
255+
}
256+
let start = Math.max(0, i - CONTEXT);
257+
let end = Math.min(last, i + CONTEXT);
258+
let prev = merged.at(-1);
259+
if (prev && start - prev[1] <= 1) {
260+
// Windows overlap or touch — fold into the previous hunk.
261+
prev[1] = Math.max(prev[1], end);
262+
}
263+
else {
264+
merged.push([start, end]);
265+
}
266+
}
267+
if (merged.length === 0) {
268+
return [];
269+
}
270+
271+
let hunks = merged.map(([start, end], i) => {
272+
let prevEnd = i === 0 ? -1 : merged[i - 1][1];
273+
let elidedBefore = start - prevEnd - 1;
274+
// Leading common lines that fit in the context budget get shown —
275+
// the marker would take as many rows as the lines it would hide.
276+
if (i === 0 && elidedBefore <= CONTEXT) {
277+
start = 0;
278+
elidedBefore = 0;
279+
}
280+
return { lines: entries.slice(start, end + 1), elidedBefore, elidedAfter: 0 };
281+
});
282+
283+
let [lastStart, lastEnd] = merged.at(-1);
284+
let trailing = last - lastEnd;
285+
if (trailing > CONTEXT) {
286+
hunks.at(-1).elidedAfter = trailing;
287+
}
288+
else {
289+
hunks.at(-1).lines = entries.slice(lastStart);
290+
}
291+
292+
return hunks;
293+
}

0 commit comments

Comments
 (0)