Skip to content

fix: the HTML report survives Rails' fork-parallel test runs (#258) - #266

Merged
pftg merged 1 commit into
masterfrom
feat/parallel-report-merge
Aug 24, 2026
Merged

fix: the HTML report survives Rails' fork-parallel test runs (#258)#266
pftg merged 1 commit into
masterfrom
feat/parallel-report-merge

Conversation

@pftg

@pftg pftg commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Closes #258.

The bug, measured

Under Rails' default parallelize(workers: N) the HTML report is never written and the
[snap_diff] N verified, N changed, N new line never prints. Minitest skips after_run in
forked children (allow_fork = false at minitest.rb:64, guard at :79), so the workers
holding the results never finalize, and the parent that does finalize recorded nothing.

Pass/fail is unaffected — failures marshal back over DRb — and every image artifact still
lands on disk. Only the report and the summary are lost, which is what makes it quiet:
ActiveSupport.test_parallelization_threshold defaults to 50, so a suite silently loses its
report the day someone adds the 51st test.

The fix

Rails runs run_cleanup_hooks inside the worker just before it exits
(parallelization/worker.rb:31). Each worker dumps its records to a JSON fragment there; the
parent merges every fragment in the Minitest.after_run it does reach, then writes one
report at the documented path. No application-side configuration.

  • The summary counts are the merged totals. verified and changed come from the merged
    reporter state; new from the union of the workers' missing-baseline sets, so a name missing
    in two workers is counted once — same as serial.
  • Fragments live under the system temp dir, keyed by the parent's pid, removed after the
    merge. Nothing is written inside the repository. Each is written to a .tmp name and renamed
    into place, so a worker killed mid-write leaves nothing the merge will read.
  • Not the per-worker save_path route. save_path is also where baselines are read from
    (config.screenshot_area), so a per-worker value points every comparison at an empty
    baseline directory and records everything as new.
  • Feature-detected. Registered only when ActiveSupport::Testing::Parallelization is
    defined, and retried from ActiveSupport.on_load(:active_support_test_case) because
    Bundler.require loads this gem before ActiveSupport::TestCase exists — the zero-require
    Rails path. A non-Rails Capybara suite is untouched.

Evidence

An 8-classes × 8-tests harness with committed baselines (8 of them deliberately stale), run
under parallelize(workers: 4) against the same gem with and without the three changed files:

runs result report summary line
before, workers: 4 5/5 64 runs, 8 failures none 0 verified, 0 changed, 0 new. NOTHING WAS VERIFIED
after, workers: 4 5/5 64 runs, 8 failures one, 8 failures 128 verified, 8 changed, 0 new
after, with: :threads 5/5 64 runs, 8 failures one, 8 failures 128 verified, 8 changed, 0 new
after, serial 3/3 64 runs, 8 failures one, 8 failures 128 verified, 8 changed, 0 new

The merged report names exactly the 8 stale screenshots, with all image paths resolved. Totals
are 128, not 512 — no worker's pre-fork state is counted more than once. With two baselines
left uncommitted, the merged line reads 126 verified, 8 changed, 2 new and names both across
worker boundaries. No fragment directory survives a run.

Each guard in test/unit/parallel_report_merge_test.rb was mutation-checked: breaking the
idempotence guard, the feature detection, the deferred on_load install, the atomic write, the
merge glob, the owner-pid guard, the missing-baseline merge, the total accumulation, and the key
symbolization each turns a specific test red.

Docs

docs/reporters.md and one row of docs/thread_safety.md said the report is not written under
forking parallelism. Updated. The manual parallelize_teardown { finalize! } workaround they
prescribed is no longer needed; a suite that still carries it keeps working (measured — the
merged report is written last, at the documented path, with every failure in it), it just also
prints a partial summary line per worker.

rake test:unit, rake test:canonical and standardrb lib test are green.

🤖 Generated with Claude Code

https://claude.ai/code/session_014BQJX6eWzBj2UTm5zQsjEs

Summary by Sourcery

Ensure Rails fork-parallel test runs produce one complete HTML screenshot report and accurate suite-wide summary.

Bug Fixes:

  • Restore complete HTML reports and summary counts for Rails fork-based parallel test runs by merging screenshot results from all workers in the parent process.

Enhancements:

  • Add Rails-specific, feature-detected parallel cleanup integration with temporary JSON handoff fragments, merged missing-baseline tracking, atomic writes, and cleanup after merging.
  • Preserve existing behavior for serial, threaded, non-Rails, and legacy workaround configurations.

