Skip to content

Strategy-aware diff output for failing tests#127

Open
DmitrySharabin wants to merge 10 commits intomainfrom
diff-output-ux
Open

Strategy-aware diff output for failing tests#127
DmitrySharabin wants to merge 10 commits intomainfrom
diff-output-ux

Conversation

@DmitrySharabin
Copy link
Copy Markdown
Member

@DmitrySharabin DmitrySharabin commented Apr 23, 2026

Summary

Replaces the old single-layout char-diff for failing tests with a strategy-aware formatDiff module that picks a layout based on value shape, and a cleanupSemantic post-pass that suppresses coincidental char-diff noise.

Layouts

  • Short values → inline char-diff (Got X, expected Y).
  • Long single-line text (>40 chars, contains spaces) → two-line word-diff in an Actual: / Expected: block.
  • Multi-line stringified values → unified -/+ block under an Actual ↔ Expected: header, with:
    • Paired char-diff on matched-count removed/added blocks; dissimilar pairs collapse automatically to a single remove + single add via the cleanup pass.
    • Plain fallback when counts don't match (pure add, pure remove, or mixed mismatch).
    • Context elision: common runs > 2 lines collapse to … N matching lines ….
    • Changed lines carry a neutral gray (lightblack) background so the eye can spot at a glance which lines differ; red/green per-chunk highlights stack on top of that tint and mark the precise edits. Gray is used rather than lightred / lightgreen for two reasons — with the 16-color ANSI palette we're limited to, the per-chunk red/green highlights wouldn't pop against a same-hue line background, and a green/red line tint would read as "the whole line was added/removed" even when only part of it changed.
  • Type mismatches → short header + both values, no diff.
  • Stringified-identical but unequal values → both values shown with a type annotation and a (values are stringified identically but not equal) hint. Hit most often when a custom shallow check sees distinct references with identical content.
  • Whitespace-only changes → background highlight so they stay visible (unchanged from before).

Cleanup pass

diffChars (Myers) paints coincidental shared chars as "common" — e.g. for alignment vs padding it finds a/i/n and shreds the swap into 1-char islands. A cleanupSemantic helper post-processes diffChars output: a common chunk survives as a boundary only when it's longer than the edits on either adjacent side; shorter commons dissolve into surrounding edits so the region collapses to one removed + one added block. Same idea as diff-match-patch's diff_cleanupSemantic, narrowed to our shape.

This removes the need for a similarity threshold in formatBlock — matched-count pairs always go through the paired path; cleanup keeps single-char precision where the change is genuinely localized (username vs userName, v1.2.3 vs v1.2.4) and collapses the swap where it isn't (alignment/padding).

Wire-up

TestResult.js delegates failure formatting to formatDiff(actual, expected, unmapped). The legacy formatDiff helper in src/util.js is removed. Pre-map values are passed through an optional unmapped argument and rendered alongside the diff in each layout.

Tests

tests/format-diff.js covers every strategy boundary: type mismatch, stringified-identical, inline char-diff (with and without unmapped), long word-diff (with and without unmapped), inline multi-line string, elision, paired char-diff with unmapped, noisy-swap collapse via cleanup, multiple paired lines, unequal counts, pure add, pure remove.

Dependencies

diff bumped to ^8.0.4 (CDN URL kept in sync with package.json). diffWordsWithSpace is used for the long-single-line layout so whitespace survives as part of token boundaries.

image image image

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 23, 2026

Deploy Preview for h-test ready!

Name Link
🔨 Latest commit 952675e
🔍 Latest deploy log https://app.netlify.com/projects/h-test/deploys/69eb504e32a8c80008695fca
😎 Deploy Preview https://deploy-preview-127--h-test.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

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.
@LeaVerou
Copy link
Copy Markdown
Member

Good idea.
However, character-level diff is still useful even in multiline mode, since you could have long lines with only a few characters that actually changed.
All changes should have a background highlight I think, not just whitespace changes. Then, in multiline view we can use background for the whole line and text color for the part that actually changed.

In fact, for some diffs I can see value in a "compact" view where you only see the unchanged bits once and the changed bits are inline. Perhaps the default could take into account size of total output vs % of changes.

I also wonder if it would be useful to have an optional override that lets users explicitly control this.

@DmitrySharabin
Copy link
Copy Markdown
Member Author

However, character-level diff is still useful even in multiline mode, since you could have long lines with only a few characters that actually changed.
All changes should have a background highlight I think, not just whitespace changes. Then, in multiline view we can use background for the whole line and text color for the part that actually changed.

Agreed. Let's concentrate on this first. This will already be a big improvement to what we have now.

In fact, for some diffs I can see value in a "compact" view where you only see the unchanged bits once and the changed bits are inline. Perhaps the default could take into account size of total output vs % of changes.

Good idea. I'm afraid, though, that even with this feature in place, the interactive output might be broken on long diffs. I'd fix the terminal UX first. I wonder if we can do something like LLMs do in the terminal. Is there anything better than log-update that suits our needs? 🤔

Comment thread src/classes/TestResult.js Outdated
const { diffChars } = await import(IS_NODEJS ? "diff" : "https://cdn.jsdelivr.net/npm/diff@7.0.0/lib/index.es6.js");
import { stripFormatting } from "../format-console.js";
import { delay, formatDuration, interceptConsole, pluralize, stringify } from "../util.js";
import { formatDiff } from "../format-diff.js";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in a util/ directory?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have the util/ directory for now. format-console.js should probably live there as well. We can add it starting from this PR and move the rest of the util methods there in the follow-up PRs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Address PR #127 review feedback from @LeaVerou:

- colorize(): single `<bg side-color><b>` primitive for all changed
  runs; drop per-token fg color and the whitespace-only branch.
- lineDiff() multiline changed lines wrap in `<bg lightblack>`;
  chunk bg stacks over the neutral line bg so changed chars pop.
- Isolated -/+ pair switches from diffWords to diffChars so small
  changes in long stringified lines no longer repaint whole tokens.

sideBySide long-line path keeps diffWords — prose-like content where
word unit matches semantic unit.
Non-isolated `-`/`+` blocks previously rendered plain. Now they
interleave char-diff pairs when counts match AND every paired line
clears the `PAIR_SIMILARITY` threshold — per-char overlap ratio via
`diffChars` common runs. Forced char-diff on unrelated lines would
paint coincidental overlaps as "common" and mislead, so the check
is all-or-nothing per block.

Other changes:

- `colorize` simplified: single `<bg side-color><b>` primitive for
  all changed runs; no fg color; no whitespace-only branch.
- `lineDiff` uses `formatBlock(removed, added)` which subsumes the
  old `isolatedPair` special case (1:1 similar pair via diffChars,
  everything else plain).
- `diffChars` result is reused between similarity check and
  rendering — halves diff calls on the paired-block path.
- File reorganized: public entry, strategies, formatting helpers,
  pure utilities.
Three fixtures cover the block-level pair decision in formatBlock:

- Similar lines pair up — char-diff interleaved
- Dissimilar lines stay plain — removes then adds, no char-diff
- Unequal counts stay plain — no safe pairing

Also updates expected strings of existing fixtures to match the new
bg-based diff highlighting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants