Skip to content

fix: a failed screenshot names the line that took it, once - #303

Merged
pftg merged 4 commits into
snap-diff:masterfrom
oboxodo:fix/failure-message-stack
Oct 12, 2026
Merged

pftg merged 4 commits into
snap-diff:masterfrom
oboxodo:fix/failure-message-stack

Conversation

@oboxodo

@oboxodo oboxodo commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

A failed screenshot's message ends with the whole call stack of the screenshot call, and under RSpec the entire failure is then printed a second time, under Caused by:. One spec with two failing screenshots prints 218 lines. After this PR it prints 39, and each screenshot names the line of the spec that took it. That includes expect(page).to match_screenshot, where the spec's line is buried 10 frames deep today.

Before (real run, master, a spec with screenshot "b_1" on line 44 and expect(:page).to match_screenshot("b_2") on line 45)

  Screenshot does not match for 'b_1': the change spans 100 of 6400 px (1.56% of the 80x80 image)
    changed region: [20.0,15.0,30.0,25.0] (left,top,right,bottom edges)
    judged against: no tolerance thresholds configured (any difference fails)
    baseline:           doc/screenshots/b_1.base.png
    ...
    heatmap:            doc/screenshots/b_1.heatmap.diff.png
  test/fixtures/rspec_call_site_spec.rb:44:in 'block (2 levels) in <top (required)>'
  …/gems/rspec-core-3.13.6/lib/rspec/core/example.rb:263:in 'BasicObject#instance_exec'
  …/gems/rspec-core-3.13.6/lib/rspec/core/example.rb:263:in 'block in RSpec::Core::Example#run'
  … (the rest of the stack, for each screenshot)
# --- Caused by: ---
# SnapDiff::ExpectationNotMet:
#   (the whole message again, stacks included)

match_screenshot's stack starts inside rspec-expectations and snap_diff's own matcher; the spec line is the 11th frame.

After (same run)

  Screenshot does not match for 'b_1': the change spans 100 of 6400 px (1.56% of the 80x80 image)
    ...
    heatmap:            doc/screenshots/b_1.heatmap.diff.png
  test/fixtures/rspec_call_site_spec.rb:44

  Screenshot does not match for 'b_2': the change spans 100 of 6400 px (1.56% of the 80x80 image)
    ...
  test/fixtures/rspec_call_site_spec.rb:45
# ./test/fixtures/rspec_call_site_spec.rb:44:in 'block (2 levels) in <top (required)>'
before after
RSpec, screenshot + match_screenshot failing 218 lines 39
RSpec, two failing screenshots 227 36
Minitest, two failing screenshots 68 32

Minitest shows only one [file:line] per failed test, so for the second screenshot the message is the only place its line appears. That line stays.

How it got here

#84 asked for "which test source line the screenshot was called from", and 4f34873 appended exactly that one line. 80ba12d widened it to caller(skip_stack_frames + 1) with a filter that only knew activesupport|minitest|railties frames. When filtering moved into BacktraceFilter (#141), which only touches exception backtraces, the message started carrying every frame, unfiltered.

Commit 1: name the screenshot's call site instead of the whole stack

The message ends with one path:line: the first frame in a real file outside snap_diff's lib/, installed gems (Gem.path, Gem.default_dir) and Ruby's library dirs (rubylibdir, sitelibdir, vendorlibdir). That's the split ActiveSupport::BacktraceCleaner makes for its gem filter and stdlib silencer.

  • It finds the user's line without counting frames, so it doesn't depend on rspec-expectations' call depth.
  • "Real file" (File.file?) also drops <internal:…>, (eval) and JRuby's Java frames without a per-platform rule.
  • Roots are compared on directory boundaries (the trap 9148f79 fixed in BacktraceFilter).
  • The path is relative to config.root when the file is inside it, like the feat: make the failure message legible #264 artifact paths; otherwise it's left as Ruby recorded it, which resolves from where the test command ran, as RSpec's own backtrace lines do. The :in '…' label is dropped, as in Removal.origin_for's deprecation origins.
  • No qualifying frame: the first frame, rendered the same way, so a stubbed caller = ["my_test.rb:42"] reads exactly as before (the existing message tests pass unchanged). No caller at all: the message has no trailing line; building it from a nil caller used to raise NoMethodError. (validate! and the registry's verify still pass caller as the raised error's backtrace, unchanged.)

It's unlabelled, in the spot where the stack was. A label (called at:) would read better, but wouldn't line up with the artifact block's column and changes a line people may grep; happy to add one if you prefer.

One behaviour to know about: a project helper that wraps screenshot is user code, so it's named unless it passes skip_stack_frames: 1. That's the override docs/configuration.md already shows. Before, the full stack showed the helper's line first and the test line further down. The docs section and the DSL override comment now say why to pass it. Listing every non-gem frame instead would also pull in binstubs like bin/rspec.

Commit 2: RSpec prints a failed screenshot once

The adapter's append_after translates SnapDiff::ExpectationNotMet into RSpec::Expectations::ExpectationNotMetError inside the rescue, so Ruby attaches the original as cause, and rspec-core prints a cause in full. The translated error already copies the message and backtrace, so it's raised with cause: nil. Minitest's adapter re-raises the same way but Minitest prints no cause (checked), and Cucumber's never re-raises, so neither changes.

Commit 3: keep the call site last, and its path readable across drives

From the bot review on this PR:

  • Contributor notes (AI triage) came after the call site, so with triage on the location wasn't the last line, as the docs and CHANGELOG say. The notes now sit between the diff details they describe and the call site.
  • display_path's Pathname#relative_path_from raises ArgumentError ("different prefix") when it can't relate the frame to the root, e.g. a test file on another Windows drive. That replaced the screenshot failure with an unrelated error. Such a frame now stays as Ruby recorded it.

Not changed

  • ScreenshotAssertion#caller (public; still the raised error's backtrace), BacktraceFilter, and every exception backtrace.
  • The message's first line and the artifact block.
  • skip_stack_frames semantics.

Evidence

  • test/unit/screenshot_assertion_call_site_test.rb (10 tests), frames built from real files so they hold on any machine and on JRuby:
    • a real rspec-expectations frame, snap_diff's own matcher frame, a Ruby library file, <internal:kernel> and (eval) before the test's own line → that line, relative to root;
    • outside root → absolute; the directory-boundary case (…/foo/ doesn't exclude …/foobar/); a wrapper helper; the fallback; nil and [] callers;
    • a frame Pathname can't relate to the root (a relative root reaches Ruby's same "different prefix" error on any OS) → kept as recorded; contributor notes → the call site is still last;
    • the real screenshot, assert_matches_screenshot, assert_no_screenshot_changes and delayed: false paths, each naming the test's own line.
  • test/unit/rspec_call_site_test.rb: a browser-free RSpec run in a subprocess (ScreenshoterStub, baselines placed where a checkout would put them) through screenshot and match_screenshot, run once. Each call site appears once on its own line, no rspec or snap_diff frame appears, and there is no Caused by:.
  • Mutation checks: with master's lib/, all 8 unit tests fail and the RSpec test fails. Dropping the gem roots, the directory boundary, the real-file check or the relative rendering each turns at least one test red. Without cause: nil, the RSpec test fails on its Caused by: check. Without commit 3's rescue, or with the notes after the call site, its matching test fails.
  • Full rake in the bin/dtest image (Ruby 4.0), at commit 2: 819 runs, 0 failures, 1 skip. At commit 3: rake test:unit 780 runs, 0 failures. standardrb: 181 files, no offenses.
  • JRuby (raise …, cause: nil, the frame parsing): green in a manual CI run on this branch at bf63890, https://github.com/oboxodo/snap_diff-capybara/actions/runs/38211137915. Since then lib/ changed only in review tidy-ups: FRAME became a private constant and call_site / library_roots were restructured with the same regex and roots; the raise is unchanged. Commit 3 adds a plain rescue ArgumentError and reorders the message lines. In the same run, every other job passed except the two selenium_headless (Firefox) jobs, which fail exactly as on master: the same 7 tests, Dimensions have changed: 800x514 => 800x526 (Firefox 157 breaks the selenium_headless baselines, and 8 browser tests that can't fail hid part of it #300, fixed by fix: stop blur_active_element clicking inputs, and green selenium_headless on Firefox 157 #301).