Documentation:

  • Update reporter and thread-safety documentation to describe complete report support under Rails fork parallelism and remove the obsolete workaround guidance.

Tests:

  • Add fork-based integration coverage for result merging, summary totals, fragment cleanup, interrupted writes, parent ownership, deferred Rails hook installation, duplicate registration, and Rails-free loading.

Under Rails' default `parallelize(workers: N)` the report was never
written and the `[snap_diff] N verified, N changed, N new` line never
printed. Minitest skips `after_run` in a forked child (`allow_fork =
false`), so the workers that hold every record never finalize, and the
parent that finalizes recorded nothing. Pass/fail was unaffected --
failures marshal back over DRb -- which is what made it quiet: a suite
silently lost its report the day someone added the 51st test, because
`ActiveSupport.test_parallelization_threshold` defaults to 50.

Rails runs `run_cleanup_hooks` INSIDE the worker just before it exits,
so each worker dumps its records to a JSON fragment there, and the
parent merges every fragment in the `Minitest.after_run` it does reach
-- one report, at the documented path, with no application-side
configuration.

- The summary counts are the MERGED totals. `verified` and `changed`
  come from the merged reporter state, `new` from the union of the
  workers' missing-baseline sets, so a name missing in two workers is
  still counted once, exactly as in a serial run.
- Fragments live under the system temp dir, keyed by the parent's pid,
  and are removed after the merge: nothing is written inside the
  repository. Each is written to a `.tmp` name and renamed into place,
  so a worker killed mid-write leaves nothing the merge will read.
- Not the per-worker `save_path` route: `save_path` is also where
  baselines are read from (`config.screenshot_area`), so a per-worker
  value points every comparison at an empty baseline directory and
  records everything as new.
- Registered only when ActiveSupport::Testing::Parallelization is
  defined, and retried from `ActiveSupport.on_load` because
  `Bundler.require` loads this gem before ActiveSupport::TestCase
  exists. A non-Rails Capybara suite is untouched, and so are serial
  runs and `parallelize(with: :threads)`, which both record in the
  process that finalizes.

Measured on an 8-classes x 8-tests harness with committed baselines,
under `parallelize(workers: 4)`: before, 5/5 runs gave `report=NO` and
`0 verified, 0 changed, 0 new`; after, 5/5 runs gave one report with
all 8 failures and `128 verified, 8 changed, 0 new` -- identical to
serial and to `with: :threads`, which were re-measured unchanged.

@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 36 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: 16d1b74d-977c-4058-acfe-b2189faf2bf9

📥 Commits

Reviewing files that changed from the base of the PR and between de5b662 and 71d3ad4.

📒 Files selected for processing (6)
  • docs/reporters.md
  • docs/thread_safety.md
  • lib/snap_diff/integrations/minitest.rb
  • lib/snap_diff/reporters/html.rb
  • lib/snap_diff/reporting.rb
  • test/unit/parallel_report_merge_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

Fixes missing HTML reports and summary counts under Rails fork-based parallel test runs by dumping each worker’s reporter state through a Rails cleanup hook, merging atomic JSON fragments in the parent before finalization, and cleaning up the temporary handoff directory. The change is Rails-feature-detected and deferred when necessary, preserves serial/thread/non-Rails behavior, adds comprehensive fork and failure-safety tests, and updates the concurrency documentation.

Sequence diagram for Rails fork-parallel report merging

sequenceDiagram
    participant Parent
    participant Worker as Rails worker
    participant TempDir as Temp fragment directory
    participant Reporter

    Parent->>Worker: fork test process
    Worker->>Reporter: run tests and record results
    Worker->>Worker: run_cleanup_hooks
    Worker->>Reporter: dump_state
    Reporter-->>Worker: reporter totals and failures
    Worker->>TempDir: write JSON .tmp fragment
    Worker->>TempDir: rename fragment to JSON
    Worker-->>Parent: worker exits
    Parent->>TempDir: read JSON fragments
    Parent->>Reporter: merge_state!
    Parent->>TempDir: remove fragment directory
    Parent->>Reporter: finalize!
    Reporter-->>Parent: write merged HTML report and summary
Loading

Flow diagram for feature-detected Rails parallel hook installation

