Evidence from a lightning-demo teardown (.ai/lightning-demos.md) — every mechanism below was executed on a real machine, not read about. Transcripts are in that file.
Two failure moments drove this, both measured on real customers:
- M1 — the miss. A green bar on a deliberately broken page.
- M2 — the accept. The most frequent daily action, and we have no verb for it —
grep -rni "def accept|approve|update_baseline" lib/ returns nothing, there are no rake tasks and no exe/.
#255 and #257 fixed the silence (we now warn loudly and the docs are true). This issue is about the shape.
1. Write the baseline on a miss — and fail anyway
Playwright's default missing mode, verified live:
Error: A snapshot doesn't exist at /.../homepage-darwin.png, writing actual.
1 failed
toMatchSnapshot.ts:211 returns a literal false — the test fails while the file is written. Second run: 1 passed.
The user never has to learn how to record; recording happened. They get a red bar plus a file on disk, which is the honest signal: I could not verify this, here is what I saw, look at it and re-run.
This needs a decision, because it changes a default. Our fail_if_new defaults to ENV["CI"], so locally a new screenshot passes. Playwright's position is that a snapshot with no baseline has not been checked, so it is red. I think Playwright is right and both our customer personas were burned exactly here — but it makes "first run always passes" (README Quick Start) no longer true, and every existing user adding a test would see one new red bar. Wants 4-eyes before implementing.
The trap, stated by the researcher: writing-on-missing is safe because the run fails. Adopt the write without keeping the failure and we ship M1 as a feature.
2. rake snap_diff:accept — re-run, don't copy
Three independent tools converged on this shape: Playwright --last-failed -u, BackstopJS backstop approve (45 lines, promotes only failed_diff_*), Lost Pixel's update mode. RSpec's example_status_persistence_file_path is the Ruby-native precedent our users already know.
The property worth stealing: it re-runs the test rather than copying the previous run's artifact, so you cannot promote a baseline that no longer reproduces. Add FILTER= (BackstopJS has --filter) and narrate before acting.
The local equivalent of Percy's "approve" is git add of a file the tool just regenerated and re-verified for you. The verb is missing; the concept already fits.
3. Rewrite both messages (VCR + WebMock + SimpleCov)
VCR's unhandled-request error, produced live, has four properties we have none of: it restates the effective configuration back at you (:record => :none, :match_requests_on), enumerates every escape route including "if you're surprised, here's how to debug", and uses versioned doc links (?v=6-4-0) that cannot point at docs for an API you do not have.
WebMock goes further and emits the fix, pre-filled from this run's real values, rather than describing it.
Design rule, and it is why RECORD_SCREENSHOTS rotted for years: never print a command in an error message that is not generated from live state.
SimpleCov's threshold failure earns every one of its four lines — what it is, what was required, where to look first, and a distinct exit code 2 for "the tool ran fine, your content failed the gate".
4. Labelled, relative, ordered paths
Expected: tests/vis.spec.js-snapshots/homepage-darwin.png
Received: test-results/.../homepage-actual.png
Diff: test-results/.../homepage-diff.png
Ours prints four unlabelled absolute paths and the reader guesses. Playwright omits Diff: when there is none — the block is honest about what exists.
5. A third state in the summary line
Argos names the no-baseline state orphan — not a pass, not a failure. Ours should count it:
14 screenshots verified, 0 changed, 1 new (not verified)
You cannot read that and believe the page was checked. Highest value-per-byte item here.
The anti-lesson, from the market leader
Percy exits 0 when it silently does nothing:
$ npx @percy/cli exec -- echo hi
[percy] Skipping visual tests
[percy] Error: Missing Percy token
[percy] Command "echo hi" exited with status: 0
$ echo $?
0
It prints the word Error, says it is skipping every visual test, and exits successfully. A CI job that loses its token goes green forever. That is our M1 in its purest form, shipped by the biggest name in the category.
Contrast Chromatic, where every route to a green bar without a real comparison has its own named, branch-scoped flag — --auto-accept-changes, --exit-zero-on-changes, --skip. You cannot land there by accident, and a reviewer reading CI config sees it.
Rule: misconfiguration must be a non-zero exit.
Cost
Items 1–5 are one config key, one rake task, two rewritten methods and a counter. No subsystem. Target 2.1; items 4 and 5 are cheap enough to consider for 2.0 if the release slips.
Deliberately not stealing: Percy/Chromatic/Argos dashboards (need a service), TurboSnap dependency tracing (four flags to configure one feature), jest-image-snapshot's terminal image embedding (two options and a terminal allow-list for something a labelled path does for everyone), a .snap_diff_todo.yml (our accepted state is already PNGs in git — more reviewable than a YAML exclusion list).
Also worth a follow-up: stale-baseline detection (jest-image-snapshot's OutdatedSnapshotReporter, ~20 lines). First version must report only, never delete — obsolescence is only knowable from a full run, and deleting after a filtered run destroys baselines.
Evidence from a lightning-demo teardown (
.ai/lightning-demos.md) — every mechanism below was executed on a real machine, not read about. Transcripts are in that file.Two failure moments drove this, both measured on real customers:
grep -rni "def accept|approve|update_baseline" lib/returns nothing, there are no rake tasks and noexe/.#255 and #257 fixed the silence (we now warn loudly and the docs are true). This issue is about the shape.
1. Write the baseline on a miss — and fail anyway
Playwright's default
missingmode, verified live:toMatchSnapshot.ts:211returns a literalfalse— the test fails while the file is written. Second run:1 passed.The user never has to learn how to record; recording happened. They get a red bar plus a file on disk, which is the honest signal: I could not verify this, here is what I saw, look at it and re-run.
This needs a decision, because it changes a default. Our
fail_if_newdefaults toENV["CI"], so locally a new screenshot passes. Playwright's position is that a snapshot with no baseline has not been checked, so it is red. I think Playwright is right and both our customer personas were burned exactly here — but it makes "first run always passes" (README Quick Start) no longer true, and every existing user adding a test would see one new red bar. Wants 4-eyes before implementing.The trap, stated by the researcher: writing-on-missing is safe because the run fails. Adopt the write without keeping the failure and we ship M1 as a feature.
2.
rake snap_diff:accept— re-run, don't copyThree independent tools converged on this shape: Playwright
--last-failed -u, BackstopJSbackstop approve(45 lines, promotes onlyfailed_diff_*), Lost Pixel'supdatemode. RSpec'sexample_status_persistence_file_pathis the Ruby-native precedent our users already know.The property worth stealing: it re-runs the test rather than copying the previous run's artifact, so you cannot promote a baseline that no longer reproduces. Add
FILTER=(BackstopJS has--filter) and narrate before acting.The local equivalent of Percy's "approve" is
git addof a file the tool just regenerated and re-verified for you. The verb is missing; the concept already fits.3. Rewrite both messages (VCR + WebMock + SimpleCov)
VCR's unhandled-request error, produced live, has four properties we have none of: it restates the effective configuration back at you (
:record => :none,:match_requests_on), enumerates every escape route including "if you're surprised, here's how to debug", and uses versioned doc links (?v=6-4-0) that cannot point at docs for an API you do not have.WebMock goes further and emits the fix, pre-filled from this run's real values, rather than describing it.
SimpleCov's threshold failure earns every one of its four lines — what it is, what was required, where to look first, and a distinct exit code 2 for "the tool ran fine, your content failed the gate".
4. Labelled, relative, ordered paths
Ours prints four unlabelled absolute paths and the reader guesses. Playwright omits
Diff:when there is none — the block is honest about what exists.5. A third state in the summary line
Argos names the no-baseline state orphan — not a pass, not a failure. Ours should count it:
You cannot read that and believe the page was checked. Highest value-per-byte item here.
The anti-lesson, from the market leader
Percy exits 0 when it silently does nothing:
It prints the word
Error, says it is skipping every visual test, and exits successfully. A CI job that loses its token goes green forever. That is our M1 in its purest form, shipped by the biggest name in the category.Contrast Chromatic, where every route to a green bar without a real comparison has its own named, branch-scoped flag —
--auto-accept-changes,--exit-zero-on-changes,--skip. You cannot land there by accident, and a reviewer reading CI config sees it.Rule: misconfiguration must be a non-zero exit.
Cost
Items 1–5 are one config key, one rake task, two rewritten methods and a counter. No subsystem. Target 2.1; items 4 and 5 are cheap enough to consider for 2.0 if the release slips.
Deliberately not stealing: Percy/Chromatic/Argos dashboards (need a service), TurboSnap dependency tracing (four flags to configure one feature), jest-image-snapshot's terminal image embedding (two options and a terminal allow-list for something a labelled path does for everyone), a
.snap_diff_todo.yml(our accepted state is already PNGs in git — more reviewable than a YAML exclusion list).Also worth a follow-up: stale-baseline detection (jest-image-snapshot's
OutdatedSnapshotReporter, ~20 lines). First version must report only, never delete — obsolescence is only knowable from a full run, and deleting after a filtered run destroys baselines.