Deliberately not here

  • The size-change message is still in the pre-feat: make the failure message legible #264 shape (no baseline:/actual: labels); separate change.
  • README.md:147 still shows a pre-feat: make the failure message legible #264 example message; unrelated to the stack.
  • Reporters::Default#display_path makes the same relative_path_from call, for the artifact paths. Those live under the root by construction, so it's left alone.
  • Two edge cases fall back to a less useful line rather than the user's: a gem or Ruby install reached through a symlink (its frames' paths don't start with the configured roots), and a Dir.chdir before the message is built (relative frames then fail the real-file check, so the first frame is used). Neither breaks the message.

Summary by Sourcery

Report concise screenshot failure call sites and eliminate duplicate RSpec failure output.

Bug Fixes:

  • Show the source line that triggered each failed screenshot instead of appending the full call stack.
  • Prevent RSpec from printing translated screenshot failures a second time under Caused by:.
  • Preserve readable call-site paths and avoid replacing screenshot failures with path errors in edge cases.

Enhancements:

  • Keep contributor annotations before the final screenshot call site and document how wrappers can skip their own stack frame.

Documentation:

  • Document the new failed-screenshot call-site output and wrapper behavior.

Tests:

  • Add unit coverage for call-site selection, path rendering, fallback behavior, annotation ordering, and DSL entry points.
  • Add a subprocess RSpec integration test covering single-print failures and call-site reporting.

Chores:

  • Record the changed screenshot failure output and RSpec behavior in the changelog.

Summary by CodeRabbit

  • Bug Fixes

    • Screenshot failure messages now point to the relevant project call site instead of showing a long stack trace. Paths within the configured project root are shown relative to that root.
    • RSpec reports each failed screenshot once, without repeating the error under a “Caused by” section.
  • Documentation

    • Clarified how to identify screenshot failure locations, how to skip project helper frames when reporting them, and how overrides can report the test line instead of the override.

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no actionable new issue remains.

Summary

Failed screenshots now name one source line instead of printing the whole stack.

  • Failed screenshots point to the line that took them.
  • RSpec prints each failed screenshot only once.

Reviews (3) · Last reviewed commit: "Merge branch 'master' into fix/failure-m..." · Reviewed by Greptile

oboxodo and others added 2 commits October 12, 2026 03:43
A failed screenshot's message ended with the full, unfiltered call
stack of the screenshot call. snap-diff#84 asked for the one source line, and
its fix appended exactly that; a later filter only knew Minitest and
Rails frames, and once filtering moved into BacktraceFilter (which
only touches exception backtraces) the message carried every frame.

The message now ends with one path:line: the first frame in a real
file outside this gem, installed gems (Gem.path, Gem.default_dir) and
Ruby's library dirs, the same split ActiveSupport::BacktraceCleaner
makes. That finds the user's line without counting frames, which
matters for `expect(page).to match_screenshot`, where rspec-expectations
and the matcher sit in between. Roots are compared on directory
boundaries; the path is shown relative to config.root like the
artifact paths. With no qualifying frame it falls back to the first
frame, so a stubbed caller reads as before; with no caller the message
has no trailing line (building it from a nil caller used to raise).

ScreenshotAssertion#caller is unchanged: it is public, and the raised
error's backtrace still comes from it. A project helper wrapping
`screenshot` is user code too, so it is named unless it passes
skip_stack_frames: 1, as the documented override already does; the
docs and the DSL override comment now say why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RSpec adapter translates SnapDiff::ExpectationNotMet into RSpec's
ExpectationNotMetError inside the rescue, which makes Ruby attach the
original as its cause, and rspec-core prints a cause in full under
"Caused by:". The translated error already copies the message and the
backtrace, so the cause only repeated them; it is raised with
cause: nil now.