flowchart TD
    Load["snap_diff minitest integration loads"] --> Install["install_parallel_hooks!"]
    Install --> RailsPresent{"ActiveSupport::Testing::Parallelization defined?"}
    RailsPresent -->|Yes| Register["run_cleanup_hook { dump_parallel_fragment }"]
    RailsPresent -->|No| OnLoad["ActiveSupport.on_load(:active_support_test_case)"]
    OnLoad --> Install
    Register --> Fork["Rails fork workers dump fragments"]
    Fork --> ParentHook["Minitest.after_run"]
    ParentHook --> Merge["merge_parallel_fragments!"]
    Merge --> Finalize["finalize!"]
Loading

File-Level Changes

Change Details Files
Adds Rails fork-parallel result handoff and parent-side report merging.
  • Registers a Rails cleanup hook with deferred ActiveSupport on-load installation and idempotent process ownership.
  • Serializes worker reporter state and missing-baseline names as atomically renamed JSON fragments under the system temp directory.
  • Merges worker fragments in the parent before finalization, symbolizes failure keys, unions missing baselines, and removes the fragment directory.
  • Keeps non-Rails, serial, and thread-parallel behavior unchanged.
lib/snap_diff/integrations/minitest.rb
lib/snap_diff/reporting.rb
lib/snap_diff/reporters/html.rb
Adds focused coverage for forked report merging, cleanup, safety, and integration compatibility.
  • Exercises real fork workers and verifies merged totals, failures, new-baseline counts, report generation, and fragment cleanup.
  • Checks atomic-write behavior, ignored temporary fragments, owner-PID isolation, no-fork no-op behavior, idempotent hook registration, and deferred Rails installation.
  • Probes loading the Minitest integration without ActiveSupport.
test/unit/parallel_report_merge_test.rb
Updates concurrency documentation to describe complete Rails fork-parallel reports.
  • Marks Rails fork parallelism as fully supported with one merged report at the documented path.
  • Documents temporary fragment handling and removes the old per-worker workaround while explaining compatibility caveats.
  • Updates the thread-safety support matrix.
docs/reporters.md
docs/thread_safety.md

Assessment against linked issues

Issue Objective Addressed Explanation
#258 Make HTML reports and summary counts survive Rails fork-based parallelization by collecting each worker's records, merging them in the parent, and cleaning up the temporary fragments.
#258 Register the fork-parallel integration only when Rails parallelization is available, while preserving existing serial, threaded, and non-Rails behavior.
#258 Document the automatic fork-parallel report support and replace the prior manual workaround guidance, including the restriction against using per-worker save paths.

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 d2b0768 into master Aug 24, 2026
8 checks passed
@pftg
pftg deleted the feat/parallel-report-merge branch August 24, 2026 09:00
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
`SnapDiff::Reporting.register` appears exactly once in the whole gem, at
reporters/html.rb:140. So the honest summary line shipped bundled with
the HTML report, and the documented Rails setup registered nothing:

    $ ruby -e 'require "snap_diff/integrations/minitest"
               puts SnapDiff::Reporting.reporters.size'
    0

That line exists to catch the failure modes no per-assertion rule can
see -- a run where zero system tests executed, or where an inherited
GIT_DIR redirected every baseline lookup. `0 verified` is the only
signal for either, and it was behind an opt-in require.

Separates the two concerns: counting is core honesty, writing an HTML
file is a feature. Reporting owns `verified`/`changed` and prints
`counts_summary` unconditionally from `finalize!`; Reporters::HTML keeps
the report file, stays opt-in, and its `summary` is now just the path of
the file it wrote -- on its own line, and nil when it wrote nothing. So
the counts print exactly once whether or not the reporter is loaded, and
`0 verified` still shouts NOTHING WAS VERIFIED.

