Skip to content

fix: never print a path or command that is not derived from live state (#260) - #267

Merged
pftg merged 2 commits into
masterfrom
fix/live-state-and-config-precedence
Aug 24, 2026
Merged

fix: never print a path or command that is not derived from live state (#260)#267
pftg merged 2 commits into
masterfrom
fix/live-state-and-config-precedence

Conversation

@pftg

@pftg pftg commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Two 2.0 items, one commit each.

1. The error names a file that will never exist — fixes #260

No existing screenshot found for <path>! To record it: run the test, then \git add `was unfollowable on the exact run that printed it.build_screenshot_assertioncalledcheck_base_screenshot*before*capture_screenshot, and fail_if_new` defaults to on under CI — so in CI the raise came first and nothing was ever written to the path the message named.

Measured end to end against a real browser (cuprite), before:

$ CI=true  → SnapDiff::ExpectationNotMet: No existing screenshot found for …/issue_260_no_baseline.png!
             ls …/issue_260_no_baseline.png  → no such file
$ CI=       → no exception raised
             ls …/issue_260_no_baseline.png  → file IS on disk

After: the file is on disk in both cases, so git add <path> is a command the user can run right now — and a failing CI job leaves the new screenshot behind for an artifact upload instead of nothing.

Chosen fix: capture first. The alternative — branching the message on whether the file was written — needs two messages kept in sync, and the CI branch still could not offer git add; it would have to send the user somewhere else. Capturing first makes the state match the message in every case, with one code path.

Only the raise moved. The git checkout that drives need_to_compare? stays in check_base_screenshot, and so does the no-baseline warning, which has to read @snapshot.path before the capture overwrites it. Because the two are now separate, the warning would otherwise fire alongside the raise, so check_base_screenshot bows out when fail_if_new is on — the raise says the same thing with the fix attached.

Unguarded seam found by mutation: deleting @snapshot.checkout_base_screenshot outright left the entire unit suite green. Every test stubs Vcs.checkout_vcs, so nothing asserted the matcher ever asks git for the baseline — while need_to_compare?, the warning and the raise all hang off that one call. Added the missing guard.

2. Explicit config outranks the env sniff

fail_if_new stored !ENV["CI"].nil? at require time, which made "the user asked for false" and "CI was absent when the gem loaded" the same false — so the environment, not the user, had the last word. It now has no stored default: nil means nobody said, and only then does the CI sniff answer, read live.

SnapDiff.config.fail_if_new = false   # false, even under CI=true

Same ordering insta narrowed to in insta#924 after Ruff hit it ("normally, CLI flags take precedence over environment variables"), and the inverse of jest#12288, where reading argv.ci instead of detected CI state made CI=1 jest and jest --ci disagree for a whole major version.

The default itself is unchanged. Failing only under CI stays, deliberately — a locally recorded baseline is often worthless across OS. This is a precedence rule only.

test/unit/config_default_timing_test.rb is updated, not deleted: it still pins the two contracts that did not change (root frozen at require time, default_options[:wait] live per call), and its CI probe now pins the new rule instead of the old freeze. The probes are re-run from the v1 entry points by test/legacy/legacy_config_default_timing_test.rb, so the legacy mattr_accessor view gets the same precedence for free.

Evidence

Guard test first for each item; every guard mutation-checked (break it → red → restore with a targeted edit → green):

Mutation Result
raise moved back before the capture red — the message says \git add …` -- that file has to exist by then`
drop the fail_if_new bail-out in check_base_screenshot red — warning fires alongside the raise
delete @snapshot.checkout_base_screenshot green before the new guard, red after
fail_if_new reader ignores the ivar (env always wins) red — explicit false outranks CI=1
fail_if_new frozen back into storage at require red — CI unset after require is seen

rake test:unit (643 runs), rake test:canonical (527 runs), standardrb lib test — all green.

Summary by Sourcery

Make missing-screenshot errors actionable by capturing the screenshot first and give explicit fail_if_new settings precedence over live CI detection.

Bug Fixes:

  • Ensure missing-baseline failures capture and persist the new screenshot before reporting the path and git add instruction.
  • Prevent duplicate no-baseline warnings when fail_if_new raises.

Enhancements:

  • Make explicit fail_if_new configuration take precedence over the live CI environment while preserving CI-based defaults.
  • Add coverage for baseline checkout, actionable error paths, warning suppression, and configuration precedence.

Documentation:

  • Update configuration and architecture documentation to describe live CI fallback and explicit configuration precedence.

Tests:

  • Expand unit and legacy configuration timing tests and screenshot matcher tests for the new behavior.

pftg added 2 commits August 24, 2026 11:08
…260)

`No existing screenshot found for <path>! To record it: run the test, then
`git add <path>`' was unfollowable on the exact run that printed it. In CI
-- the one place `fail_if_new` is on by default -- `check_base_screenshot`
raised BEFORE `capture_screenshot`, so nothing was ever written to that
path. Measured end to end against a real browser:

    $ CI=true  ... => ExpectationNotMet, `ls <path>` -> no such file
    $ CI=      ... => no raise,          `ls <path>` -> file IS on disk

The raise now runs after the capture, so the path exists whenever we name
it and `git add` is a command the user can run right now -- and a failing
CI job leaves the new screenshot behind for an artifact upload instead of
nothing. Only the raise moved: the git checkout that drives
`need_to_compare?`, and the no-baseline warning that has to read
`@snapshot.path` before the capture overwrites it, both stay put. The two
now-separate paths mean the warning would fire alongside the raise, so
`check_base_screenshot` bows out when `fail_if_new` is on -- the raise says
the same thing with the fix attached.

Also guards a seam found by mutation: deleting
`@snapshot.checkout_base_screenshot` outright left the ENTIRE unit suite
green. Everything downstream hangs off that one call, and every test stubs
`Vcs.checkout_vcs`, so nothing asserted the matcher ever asks git.
`fail_if_new` stored `!ENV["CI"].nil?` at require time, which made "the
user asked for false" and "CI was absent when the gem loaded" the same
false -- so the environment, not the user, had the last word. It now has
no stored default: nil means nobody said, and only then does the CI sniff
answer, read live. An explicit setting wins in both directions, whenever
the variable appears; assigning nil hands the setting back to the
environment.

    SnapDiff.config.fail_if_new = false   # false, even under CI=true

Same ordering insta narrowed to in insta#924 after Ruff hit it ("normally,
CLI flags take precedence over environment variables"), and the inverse of
jest#12288, where reading argv.ci instead of detected CI state made
`CI=1 jest` and `jest --ci` disagree for a whole major version.

The default itself is unchanged: failing only under CI stays, deliberately
-- a locally recorded baseline is often worthless across OS.

config_default_timing_test.rb is updated, not deleted: it still pins the
two contracts that did not change (root frozen at require time,
default_options[:wait] live per call), and its CI probe now pins the new
rule instead of the old freeze. The probes run from both the canonical and
the legacy entry points, so the v1 mattr_accessor view gets the same
precedence for free.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @pftg, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 18 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a6693fe9-4d23-4f13-a2fa-bc4ae793dd33

📥 Commits

Reviewing files that changed from the base of the PR and between 9988234 and aaddb19.

📒 Files selected for processing (10)
  • docs/architecture.md
  • docs/configuration.md
  • lib/capybara/screenshot/diff/config_legacy.rb
  • lib/snap_diff/config.rb
  • lib/snap_diff/screenshot_matcher.rb
  • test/legacy/legacy_config_default_timing_test.rb
  • test/unit/config_default_timing_test.rb
  • test/unit/dsl_test.rb
  • test/unit/screenshot_matcher_test.rb
  • test/unit/snap_diff_config_test.rb

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

The PR fixes misleading missing-screenshot guidance by capturing before enforcing fail_if_new, adds a guard that preserves baseline checkout behavior, and changes fail_if_new so explicit configuration outranks a live CI environment fallback while retaining CI-only failure as the default.

Sequence diagram for screenshot capture before missing-baseline enforcement

sequenceDiagram
    participant Matcher as ScreenshotMatcher
    participant Snapshot
    participant Config
    participant Filesystem

    Matcher->>Snapshot: checkout_base_screenshot()
    Snapshot->>Filesystem: Check base_path.exists?
    Matcher->>Matcher: check_base_screenshot()
    Matcher->>Matcher: capture_screenshot(capture_options, comparison_options)
    Matcher->>Filesystem: Write screenshot to path
    Matcher->>Matcher: fail_if_new_screenshot()
    Matcher->>Config: fail_if_new()
    Config-->>Matcher: CI fallback or explicit setting
    alt New screenshot and fail_if_new enabled
        Matcher-->>Matcher: Raise ExpectationNotMet with git add path
    else Existing baseline or failure disabled
        Matcher->>Matcher: need_to_compare?()
    end
Loading

Flow diagram for fail_if_new configuration precedence

flowchart TD
    A[Read fail_if_new] --> B{&#64;fail_if_new is nil?}
    B -- No --> C[Use explicit setting]
    B -- Yes --> D[Read ENV CI live]
    D --> E{CI is non-empty?}
    E -- Yes --> F[Return true]
    E -- No --> G[Return false]
    C --> H[Explicit value outranks environment]
    F --> H
    G --> H
Loading

File-Level Changes

Change Details Files
Reorders screenshot capture and missing-baseline validation so error instructions reference a file that exists.
  • Captures the screenshot before raising for a missing baseline when fail_if_new is enabled.
  • Keeps VCS baseline checkout and pre-capture warning logic in check_base_screenshot.
  • Suppresses the duplicate warning when the post-capture error will be raised.
  • Adds tests for VCS checkout invocation, on-disk error paths, and warning suppression.
lib/snap_diff/screenshot_matcher.rb
test/unit/dsl_test.rb
test/unit/screenshot_matcher_test.rb
Makes fail_if_new configuration explicitly override a live CI environment fallback.
  • Stores nil rather than a require-time CI-derived default.
  • Implements a reader that consults ENV['CI'] only when no explicit value is set.
  • Supports assigning nil to restore environment-based behavior.
  • Documents the revised default timing and precedence contract.
  • Adds canonical and legacy entry-point timing and precedence coverage.
lib/snap_diff/config.rb
lib/capybara/screenshot/diff/config_legacy.rb
docs/architecture.md
docs/configuration.md
test/unit/config_default_timing_test.rb
test/legacy/legacy_config_default_timing_test.rb
test/unit/snap_diff_config_test.rb

Assessment against linked issues

Issue Objective Addressed Explanation
#260 Capture a new screenshot before raising the fail_if_new error, so the path named in the message actually exists and can be added to git, including in CI.
#260 Preserve the baseline checkout performed by check_base_screenshot while moving only the error raise, and avoid emitting the separate no-baseline warning when fail_if_new will raise.
#260 Add regression coverage that exercises the user-facing screenshot flow and verifies both that the named file is written before the error and that the baseline checkout remains intact.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@pftg
pftg merged commit f1ceea9 into master Aug 24, 2026
8 checks passed
@pftg
pftg deleted the fix/live-state-and-config-precedence branch August 24, 2026 09:19
pftg added a commit that referenced this pull request Aug 24, 2026
The v2.0.0 section was written before #250, #253, #254, #255, #256, #261,
#263, #264, #266 and #267 landed, and three of its claims had gone false:

- "Known limitations: fork-based parallel tests produce no HTML report ...
  Fixed in 2.1" -- fixed in 2.0 by #266. Reproduced both sides here:
  1.15.1 + `parallelize(workers: 2, threshold: 0)` writes NO report and
  prints no summary line; master writes one merged report and
  `4 verified, 4 changed, 0 new`.
- "a suite whose only contact with the v1 API is
  `require \"capybara_screenshot_diff/minitest\"` + `include ...Assertions`
  still prints nothing" -- #263 made the require doors warn. That exact
  setup now prints the migration notice; verified in a scratch project.
- "Two removals 2.0 cannot warn about ... `driver:` as a setting" -- #263
  made both the setting writer and the per-screenshot key warn. Verified:
  `Capybara::Screenshot::Diff.driver = :vips` prints the removal line with
  a call site.

And the silent-by-design constant list repeated the shape of the beta2
`defined?` mistake: it listed "Os, Region" inside a run of
`Capybara::Screenshot::Diff::` names. Probed on master --
`defined?(Capybara::Screenshot::Diff::Os)` and
`defined?(Capybara::Screenshot::Diff::Region)` are both nil. The real
names are `Capybara::Screenshot::Os` and the top-level `Region`, neither
of which existed under `::Diff` in 1.15.1 either. Fully qualified now, and
`::Comparison` added to match docs/UPGRADING.md.

New material, every claim checked against the code or a live run:

- a "why upgrade" section for the four green-suite-testing-nothing bugs
  (#255, #256, #254, #266), plus the unfollowable CI message (#267) and
  the fail_if_new precedence change
- before/after transcripts of the failure message (#264), taken from the
  same page rendered on 1.15.1 and on master
- the summary line (#261), with the fact that it comes from the HTML
  reporter and needs its one-line require -- an omission that would have
  read as a missing feature
- the #250 / #253 perf table, attributed to its harness, with columns
  labelled before/after rather than 1.x/2.0
- the libvips fix is stated as guarded on libvips 8.15+, so a reader on an
  older libvips knows the bug is still theirs

Install snippets stay pinned to 2.0.0.beta3 on purpose: `~> 2.0` resolves
to nothing on rubygems today. docs/RELEASE_PREP.md already carries a
precise step to swap all five (its grep finds exactly those five), and
gains one line so the record-modes placeholder in the entry cannot ship
unfilled.

`rake test:unit` 651 runs / 0 failures, `standardrb lib test` clean.
pftg added a commit that referenced this pull request Aug 24, 2026
* feat: VCR-shaped record modes -- the accept workflow (#259)

Accepting an intentional UI change is the most frequent action in the
product and had no verb: `grep -rni "def accept|approve|update_baseline"
lib/` returned nothing, both documented recipes were wrong, and two
customer personas ended up reading vcs.rb to work out that the answer is
`git add` + commit.

    SnapDiff.config.record = :once   # default -- record when no baseline
    SnapDiff.config.record = :none   # strict -- a missing baseline fails
    SnapDiff.config.record = :all    # re-record everything, compare nothing

`:all` is the genuinely new capability: the bulk-accept verb for the
redesign that changed forty screenshots at once. Modes rather than a CLI
flag because there is no runner to hang a flag on -- VCR's shape, and
ours.

PRECEDENCE: an explicitly set mode outranks `fail_if_new`; `fail_if_new`
decides only when no mode was set. Same property #267 gave `fail_if_new`
over the CI sniff.

:once IS today, structurally. Config#record reads
`@record || (fail_if_new ? :none : :once)`, so with nothing set
`record_mode == :none` is true exactly when `config.fail_if_new` was, and
the two decision points in ScreenshotMatcher swapped one for the other
1:1. No new branch on the default path; the missing-baseline default is
untouched (CI-only failure, as Jest/AVA/Vitest/testthat all chose).

`:all` refuses to run under CI. It accepts every rendering by design, so
left in a committed config it is a build that compares nothing and passes
forever -- Percy's failure mode. A CI job that needs to record NEW
baselines uses `:once`, which still compares everything that has one.
`#capture` is exempt: it never compares, so the mode is inert there.

Additive only (ADR-010). `fail_if_new`, `pending_if_new` and
`fail_on_difference` keep working for the whole 2.x line; each warns once
per process through the existing Removal channel, naming the mode that
replaces it -- and only where that is true. `record:` is also a
per-screenshot option, carved out before Comparison so it cannot become
an accepted-but-unread key.

Also fixes a stale claim in docs/ci-integration.md: since #267 the
screenshot IS written before the raise.

* test: record-mode examples must run as a laptop, not as CI

The `:all` examples failed on CI for exactly the reason they exist: `:all`
refuses to run when ENV["CI"] is set, and the runner sets it. They passed
locally because CI is unset there.

The class now clears ENV["CI"] in setup and restores it in teardown, so it
runs as a developer's laptop by default; the CI-refusal cases opt IN through
the existing `with_ci` helper. Verified both ways: 25 runs green with CI=true
and with it unset.

Local green is half the bar -- this repo has been here before.
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.

The new-screenshot error prints a path that will never exist in CI

1 participant