Minitest's adapter re-raises the same way but prints no cause, and
Cucumber's does not re-raise, so neither changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

The PR replaces full screenshot call stacks with one resolved user call-site line, including through RSpec matchers and DSL wrappers, and suppresses RSpec cause chaining so each failed screenshot is printed only once. It adds focused unit and subprocess integration coverage for path filtering, fallbacks, entry points, and output formatting, along with updated documentation and changelog notes.

Sequence diagram for resolving a failed screenshot call site

sequenceDiagram
    participant Test as Test spec
    participant DSL as ScreenshotAssertion
    participant Caller as Ruby caller frames
    participant Filter as Call-site filter
    participant Error as ExpectationNotMet

    Test->>DSL: screenshot(name)
    DSL->>Caller: caller
    Caller-->>Filter: stack frames
    Filter->>Filter: user_code?
    Filter->>Filter: display_path(path)
    Filter-->>DSL: path:line
    DSL->>Error: validate!
    Error-->>Test: message with one call-site line
Loading

Sequence diagram for preventing duplicate RSpec screenshot failures

sequenceDiagram
    participant Spec as RSpec example
    participant Adapter as RSpec adapter
    participant SnapDiff as SnapDiff assertion
    participant RSpecError as RSpec expectation error

    Spec->>Adapter: screenshot(name)
    Adapter->>SnapDiff: validate!
    SnapDiff-->>Adapter: SnapDiff::ExpectationNotMet
    Adapter->>RSpecError: new(message)
    Adapter->>RSpecError: set_backtrace(backtrace)
    Adapter-->>Spec: raise cause: nil
    Spec-->>Spec: print failure once
Loading

File-Level Changes

Change Details Files
Reduce failed screenshot messages to a single, useful call-site line.
  • Select the first existing source frame outside snap_diff, installed gems, and Ruby library roots.
  • Render paths relative to the configured root when applicable and preserve fallback behavior for unusable or missing callers.
  • Apply the same call-site logic across screenshot DSL entry points, matcher paths, wrappers, and immediate validation.
lib/snap_diff/screenshot_assertion.rb
lib/snap_diff/dsl.rb
test/unit/screenshot_assertion_call_site_test.rb
Prevent RSpec from printing translated screenshot failures twice.
  • Re-raise the translated RSpec expectation with the original cause suppressed.
  • Add a subprocess integration test covering screenshot and match_screenshot output, call-site uniqueness, stack filtering, and absence of Caused by output.
lib/snap_diff/integrations/rspec.rb
test/fixtures/rspec_call_site_spec.rb
test/unit/rspec_call_site_test.rb
Document and announce the new failure-output behavior.
  • Describe call-site selection and the skip_stack_frames guidance for project wrappers.
  • Record concise changelog entries for call-site reporting and single-print RSpec failures.
docs/configuration.md
CHANGELOG.md

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

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f438e9b6-9bb6-4c72-8cb6-d0ce816e707c

📥 Commits

Reviewing files that changed from the base of the PR and between 6550add and 6857ef9.


📒 Files selected for processing (3)
  • CHANGELOG.md
  • lib/snap_diff/screenshot_assertion.rb
  • test/unit/screenshot_assertion_call_site_test.rb

🚧 Files skipped from review as they are similar to previous changes (1)
  • CHANGELOG.md

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.



📝 Walkthrough

Walkthrough

Screenshot assertion failures now report a selected caller location instead of the full caller backtrace. The RSpec integration raises the translated expectation error without its original cause. Tests cover caller selection, path formatting, and failure output.

Changes

Screenshot failure reporting

Layer / File(s) Summary
Select and format screenshot call sites
lib/snap_diff/screenshot_assertion.rb, test/unit/screenshot_assertion_call_site_test.rb, lib/snap_diff/dsl.rb, docs/configuration.md, CHANGELOG.md
Failure messages report the first eligible caller frame, with paths inside the configured root shown relative to it. Tests and guidance cover frame selection and skipping wrapper helpers.
Render RSpec screenshot failures
lib/snap_diff/integrations/rspec.rb, test/fixtures/rspec_call_site_spec.rb, test/unit/rspec_call_site_test.rb, CHANGELOG.md
The translated RSpec error has no attached cause. A subprocess fixture checks that each screenshot failure reports its call site once and omits the cause section.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: pftg