The fork-parallel merge (#266) carries the new counters in the same
fragment as the missing-baseline names, and its guard now goes through
`Reporting.notify` -- the real path -- so it checks both halves the
worker has to hand back.

`count` warns and skips rather than raising: `notify` runs inside every
test's teardown, and a raise there aborts SnapDiff.reset before it
clears the registry, leaking one test's assertions into the next. Same
contract the reporter loop already applies, and just as loud
(unconditional, not DEBUG-gated). Adding it surfaced three test doubles
that never implemented Comparison's `difference` reader -- they had been
blowing up unnoticed inside HTML#record, which swallows under
`if ENV["DEBUG"]`.

docs: `configuration.md` recommended the LEGACY `Capybara::Screenshot.enabled`
spelling inside the canonical config reference, in three places.
pftg added a commit that referenced this pull request Aug 24, 2026
These three checks could not have been written before the rebase: the
counts line (#269) and record modes (#274) never met until now.

- The fork-parallel fragment carries BOTH tallies, so a worker that
  counts assertions AND re-records a baseline must hand back both and
  neither may cost the other. The main merge case now does both.
- A fragment with none of the keys added since #266 still merges. The
  fragments directory is keyed by pid under the system temp dir, so a
  recycled pid can hand the merge a fragment written by an older version
  of the gem; every key but "missing_baselines" is read with a default
  for exactly that. Mutation-checked: dropping one default fails the
  merge with `TypeError: nil can't be coerced into Integer`.
- `record: :all` end to end, on a finished process. It re-records
  without comparing, which through the summary path is neither verified
  nor changed, and NOT "new" either -- there was a baseline, it just was
  not consulted.
pftg added a commit that referenced this pull request Aug 24, 2026
…on (#272, #269, #270) (#275)

* fix: skip_area selectors that match nothing no longer block for 5s (#272)

Capybara's `all` defaults to `minimum: 1` and blocks in `synchronize`
until that count is satisfied, so every `skip_area` (or `crop`) selector
matching nothing burned a full `Capybara.default_max_wait_time` -- 5s by
default, per selector, per screenshot. Measured with a real browser at
Capybara's shipped 5s default, `%w[picture img]` against an image-less
page: 10.012s before, 0.009s after. One project reported that exact
scenario as 44% of their whole suite.

The cost is only half of it. `skip_area` is a MASK -- "exclude whatever
is currently there". Waiting for an element to appear is the wrong
semantic: a selector matching nothing has nothing to mask, and that
answer is available immediately.

`all_visible_regions_for` is the only Capybara finder in lib/; the rest
of BrowserHelpers is execute_script/evaluate_script and driver
introspection, none of which carry a count expectation to block on. So
this is the class of bug, not one instance of it.

The guard uses a REAL browser session. Every existing test of this path
stubs the browser, and a stub answers instantly whether or not the
selector matches -- which is exactly why a 5-second wait went unnoticed
for years. Its budget is derived from the live
`default_max_wait_time` rather than hardcoded, so lowering the suite's
wait cannot quietly turn it into an assertion that passes while broken.

* fix: the end-of-run summary prints without a reporter registered (#269)

`SnapDiff::Reporting.register` appears exactly once in the whole gem, at
reporters/html.rb:140. So the honest summary line shipped bundled with
the HTML report, and the documented Rails setup registered nothing:

    $ ruby -e 'require "snap_diff/integrations/minitest"
               puts SnapDiff::Reporting.reporters.size'
    0

That line exists to catch the failure modes no per-assertion rule can
see -- a run where zero system tests executed, or where an inherited
GIT_DIR redirected every baseline lookup. `0 verified` is the only
signal for either, and it was behind an opt-in require.

Separates the two concerns: counting is core honesty, writing an HTML
file is a feature. Reporting owns `verified`/`changed` and prints
`counts_summary` unconditionally from `finalize!`; Reporters::HTML keeps
the report file, stays opt-in, and its `summary` is now just the path of
the file it wrote -- on its own line, and nil when it wrote nothing. So
the counts print exactly once whether or not the reporter is loaded, and
`0 verified` still shouts NOTHING WAS VERIFIED.

The fork-parallel merge (#266) carries the new counters in the same
fragment as the missing-baseline names, and its guard now goes through
`Reporting.notify` -- the real path -- so it checks both halves the
worker has to hand back.

`count` warns and skips rather than raising: `notify` runs inside every
test's teardown, and a raise there aborts SnapDiff.reset before it
clears the registry, leaking one test's assertions into the next. Same
contract the reporter loop already applies, and just as loud
(unconditional, not DEBUG-gated). Adding it surfaced three test doubles
that never implemented Comparison's `difference` reader -- they had been
blowing up unnoticed inside HTML#record, which swallows under
`if ENV["DEBUG"]`.

docs: `configuration.md` recommended the LEGACY `Capybara::Screenshot.enabled`
spelling inside the canonical config reference, in three places.

* fix: a disabled screenshot no longer counts as an assertion (#270)

`integrations/minitest.rb` incremented the counter before the `active?`
guard inside `super`, which returns false immediately when screenshots
are disabled. So a test whose only assertion was a screenshot reported
`1 runs, 1 assertions, 0 failures` -- nothing captured, nothing
compared, and a green line claiming otherwise.

Counting only when active hands the alarm to Rails for free. Rails
unconditionally prepends ActiveSupport::Testing::TestsWithoutAssertions
into every ActiveSupport::TestCase (test_case.rb:205), so those tests
now warn:

    Test is missing assertions: `test_it` .../my_test.rb:12

Guarded in both directions with the real Rails module, not a stand-in:
the alarm fires for a test whose sole assertion was a disabled
screenshot, and stays quiet both for a test that asserts something else
and for a screenshot that actually ran.

Audited the other two adapters, neither needs a change:

- RSpec's matcher returns a literal `true`, which is correct rather than
  the same bug: returning `assert_matches_screenshot`'s false would fail
  the example over a config switch the user set on purpose, and a real
  mismatch raises rather than returning false. RSpec has no assertion
  count to correct and no missing-assertion alarm to trigger, so only
  the end-of-run `0 verified` line can see a disabled run. Said so at
  the call site, since the next reader will otherwise "fix" it.
- Cucumber counts nothing; `SnapDiff::DSL#assert_matches_screenshot`
  already returns false when inactive.

* test: pin the #274 record-modes interactions the rebase created

These three checks could not have been written before the rebase: the
counts line (#269) and record modes (#274) never met until now.

- The fork-parallel fragment carries BOTH tallies, so a worker that
  counts assertions AND re-records a baseline must hand back both and
  neither may cost the other. The main merge case now does both.
- A fragment with none of the keys added since #266 still merges. The
  fragments directory is keyed by pid under the system temp dir, so a
  recycled pid can hand the merge a fragment written by an older version
  of the gem; every key but "missing_baselines" is read with a default
  for exactly that. Mutation-checked: dropping one default fails the
  merge with `TypeError: nil can't be coerced into Integer`.
- `record: :all` end to end, on a finished process. It re-records
  without comparing, which through the summary path is neither verified
  nor changed, and NOT "new" either -- there was a baseline, it just was
  not consulted.
pftg added a commit that referenced this pull request Aug 24, 2026
… that never matched (#277) (#279)

* feat: an optional readiness block on the screenshot DSL (#277)

`assert_matches_screenshot` and `capture_screenshot` return at the
`active?` guard before doing anything else. So readiness scaffolding
written on the line above -- `preload_all_images`, `document.fonts.ready`,
waiting on a widget -- still runs when screenshots are DISABLED, and in a
real consumer that is three browser round-trips (scroll to bottom, an
`assert_text` with its own wait, scroll back) per screenshot that is never
taken. Turning visual tests off was free except for the scaffolding, and
the scaffolding is the expensive part.

Both methods now take an optional block, run after the guard and before
the capture. `screenshot` and `assert_no_screenshot_changes` forward it --
a delegator that swallowed it would hand the user a block that silently
never runs.

Deliberately NOT a hook: no config-level `before_capture`, no after-hooks,
no block on Comparison. It runs once per assertion, not once per stability
attempt (a per-attempt block is a different feature and a separate
decision), and an error raised inside it is the user's own and propagates
unchanged.

RSpec's `match_screenshot` matcher does not take one: `expect(page).to
match_screenshot('x') { ... }` binds the block by Ruby's `{}`/`do...end`
precedence rather than by intent. RSpec and Cucumber users call
`assert_matches_screenshot` directly, where the block is unambiguous.

Also drops the docs' claim that an unmatched `skip_area` selector waits for
the element -- untrue since #272 -- and says what replaced it: the mask
covers what exists at assertion time, and late-loading content belongs in
the block.

* feat: name the selectors that never matched anything, once per run (#277)

Removing the implicit wait (#272) took a measured 44% off a real suite, but
that wait was also, accidentally, giving late-loading elements time to
appear. A `skip_area` selector that matches nothing now yields an EMPTY
mask, silently: nothing excluded, the unstable region compared, the test
flakes -- and the only tell is a flake weeks later.

#275 deliberately declined a per-screenshot warning, and was right to: the
gem cannot tell a typo from a legitimately image-less page, so the
legitimate case would fire on every screenshot until people stopped reading
it. A RUN-level tally has no such problem. A selector that matched
somewhere is doing its job and is never mentioned; one that matched nowhere
in the entire run is a typo or a stale selector with high probability.

Fed from BrowserHelpers.bounds_for_css, the one seam that still knows which
selector produced which regions -- AreaCalculator sees only the flattened
list -- so a selector that was configured but never reached can never be
named. Two sets rather than a counter, because under fork-parallel (#266)
the hit and the miss for one selector arrive from different processes and
only the parent that merged every fragment can answer "did this match
ANYWHERE"; both halves ride the existing fragment alongside
verified/changed/new, `fetch`ed with defaults like every key added since.

Silent when the set is empty, on purpose. A line that prints on every run
is a line users learn to skip, which is how this one would stop working.
pftg added a commit that referenced this pull request Aug 24, 2026
Every JRuby cell in the last 15 Test runs was `cancelled` -- 29 cancelled, 1
failure, zero successes. Fail-fast killed the lane before it could report, and
`cancelled` reads as an absence rather than a verdict, so nothing said the lane
had been dark since #266.

Fixing the rails71 failure in the previous commit let JRuby run far enough to
report, and it reported six errors:

    NotImplementedError: fork is not available on this platform
      org/jruby/RubyKernel.java:2073:in 'fork'
      test/unit/parallel_report_merge_test.rb:224:in 'fork_worker'

All six route through `fork_worker`. This is not a product bug: Rails'
`parallelize(workers: N)` forks, JRuby has no fork, and JRuby suites
parallelize with threads instead -- which record in the process that finalizes
and are already covered by "merging is a no-op when no worker ever forked".
The behaviour is inapplicable there, so a skip is the honest report.

Detection measured rather than assumed, on jruby-10.0.6.0:

    Process.respond_to?(:fork) => false
    Process.fork { }           => NotImplementedError

so the plain idiom is enough; no RUBY_PLATFORM sniffing.

Verified both directions, because a guard that skips everywhere would be worse
than the bug:

    MRI 4.0.6         14 runs, 68 assertions, 0 failures, 0 skips
    JRuby 10.0.6.0    14 runs, 34 assertions, 0 errors,   6 skips

and the full unit suite on jruby-10.0.6.0 + rails72_gems.rb is
720 runs / 2090 assertions / 0 failures / 0 errors / 6 skips, so these six were
the only thing broken in that lane. CI's own run of the superset (`bin/rake
test`, 757 runs) failed on exactly these six and nothing else.
pftg added a commit that referenced this pull request Aug 24, 2026
* test: Rails' missing-assertions alarm is 7.2+, so skip it on 7.1

ActiveSupport::Testing::TestsWithoutAssertions does not exist in Rails 7.1,
so the rails71 matrix cell died with NameError at class-definition time --
eight errors, on master, from a test added for #270.

Skip rather than stub. A hand-rolled stand-in would assert that our code
cooperates with a module Rails never prepends on that version, which proves
nothing and reads as coverage.

Guarded on  rather than a version comparison: the question is
whether the constant is there to prepend, and edge/main moves independently
of the version string.

Verified both ways -- constant forced absent: 3 skips; present: 0 skips,
15 assertions.

* test: skip the fork-parallel suite where Kernel#fork does not exist

Every JRuby cell in the last 15 Test runs was `cancelled` -- 29 cancelled, 1
failure, zero successes. Fail-fast killed the lane before it could report, and
`cancelled` reads as an absence rather than a verdict, so nothing said the lane
had been dark since #266.

Fixing the rails71 failure in the previous commit let JRuby run far enough to
report, and it reported six errors:

    NotImplementedError: fork is not available on this platform
      org/jruby/RubyKernel.java:2073:in 'fork'
      test/unit/parallel_report_merge_test.rb:224:in 'fork_worker'

All six route through `fork_worker`. This is not a product bug: Rails'
`parallelize(workers: N)` forks, JRuby has no fork, and JRuby suites
parallelize with threads instead -- which record in the process that finalizes
and are already covered by "merging is a no-op when no worker ever forked".
The behaviour is inapplicable there, so a skip is the honest report.

Detection measured rather than assumed, on jruby-10.0.6.0:

    Process.respond_to?(:fork) => false
    Process.fork { }           => NotImplementedError

so the plain idiom is enough; no RUBY_PLATFORM sniffing.

Verified both directions, because a guard that skips everywhere would be worse
than the bug:

    MRI 4.0.6         14 runs, 68 assertions, 0 failures, 0 skips
    JRuby 10.0.6.0    14 runs, 34 assertions, 0 errors,   6 skips

and the full unit suite on jruby-10.0.6.0 + rails72_gems.rb is
720 runs / 2090 assertions / 0 failures / 0 errors / 6 skips, so these six were
the only thing broken in that lane. CI's own run of the superset (`bin/rake
test`, 757 runs) failed on exactly these six and nothing else.
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.

Fork-parallel reports should need zero developer setup

1 participant