Merge Risk: ⚪ Minimal · up to 6857e

The change appears mergeable after normal checks; no actionable issue was identified.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 6 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main changes: failed screenshots report their call-site line once.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

Full details: Docstring Coverage

Explanation

Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 6 files. (1 skipped: 1 unsupported.)


  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 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.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="lib/snap_diff/screenshot_assertion.rb" line_range="163" />
<code_context>
+    # failure message's artifact paths are; otherwise as Ruby recorded it,
+    # which resolves from where the test command ran.
+    def display_path(path)
+      relative = Pathname(path).expand_path.relative_path_from(SnapDiff.config.root).to_path
+      relative.start_with?("..") ? path : relative
+    end
</code_context>
<issue_to_address>
**Path errors replace screenshot failures**

When a qualifying caller frame and `SnapDiff.config.root` are on different Windows drives, `display_path` calls `Pathname#relative_path_from` before checking whether the frame is outside the configured root. It raises `ArgumentError`, so `validate` stops formatting the mismatch and callers see a path error instead of the screenshot failure.

Handle different-drive paths before calling `relative_path_from`, returning the original path when a relative path cannot be computed.
</issue_to_address>

### Comment 2
<location path="lib/snap_diff/screenshot_assertion.rb" line_range="81" />
<code_context>
         end

-        message = "Screenshot does not match for '#{name}': #{compare.error_message}\n#{caller.join("\n")}"
+        message = ["Screenshot does not match for '#{name}': #{compare.error_message}", call_site].compact.join("\n")
         Contributions.annotations_for(name).each do |note|
           message += "\n  #{note[:source].upcase} triage: #{note[:text]}"
</code_context>
<issue_to_address>
**Call site is not the final line**

When a registered contribution provider returns an annotation for a failed screenshot, `validate` appends contribution annotations after `call_site`, so the message ends with triage text instead of the promised `path:line`; callers relying on the final line lose the screenshot call site.

Append contribution annotations before `call_site` so the call site remains the final line.
</issue_to_address>

Sourcery assessment

Approval pending. 2 findings to address first.

Blocking findings: lib/snap_diff/screenshot_assertion.rb:163, lib/snap_diff/screenshot_assertion.rb:81


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread lib/snap_diff/screenshot_assertion.rb
Comment thread lib/snap_diff/screenshot_assertion.rb Outdated
Comment thread lib/snap_diff/screenshot_assertion.rb
Two gaps in the call-site line, raised in review:

- Contributor notes (AI triage) were appended after the call site, so
  with triage on the message no longer ended with the location the
  docs and CHANGELOG promise. The notes now sit between the diff
  details they describe and the call site.
- display_path relates the frame to config.root with
  Pathname#relative_path_from, which raises ArgumentError ("different
  prefix") when the two can't be related, e.g. a test file on another
  Windows drive. That replaced the screenshot failure with an unrelated
  error. Such a frame now stays as Ruby recorded it, which the method's
  comment already promised for paths outside the root.

The test for the second reproduces Ruby's error on any OS with a
relative root. Reporters::Default#display_path has the same call, but
renders artifact paths, which live under the root by construction; it
is left alone here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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.

Sourcery assessment

Approved.

@pftg

pftg commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Thanks @oboxodo I’ll check today and merge

@pftg

pftg commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Thanks @oboxodo, reviewed and approved. #302 merges first, and both PRs add to the same CHANGELOG list, so this one will likely conflict after it lands. Could you rebase on master once #302 is in? I'll merge right after CI goes green.

@pftg
pftg merged commit f989ea1 into snap-diff:master Oct 12, 2026
11 checks passed
@oboxodo
oboxodo deleted the fix/failure-message-stack branch October 12, 2026 19:24
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