Skip to content

2.1: delete the legacy namespace trees and the driver abstraction - #8

Closed
pftg wants to merge 370 commits into
masterfrom
v2.1/delete-legacy-and-drivers
Closed

2.1: delete the legacy namespace trees and the driver abstraction#8
pftg wants to merge 370 commits into
masterfrom
v2.1/delete-legacy-and-drivers

Conversation

@pftg

@pftg pftg commented Aug 23, 2026

Copy link
Copy Markdown
Member

Draft. Nothing ships until the 2.1 plan is approved. No version bump, no CHANGELOG entry, no release.

Supersedes snap-diff#240, which was opened before snap-diff#242/snap-diff#245/snap-diff#246/snap-diff#247/snap-diff#248 landed and before the scope grew to include the driver abstraction. Redone from current origin/master (1cd89c9) rather than rebased — see Rebase or redo.

2.0 is the transitional release: both APIs work and everything that dies warns. 2.1 is the single cleanup release. There is no 3.0.


Migrating from 2.0 to 2.1

Measured, not imagined: a real consumer (a Jekyll/Rails site with committed visual baselines, running its suite in Docker) was upgraded end to end against this deletion. 17 lines, two files, zero blockers — 38 runs, 0 failures, 55 screenshots compared, byte-identical before and after. Every v1 API had a canonical equivalent.

That validation covered the namespace half. The driver half is new in this PR and is the part most likely to touch your config — read Drivers and image processing even if you never used the v1 names.

1. Namespaces

Requires

before after
require "capybara_screenshot_diff/minitest" require "snap_diff/integrations/minitest"
require "capybara_screenshot_diff/rspec" require "snap_diff/integrations/rspec"
require "capybara_screenshot_diff/cucumber" require "snap_diff/integrations/cucumber"
require "capybara_screenshot_diff/reporters/html" require "snap_diff/reporters/html"
require "capybara/screenshot/diff" require "snap_diff"
gem "capybara-screenshot-diff" (Bundler auto-require) gem "snap_diff-capybara"

Configuration

Both v1 holders collapse into one object. SnapDiff.configure is the single config entry point (ADR-008).

before after
Capybara::Screenshot.<setting> = … SnapDiff.config.<setting> = …
Capybara::Screenshot::Diff.<setting> = … SnapDiff.config.<setting> = …
Capybara::Screenshot::Diff.configure { |screenshot, diff| … } SnapDiff.configure { |config| … }
SnapDiff.start { |screenshot, diff| … } SnapDiff.configure { |config| … }

SnapDiff.start yielded the two v1 holders, so it could not outlive them — removed, not renamed. Same for SnapDiff.silence_deprecations and SNAP_DIFF_SILENCE_DEPRECATIONS: with no deprecations left to emit there is nothing to silence.

Constants and includes

before after
Capybara::Screenshot::Os.name SnapDiff::Os.name
Capybara::Screenshot::Diff::ImageCompare SnapDiff::Comparison
CapybaraScreenshotDiff::Reporters::HTML SnapDiff::Reporters::HTML
CapybaraScreenshotDiff::ScreenshotAssertion SnapDiff::ScreenshotAssertion
include CapybaraScreenshotDiff::DSL
include CapybaraScreenshotDiff::Minitest::Assertions
include SnapDiff::Minitest::Assertionsthe two collapse into one

Capybara::Screenshot::Os is the one to grep for. In the real upgrade it was the only hard crash: on 2.0 it raises NameError from the shim internals once the require line has been migrated but the constant has not — a partially-migrated setup looks fine (config setters keep working) until Os aborts the whole suite before a single test runs. On 2.1 it is simply gone.

2. Drivers and image processing

libvips is now the only backend, and ruby-vips is a gemspec runtime dependency (>= 2.0, < 3). You no longer add it yourself; Bundler installs it. libvips itself is still a system package (brew install vips, apt-get install libvips), and without it the gem cannot compare images at all.

Making it a real dependency is the point: before this, neither driver was declared, so a box without either got a runtime error deep in a test run. Now it is a resolver error at bundle install.

before after
gem "ruby-vips" in your Gemfile delete it (harmless to keep)
SnapDiff.config.driver = :vips / = :auto / = :chunky_png delete the line
screenshot "index", driver: :vips delete the option
shift_distance_limit (anywhere) no equivalent — see below
SnapDiff::Drivers.available to branch on what is installed nothing to branch on
SnapDiff::Drivers.loaded[:mine] = MyDriver no replacement
include SnapDiff::Driver in your own driver no replacement

What breaks loudly vs. quietly

Worth knowing before you upgrade:

  • SnapDiff.config.driver = :vips raises NoMethodError: undefined method 'driver=' at config time, before any test runs. Loud, greppable, one line to delete.
  • screenshot "index", driver: :vips is silently ignored — per-screenshot options are a free-form hash, so an unknown key is inert. It does not change behaviour (there is one backend), but nothing tells you the line is dead. Grep for it.
  • shift_distance_limit behaves the same way: NoMethodError on the config object, silently ignored per screenshot.

shift_distance_limit has no replacement

It was implemented only by the chunky_png driver, and libvips has no shift-distance comparison. Use one of:

instead why
median_filter_window_size the same idea and far faster — smooths the image before comparing
tolerance allows a ratio of pixels to differ, wherever they are
color_distance_limit allows each pixel to differ by a colour distance

Numbers in failure messages change

If you assert on comparison output, note that libvips reports differently from chunky_png: area_size and region come out as floats, and there is no max_color_distance. For the a/c test fixtures:

before (chunky_png):  ({"area_size":629,"region":[11,3,48,20],"max_color_distance":187.4})
after  (libvips):     ({"area_size":684.0,"region":[11.0,3.0,49.0,21.0]})

This only surfaced now because a Comparison built without an explicit driver: defaulted to chunky_png (Drivers.for did fetch(:driver, :chunky_png)). Anyone going through the normal DSL was already on libvips via driver: :auto.

Custom drivers: there is no migration path

The abstraction is removed whole — the SnapDiff::Driver mixin, the SnapDiff::Drivers registry (.loaded, .available, .for, .registry, .detect_available), AVAILABLE_DRIVERS, Utils.detect_available_drivers, and :auto selection. A third-party driver stops working on 2.1 and nothing replaces it. This is a deliberate call, not an oversight: one backend is what keeps every option meaning one thing. If you maintain one, say so on the issue tracker — that is the only thing that can reopen it.

SnapDiff::Drivers::VipsDriver survives, and SnapDiff::Drivers survives as its namespace — not as a registry.


The driver: setting: removed, and why

The open question was whether driver: survives as an accept-and-ignore knob. Removed. Reasoning:

  1. Accept-and-ignore is exactly the dead knob this release exists to delete. Worse: a config reading driver: :chunky_png would keep comparing on libvips, i.e. actively lie about which backend runs.
  2. Removal fails at the right moment. NoMethodError: undefined method 'driver=' fires at config time, before a single test, naming the exact line. An ignored setting fails nowhere and is discovered when someone wonders why tuning it does nothing.
  3. Almost nobody set it to something that mattered. :vips and :auto-with-libvips-present already selected what 2.1 does unconditionally; the real-world validation consumer never set it at all. The two values that did change behaviour (:chunky_png, :auto falling back to chunky) are precisely the ones 2.0 warns about.
  4. The migration is one deleted line, documented above.

The honest caveat is in What breaks loudly vs. quietly: the per-screenshot driver: form is silently ignored rather than raising, because per-screenshot options are a free-form hash. Adding validation there would be a new feature in a deletion release; it is documented instead.


A bug the deletion exposed (and fixes)

libvips served stale images. libvips caches loader operations keyed on filename + mtime, and mtime has one-second resolution — so rewriting a screenshot path and re-reading it within the same second handed back the previous image. This gem does exactly that: the screenshoter writes <name>.png, checkout_base_screenshot writes <name>.base.png from VCS, and the comparison then reads both.

It was latent because comparisons built without an explicit driver defaulted to chunky_png, which always re-reads. With libvips as the only backend it became the only behaviour — and it turned identical images into "different" and vice versa. Reproduced directly:

load1 avg=160.370664   # b.png
load2 avg=160.370664   # after overwriting the path with a.png -- STALE
direct a.png avg=160.664102

Fix: VipsDriver#from_file passes revalidate: true (libvips 8.15+, gated on Vips.at_least_libvips?(8, 15)). Two cache-flush workarounds that had been papering over this — Vips.cache_set_max(0); Vips.cache_set_max(1000) in test/system_test_case.rb and vips_driver_test.rb teardowns — are deleted. A regression test pins it, and goes red when revalidate is removed.


What this deletes

lib/capybara/, lib/capybara_screenshot_diff/, lib/capybara-screenshot-diff.rb, lib/capybara_screenshot_diff.rb, lib/snap_diff/legacy_shims.rb, lib/snap_diff/deprecation.rb, lib/snap_diff/removal.rb, lib/snap_diff/driver.rb, lib/snap_diff/drivers.rb, lib/snap_diff/drivers/chunky_png_driver.rb, lib/snap_diff/utils.rb, test/legacy/, and six driver/deprecation test files.

59 files deleted (3 751 lines). Whole diff: 101 files, +680 / −4 494 — a net 3 814 lines removed.

area change
lib/ 52 files, +88 / −1 605
test/ 37 files, +438 / −2 594
docs/ + README 5 files, +110 / −219

lib/ is down to 34 packaged files.

Fallout beyond the deletions

  • test/test_helper.rbSnapDiff::Deprecation.suppress_migration_notice!, SnapDiff::Removal.suppress! and the Warning guard that raised on any [snap_diff deprecation] line all go; the channel that emitted them no longer exists. The DriverCoverage banner/abort goes too: it guarded against a silent fallback to chunky_png, and a missing libvips is now a require "vips" LoadError at boot.
  • Rakefiletest:canonical was "everything except test/legacy/", which is now exactly test; the two converged and the second name is deleted rather than kept as an alias for one thing. rake test is THE gate. test:benchmark is deleted: it required scripts/benchmark/find_region_benchmark, which is not in this repo, so it raised LoadError on every invocation, and its body named a v1 constant.
  • .github/workflows/test.yml — the screenshot-driver: [vips, chunky_png] matrix axis is gone (half the cells, same coverage), as is SCREENSHOT_DRIVER everywhere. The job id matrix-screenshot-driver is renamed matrix-capybara-driver. ⚠️ Matrix check names change regardless (2 dimensions → 1), so branch protection needs a look.
  • scripts/generate_sample_report.rb (rake report:sample) and bin/console loaded v1 entry points; repointed and both verified running.
  • gems.rbchunky_png, oily_png and the explicit ruby-vips line removed (the last is now a gemspec runtime dep).
  • ImagePreprocessor — the driver.supports?(:filter_image_with_median) probe and its warning fallback are deleted. They existed because chunky_png did not implement median filtering; libvips does, so the branch was unreachable. supports? went with the mixin.

Gate dispositions

gate disposition why
test/legacy/legacy_tree_is_alias_only_test.rb dies it proved the v1 trees held no logic; the trees are gone
test/unit/legacy_deletion_test.rb replaced it simulated the deletion (copy lib/, rm, probe in a subprocess) because the trees were still there. They are not, and support_load_probe_test.rb already runs the same entry-point and advertised-constant tables against the real lib/ on every run
test/unit/removed_surface_test.rb new keeps the one claim nothing else makes — absence
test/unit/core_tree_has_no_legacy_deps_test.rb kept, repurposed a legacy require in lib/ now fails by itself, but a docstring or user-facing message naming Capybara::Screenshot.* survives the deletion and starts lying. Its exclusion list is empty (both files were deleted, not exempted) and it gained a REMOVED_SURFACE pattern for the driver half
test/unit/canonical_suite_has_no_legacy_refs_test.rb kept, repurposed same reasoning for the test tree; it stopped predicting a future deletion and now catches removed names in strings, heredocs and messages. Its pattern list grew to cover SnapDiff::Removal, SnapDiff::Driver, the registry methods and silence_deprecations
test/unit/support_load_probe_test.rb kept, unchanged it already measured the real lib/

Gate line discipline

removed_surface_test.rb runs a subprocess, so it carries a gate line that runs before any absence assertion. The trap here is the inverse of the one that bit us twice: the subject is the real lib/, so the risk is measuring an installed copy of the gem, or measuring nothing at all — "the constant is gone" and "nothing was ever loaded" look identical. The gate asserts snap_diff files were loaded, that every one came from this repo's lib/ (anchored on the library path, not a bare /snap_diff/ substring — CI checks out into a directory of that name), and two positive controls. It chdirs to a tmpdir and scrubs RUBYOPT/BUNDLE_GEMFILE/RUBYLIB, because with the cwd inside the project RubyGems re-adds -rbundler/setup and the gemspec unshifts lib/ ahead of any -I. A dedicated test proves the gate line rejects a process that loaded nothing.

Mutation checks (every gate broken, watched to fail, restored)

mutation gate result
touch lib/snap_diff/removal.rb removed-path check RED — Expected ["snap_diff/removal.rb"] to be empty
add def self.start to snap_diff.rb fresh-process absence RED — still defined: SnapDiff.start
put shift_distance_limit in a lib/ string core_tree_has_no_legacy_deps RED — config.rb:98: names a surface 2.1 removed
drop revalidate: from from_file vips staleness regression RED — vips served the cached b.png after the path was overwritten

What remains of driver-contract coverage

test/support/driver_contract_tests.rb is kept, with one includer instead of two. It stops being a shared contract and becomes the pin on the interface Comparison, ImagePreprocessor, Screenshoter and AnnotationService all call — signatures, arity, and load_images slot order, all of which would break those callers silently if they drifted. Behavioural option coverage (tolerance, color_distance_limit, skip_area, resize, missing-file errors) is still meaningful against one driver.

What is honestly lost: the value of running identical expectations against two implementations, which is what a contract test is normally for. And supports?(feature) is gone — it existed so ImagePreprocessor could ask whether a driver implemented median filtering, a question with one answer now. filter_image_with_median was added to the interface list to compensate, since nothing else asserts it exists.

Deleted outright: chunky_png_driver_test.rb, drivers_test.rb (registry/detection), drivers/utils_test.rb (detection), driver_coverage_test.rb + test/support/driver_coverage.rb, removed_in_2_1_deprecation_test.rb (its six warnings are what this release makes true).


Suites

$ mise x ruby@4.0.6 -- bundle exec rake test
Finished in 34.783017s, 8.9124 runs/s, 24.9835 assertions/s.
310 runs, 869 assertions, 0 failures, 0 errors, 1 skips

$ mise x ruby@4.0.6 -- bundle exec rake test:unit
Finished in 24.697212s, 11.4183 runs/s, 33.3641 assertions/s.
282 runs, 824 assertions, 0 failures, 0 errors, 0 skips

$ mise x ruby@4.0.6 -- bundle exec rake test:integration
Finished in 18.676421s, 1.4992 runs/s, 2.4095 assertions/s.
28 runs, 45 assertions, 0 failures, 0 errors, 1 skips

$ mise x ruby@4.0.6 -- bundle exec standardrb
102 files inspected, no offenses detected

$ mise x ruby@4.0.6 -- bundle exec rake test:canonical
Don't know how to build task 'test:canonical'   # collapsed into `test`

Baseline on origin/master (1cd89c9) was 613 runs, 0 failures, 1 skip for rake test, 585 for test:unit and 482/0/1 for test:canonical. The drop is test/legacy/ (9 files), the deletion simulation, and the driver/deprecation tests leaving, minus the new gate arriving. The one skip is BrowserScreenshotTest#test_animated_example, an optional_test — the same skip as baseline.

Also verified: gem build succeeds and packages 34 lib/ files with capybara and ruby-vips as runtime dependencies; rake report:sample and bin/console both run against the repointed entry points.


Rebase or redo

Redone from scratch. snap-diff#240's diff touched files that snap-diff#246/snap-diff#247/snap-diff#248 subsequently rewrote — removal.rb did not exist when it was opened, deletion_3_0_test.rb was renamed and its constants with it, the Rakefile header was rewritten — so nearly every file it touched would have conflicted. The scope also roughly doubled (drivers, chunky_png, shift_distance_limit, the gemspec dependency). Starting from current origin/master was the smaller job.

Deliberately not done here: the v1 docs sweep

The driver half of the docs is updated: docs/drivers.md is rewritten as "Image Processing" (libvips only), docs/configuration.md loses the shift_distance_limit section and the ChunkyPNG comparison columns, docs/snapdiff.md's custom-driver section becomes the no-migration-path note, docs/architecture.md's driver layer and file layout are corrected, and the README's install/requirements blocks are updated.

The v1 namespace half is left out to keep this diff reviewable — same call snap-diff#240 made. Still describing the old surface as present, by match count: docs/UPGRADING.md · docs/migration-guide.md · docs/framework-setup.md · docs/architecture.md (lines 5, 219, 293) · docs/configuration.md (the legacy two-holder config block) · docs/organization.md · docs/thread_safety.md · docs/reporters.md · CONTRIBUTING.md. Not all are wrong — a migration guide must name the old API — but every one needs a read.

For the reviewer

Removing a public API in a MINOR departs from semver. It does. The mitigation is that it is announced rather than surprising: 2.0's release notes, docs/UPGRADING.md and the runtime migration notice all say "removed in 2.1" explicitly, so the contract is published before it is enforced. On 2.0 a v1 user gets working legacy names, a per-constant warning carrying their own file:line, and one migration notice per process pointing at the guide — and since snap-diff#246, six more warnings covering exactly the driver half this PR removes.

pftg and others added 30 commits July 17, 2024 20:54
* moved raising error per tests

* refact: adds rspec and cucumber test integration

* upgrades minitest

* refact: adds depreacations

* chore: adds deprecation warnings

* refactoring: cleanup and fixes

* refact: renames validate_screenshots to verify to mock rspec/mock

* refact: drops unsed code

* refact: drops unsed code

* build: fix tests with updates (snap-diff#117)

* build: optimize time-limit per jruby (snap-diff#118)

* build: increase time limits per jruby

* refact: move to separate action to setup ruby

* build: setup jruby config to make it pass

* adds more jruby options (snap-diff#120)

* refact: cleanup test workflow

* removed redundant names per steps

* doc: adds more info about usage of the new API
* build: adds release action

* build: bump to 1.9.0
…ult (snap-diff#125)

Co-authored-by: Alan Savage <3028205+asavageiv@users.noreply.github.com>
* refact: cleanup redundant code and make explicit names

* refact: extracted snap manager

* refact: cleanup

* refact: removes usage of the filesystem to access to the screenshots

* chore: cleanup lint

* wip

* cleanup vcs

* chore: cleanup vcs

* chore: cleanup vcs

* refact: use snapshots for screenshoter

* test: cleanup tests to use snap manager

* chore: fixes lint

* test: cleanup tests to use snap manager

* refact: removes scrn_path

* refact: adds usage of snapshot for take screenshot methods

* refcat: snap tracks attempts version

* cleanup

* chore: cleanup

* chore: cleanup

* chore: cleanup

* refact: move Snap to separate file

* refact: extracted annotation

* fix: cleanup
* feat: do not expect to lazy loading images

Closes: snap-diff#127

* bug: allows to pass wait and override timeout for stable screenshoter

Closes: snap-diff#131

* chore: remove redundant operation

* chore: reduce requirement of the Rails

* chore: remove redundant chunky_png requirement

* chore: updates system font
* Relax rails version constraints

* Update version.rb
* chore: cleanup

* build: generate screenshots on fails

* WIP: build: generate screenshots on fails

* WIP: build: generate screenshots on fails

* WIP: build: generate screenshots on fails

* build: makes build works

* build: update debug artifcats

* build: update test images for CI

* Update .gitignore to exclude report image files

Added patterns to ignore .webp files alongside .png for reports. This ensures consistency in ignoring generated report files across formats. Helps maintain a cleaner working directory.
pftg added 27 commits August 23, 2026 01:14
…p 1 guard) (snap-diff#223)

Subprocess probes that pin, for each documented entry point
(capybara_screenshot_diff, capybara_screenshot_diff/minitest, snap_diff,
capybara/screenshot/diff):

- every SnapDiff::Config::MAPPING setting reads identically through both
  surfaces (SnapDiff.config.x and the legacy mattr_accessor) and matches
  the expected default
- fail_if_new's ENV["CI"] read happens at require time and is frozen:
  mutating ENV after the require (even before the first read) is not seen
- root's Rails.root / pwd fallback is likewise evaluated at require time
  and frozen against post-require Rails.root reassignment or chdir
- Diff.default_options[:wait] is the opposite: it reads
  Capybara.default_max_wait_time live, at call time

These are green on current master by design: they pin CURRENT behavior so
the coming ADR-005 storage inversion (SnapDiff::Config becoming the single
store, config_legacy flipped to delegating writers) goes red if it shifts
when any default is evaluated or what it evaluates to.

Gate-checked: temporarily hoisting the fail_if_new ENV read from
class-body eval to a lazy read-time default turned 8 of the 12 probes red;
reverting restored green.
… step 1) (snap-diff#224)

* refactor: SnapDiff::Config becomes the single config storage (ADR-008 step 1)

Inverts config storage ownership: SnapDiff::Config now holds every
setting as instance state on the eager SnapDiff.config singleton, and the
legacy Capybara::Screenshot / Capybara::Screenshot::Diff accessors are
thin delegators generated from Config::MAPPING (singleton + instance
methods, mirroring the old mattr_accessor surface; root keeps its
reader-only instance asymmetry and its Pathname-coercing writer, now in
Config#root=).

Require topology: snap_diff/config is the new leaf (it predefines the
empty legacy module skeleton, same technique as legacy_shims.rb, so
MAPPING's module references resolve); config_legacy requires it, and the
old config.rb -> config_legacy edge is gone. Graph stays acyclic.

Default timing (pinned by config_default_timing_test.rb, all 12 green):
fail_if_new (ENV["CI"]) and root (Rails.root/pwd) evaluate in
Config#initialize at the eager Config.new at require time of the leaf --
the same load moment the mattr default blocks ran at.
default_options[:wait] stays a live method-body read of
Capybara.default_max_wait_time.

* test: per-test config isolation snapshots SnapDiff.config ivars

The global-state snapshot/restore in test_helper walked the legacy
modules' class_variables, which no longer exist after the storage
inversion -- it would have silently become a no-op. Snapshot the single
storage (SnapDiff.config's instance variables) instead; one storage means
this covers both surfaces.

* fix: Config#initialize creates every mapped ivar up front

Nil-defaulted settings had no ivar until first write, so test_helper's
per-test ivar snapshot missed them and teardown could not restore them --
a legacy write to e.g. tolerance mid-test leaked into the next test.
Pre-set all MAPPING keys to nil before the explicit defaults so the full
ivar set always exists.

Also drops the now-unused active_support attribute_accessors require in
snap_manager (nothing in lib uses mattr_* since the storage inversion).

* test: rework MAPPING completeness guard for the inverted storage

The old reflection test derived settings from mattr class variables,
which no longer exist -- it passed vacuously. Replace with two directions:
every legacy singleton writer must be mapped (a future mattr_accessor or
hand-rolled writer on the legacy modules would create unmapped storage),
and SnapDiff.config must store exactly one ivar per MAPPING key (red
repro for the ivar-initialization fix; also guards the test_helper
snapshot completeness).
…p-diff#225)

SnapDiff::Error / ExpectationNotMet / UnstableImage /
WindowSizeMismatchError now live in lib/snap_diff/errors.rb; the old
CapybaraScreenshotDiff names are eager same-object aliases (not
const_missing shims, so rescue and defined?/const_defined? feature
detection behave exactly as before). All gem-internal raise/rescue
sites and doc comments flipped to the SnapDiff names. Guard tests pin
alias identity, const_defined? visibility, rescue-old-catches-new, and
the (unchanged) error hierarchy.
* refactor: move region.rb into lib/snap_diff (pure rename, ADR-008 step 3)

Pure git mv so rename detection tracks history; the namespace wrap,
forwarder, and reference flips land in the follow-up commit.

* refactor: Region gets its SnapDiff home (ADR-008 step 3)

- lib/snap_diff/region.rb: wrap in module SnapDiff; keep an EAGER
  same-object top-level alias (Region = SnapDiff::Region) because user
  configs build skip_area entries with Region.new and feature-detect via
  defined?(Region), which a lazy const_missing shim would break (same
  rationale as ADR-008 step 2's eager error aliases).
- old path becomes a one-line forwarder requiring snap_diff/region.
- browser_helpers.rb / area_calculator.rb: drop the 'Region has not
  moved yet' TODO headers, require snap_diff/region. Call sites stay
  bare Region per house style (like ComparisonResult): inside the
  SnapDiff nesting they now resolve to SnapDiff::Region.
- guards: same-object identity, defined?(Region) truthiness, and an
  is_a? probe pushing a user-built top-level Region through
  AreaCalculator's skip_area partition.
…napDiff homes (ADR-008 steps 4+5) (snap-diff#227)

* refactor: move reporters/default.rb to its snap_diff home (mv only)

Pure git mv so the rename survives with history; the rewrap to
SnapDiff::Reporters::Default and the old-path forwarder land in the
next commit.

* refactor: default reporter becomes SnapDiff::Reporters::Default (ADR-008 step 4)

The last old-namespace CLASS dependency in the core: comparison.rb now
requires and constructs SnapDiff::Reporters::Default. The old constant
Capybara::Screenshot::Diff::Reporters::Default joins the legacy_shims
mapping (lazy, warn-once, same-object -- pinned by the MAPPING-driven
forwarding and deprecation tests, now 30 pairs). The old file path stays
requirable as a forwarder.

Test edits are canonical-name migrations only (per the snap-diff#221 policy --
the raise-on-deprecation guard would trip on the old name otherwise):
default_test.rb and image_compare_test.rb reference
SnapDiff::Reporters::Default; no expectations changed.

* refactor: images-holder struct becomes SnapDiff::Comparison::Images (ADR-008 step 5a)

Resolves the Comparison name collision: the images-holder struct that
lived at Capybara::Screenshot::Diff::Comparison (blocked from a SnapDiff
home because SnapDiff::Comparison is the comparator class) is now nested
inside the comparator as SnapDiff::Comparison::Images. The old constant
joins the legacy_shims mapping (lazy, warn-once, same-object -- 31 pairs
now). comparison.rb constructs Images directly; no old-namespace class
reference remains in the compare path.

Test edits are canonical-name migrations only (per the snap-diff#221 policy;
the raise-on-deprecation guard would otherwise trip): the struct
constructions in driver_contract_tests, image_preprocessor_test,
annotation_service_test and default_test now spell the canonical name.
No expectations changed.

* refactor: driver registry gets its SnapDiff home (ADR-008 step 5b)

SnapDiff::Drivers.loaded is now the canonical driver-class cache (ex
Capybara::Screenshot::Diff::LOADED_DRIVERS, which comparison.rb no
longer defines); the old constant stays as an EAGER same-object alias
in legacy_shims because user code registers custom drivers by mutating
the hash in place -- a lazy copy would silently drop registrations, and
warning on a supported surface would be noise.

SnapDiff::Drivers.available is the canonical reader for the detected
drivers list; the value itself stays on the eager
Capybara::Screenshot::Diff::AVAILABLE_DRIVERS constant (config_legacy
detection moment unchanged, and image_compare_test pins that constant
as the stub point for the no-drivers error path). Utils'
find_driver_class_for now reads both registries through the canonical
accessors, removing the last old-namespace reads from the core compare
path. Guard tests pin same-object identity and legacy-write ->
canonical-read visibility.
snap-diff#228)

ADR-008 step 6, the last alias-safe move of the accepted ADR.

Session lifecycle: the per-test AssertionRegistry accessor moves to
SnapDiff.session, with SnapDiff.reset and
SnapDiff.pending_screenshots_message alongside it in
snap_diff/screenshot_assertion.rb (the file that already owns the registry
class). CapybaraScreenshotDiff keeps its entire surface as thin
forwarders -- singleton_methods + arities dumped before and after, diff is
empty (16 public + 1 private, unchanged). Thread.current is fiber-local;
that is left exactly as it was (issue snap-diff#217) -- this relocates the
canonical accessor, it does not change the semantics.

Reporting completion: SnapDiff::Reporting.register(reporter) is the
canonical way in, with the append under the existing mutex (issue snap-diff#217
item 2); .reporters stays public and mutable for compatibility. The
integrations (Minitest, RSpec, Cucumber) and the HTML reporter's
auto-registration now call SnapDiff.session / SnapDiff.reset /
SnapDiff.pending_screenshots_message / SnapDiff::Reporting.finalize! /
SnapDiff::Reporting.register directly instead of routing through
CapybaraScreenshotDiff, and require the snap_diff files they actually use.

AssertionRegistry#verify no longer reaches back through
CapybaraScreenshotDiff for its own assertions -- same objects, one less
round trip through the compat surface.

Guards: SnapDiff.session and CapybaraScreenshotDiff.registry are the same
object; register lands in the array .reporters exposes; 32 concurrent
register calls retain all 32; requiring the HTML reporter auto-registers
exactly one.
…s-only (ADR-008 step 7) (snap-diff#229)

Part A -- kill `anchor:`. Viewport.prepare! accepted an `anchor:` kwarg no
caller ever passed non-nil (both ScreenshotMatcher call sites hardcoded
`anchor: nil`), plus a test defending its acceptance. Adding an optional
kwarg later is 100% non-breaking, so reserving it now bought nothing and
shipped a test guarding dead code. v3's scroll-preservation work designs
the real contract.

Part B -- the CI gate. New unit test asserts every .rb file under
lib/capybara/ and lib/capybara_screenshot_diff/ is nothing but requires,
namespace reopening, constant aliases and one-line forwarders into
SnapDiff. That is what keeps 3.0 a `git rm` instead of a refactor. One
allowlisted file (config_legacy.rb, with a written reason), narrowed by
pinning its method inventory so new logic there still reds.
…p 7b) (snap-diff#230)

config_legacy.rb was the last file in the v1 trees holding real logic.
Step 1 moved config STORAGE to SnapDiff::Config but left the DERIVED
values behind, so deleting lib/capybara/ at 3.0 would have lost
behaviour rather than being a `git rm`.

Moved into SnapDiff::Config as instance methods:

- #active?            (was Capybara::Screenshot.active?)
- #screenshot_area    (was Capybara::Screenshot.screenshot_area)
- #screenshot_area_abs
- #default_options    (was Diff.default_options, incl. the vips
                       tolerance 0.001 literal)

Inverted so the canonical names hold the bodies:

- SnapDiff.compare now builds the Comparison; Diff.compare forwards.
- SnapDiff.start now does the two-arg yield; Diff.configure forwards.

The Config::MAPPING accessor generator moved to snap_diff/config.rb
alongside the storage it delegates to -- same reason legacy_shims.rb
generates the legacy constants from the snap_diff side: the generator
is code, and the v1 trees must stay alias-only.

Net effect: the alias-only gate's ALLOWED_WITH_CODE allowlist is now
EMPTY, and config_legacy.rb is checked by the general rule like every
other legacy file. The pinned method inventory that narrowed its
exemption goes with the exemption.

default_options[:wait] stays a call-time read of
Capybara.default_max_wait_time -- freezing it into Config#initialize
reds all four snap-diff#223 timing guards.

Found while moving: the active? precedence rule had no test. Replacing
the whole expression with a bare `enabled` kept all 529 unit tests
green. Its truth table is now pinned through both the canonical method
and the legacy forwarder.

AVAILABLE_DRIVERS stays in config_legacy.rb (it is a bare constant
assignment, so alias-shaped and never a gate blocker). Moving the
storage to SnapDiff::Drivers was tried and reverted: eagerly it needs
Utils, which requires Drivers back; lazily it breaks
image_compare_test's published stubbing point, because Utils reads
Drivers.available and would no longer see a stubbed legacy constant.

Legacy public surface verified byte-identical: singleton_methods +
parameters for Capybara::Screenshot and Capybara::Screenshot::Diff are
unchanged. On the SnapDiff side, .compare gains an explicit signature
(was a `(...)` pass-through) and .start loses its &block capture (now a
bare yield) -- both a consequence of the bodies landing there, and both
call-compatible.

rake test:unit 530 runs / 0 failures; rake test 563 runs / 0 failures.
…008 step 8) (snap-diff#231)

Every doc page taught legacy names only. `SnapDiff::Minitest::Assertions` and
`snap_diff/integrations/{minitest,rspec,cucumber}` worked but appeared in zero
docs, and docs/configuration.md contained zero occurrences of "SnapDiff" — so a
reader who bought the "SnapDiff is canonical" pitch was routed straight back
through legacy requires and includes.

- NEW docs/snapdiff.md: the SnapDiff-native page. Quick start for all three
  integrations with the require paths that actually work, configuration via
  SnapDiff.configure/.config, the canonical object map, standalone
  SnapDiff.compare, custom reporters via SnapDiff::Reporting.register, custom
  drivers via `include SnapDiff::Driver` + the real SnapDiff::Drivers.loaded
  registration mechanism, and the per-test lifecycle for other frameworks.
- docs/configuration.md: SnapDiff.configure shown first as canonical, legacy
  two-holder block kept alongside, with the one-storage/two-views note.
- Cross-links from README (2.0 note + docs list) and docs/UPGRADING.md.
- docs/architecture.md: corrected what steps 1-7b made stale — config storage
  ownership is now SnapDiff::Config (config_legacy installs delegators, not the
  other way round), errors/Region/reporter-registry/session homes, the drivers
  registry, and the assertion lifecycle names.
- Sweep: short canonical-equivalent notes added to framework-setup.md,
  drivers.md, reporters.md and ci-integration.md. Those pages keep teaching the
  still-supported legacy surface. reporters.md also gained the missing `summary`
  method — Reporting.finalize! calls it unconditionally.

Docs only; no lib/ or test/ changes.
…ckers) (snap-diff#232)

Every `snap_diff/*` entry a user is told to require now routes through
snap_diff.rb, so the docs' own quick start stops handing out a fragment:

- snap_diff/dsl requires "snap_diff" (acyclic: snap_diff.rb never requires
  dsl back), which fixes SnapDiff.configure/.start/.compare and ::VERSION
  under snap_diff/dsl, snap_diff/integrations/*, snap_diff/static -- and
  makes the dual-install guard fire on all of them.
- snap_diff.rb owns the rest of its documented core: version and the
  session lifecycle (.session/.reset/.pending_screenshots_message).
- Legacy entries load the legacy surface again: capybara/screenshot/diff/
  cucumber and capybara_screenshot_diff/static required only the canonical
  half, leaving CapybaraScreenshotDiff half-present (module defined,
  .verify/.reset/.reporters/... gone).
- Reporters::Default and Diff::Comparison become EAGER same-object aliases
  instead of lazy const_missing shims, so defined?/const_defined? feature
  detection works again (same rationale as the error aliases).
- SnapDiff::Error is the catch-all docs/snapdiff.md claims:
  WindowSizeMismatchError and DualInstallError now inherit it.
- lib/snap_diff-capybara.rb: `Bundler.require` on the snap_diff-capybara
  gem was a silent no-op.
- Reporting.notify's failure warning matches finalize!'s brand and format.

Guards: subprocess probes per canonical and per legacy entry point
(surface, dual-install guard, session surface), a discovered-not-listed
error-hierarchy test, and eager-identity tests for the two constants.
…ss) (snap-diff#233)

Dead code (no caller in lib, test or docs):
- ScreenshotAssertion.assert_image_not_changed
- ScreenshotNamer#full_name_with_path / #current_group_directory, and the
  @screenshot_area duplication of Config#screenshot_area they were the only
  users of
- three pure pass-throughs on Reporters::Default (save_annotation_for,
  annotate_difference, annotate_skip_areas) plus #save
- VipsDriver.difference_area
- Deprecation.warn's category: kwarg (one value in the tree)

Structural:
- AssertionRegistry#verify drops the guard verify_screenshots! already
  applies and stops computing failed_assertions.first outside the branch
  that reads it
- DSL inlines two empty private hops (build_screenshot_assertion,
  screenshot_namer)

Narrative: collapsed the 20 boilerplate ADR-step forwarder headers under
lib/capybara* to one line each and stripped migration chronology from
snap_diff.rb, dsl.rb, capybara_screenshot_diff.rb, legacy_shims.rb,
image_compare.rb, capture/viewport.rb, screenshoter.rb and snap_manager.rb,
keeping the live constraints.

rake test:unit 532 -> 522, rake test 565 -> 555, both 0 failures.
…cklog) (snap-diff#234)

Three independent beta2-review backlog items.

1. Vips::Image leaked into failure messages. ComparisonResult#to_h merged
   the whole meta hash, including the vips-only :diff_mask image object, so
   a failing assertion printed "diff_mask":"#<Vips::Image:0x...>". #to_h now
   excludes it; the object stays reachable via #diff_mask.

2. test/integration/report_screenshot_test.rb skipped all five tests unless
   RECORD_SCREENSHOTS -- the mode that records baselines rather than
   verifying them. Baselines existed only for macos/cuprite, so on the only
   CI platform (linux) there was nothing to compare against. Deleted, along
   with the orphan baselines. The HTML reporter keeps 18 unit tests, and
   `rake report:sample` already produces a sample report for eyeballing.

3. The gemspec shipped gems.rb, Rakefile and itself while omitting README.md,
   leaving a dead ../README.md link in the packaged docs. Replaced the
   deny-list regex with an allow-list: lib/, docs/, README, LICENSE, CHANGELOG.
…ess) (snap-diff#235)

* test: reverse gate -- no core file may depend on the v1 trees

legacy_tree_is_alias_only_test.rb proves the v1 trees hold no logic.
Nothing proved the mirror image, and it was false: 64 core->legacy edges
(6 backward requires, AVAILABLE_DRIVERS, ~55 config reads through the
legacy view) meant `git rm lib/capybara*` would break the gem.

This gate reports every one as file:line and starts with them all
allowlisted, so it lands green and the following commits can only shrink
it. Red on master with an empty allowlist; mutation-checked by adding a
Capybara::Screenshot::Diff reference to region.rb (gate named it).

Comments are ignored (history, not dependency); strings are not.

* refactor: driver detection lives on SnapDiff::Drivers

SnapDiff::Drivers.available is documented canonical API, but the list it
read was defined only in config_legacy.rb, so

  ruby -Ilib -e 'require "snap_diff/drivers"; SnapDiff::Drivers.available'

raised NameError: uninitialized constant Capybara::Screenshot::Diff. It
now answers.

Detection moved to Drivers.detect_available and runs at drivers.rb load
(same load moment in every entry-point path); SnapDiff::Utils
.detect_available_drivers one-lines into it, so the documented Utils name
and its tests are unchanged. config_legacy keeps AVAILABLE_DRIVERS as an
eager same-object alias -- test_helper still reads it at boot, and
namespace_forwarding_test still pins assert_same.

The stubbing point moves with the value: image_compare_test now stubs
SnapDiff::Drivers::AVAILABLE_DRIVERS. Stubbing the legacy alias would
only rebind the alias, which is the whole point of cutting the edge.

utils.rb requires drivers and drivers now needs Utils at call time, so
drivers.rb requires utils at the bottom -- either file can be required
first.

* refactor: core config reads go to SnapDiff.config, not the legacy view

Twenty-nine reads across twelve core files went through
Capybara::Screenshot / Capybara::Screenshot::Diff -- the legacy VIEW of a
storage that has lived in SnapDiff::Config since snap-diff#230. Same storage,
canonical name; behaviour is unchanged and the legacy delegators stay for
users.

Two follow-ons:
- browser_helpers dropped its respond_to?(:window_size) guard: that
  existed because the mattr_accessor might not be installed yet, and
  Config always has the attribute.
- the fail_if_new error message now tells users
  'SnapDiff.config.fail_if_new = false' -- it named an accessor that 3.0
  deletes.

Tests that STUBBED the legacy accessors now stub SnapDiff.config: a
delegator write still reaches the storage, but a stubbed delegator method
does not, so those stubs were silently no-ops against the new reads. The
legacy accessors keep their own coverage in snap_diff_config_test.rb
(every MAPPING writer) and config_default_timing_test.rb.

* refactor: core requires point at snap_diff/*, not the v1 forwarders

dsl.rb, reporters/html.rb and screenshot_matcher.rb pulled their
dependencies through capybara/screenshot/diff/* and
capybara_screenshot_diff/* forwarders -- files whose only content is a
require of the snap_diff/* unit the core actually wanted. Point at the
units directly.

snap_diff.rb's own two backward requires are the last ones left and go
with the v1-surface consolidation.

* refactor: one file holds the v1 surface, and the core names none of it

Three legacy edges were left in the canonical core:

- lib/snap_diff/config.rb DEFINED the Capybara::Screenshot::Diff module
  skeleton so Config::MAPPING could name it, and generated the legacy
  mattr_accessors. After a 3.0 `git rm` that would have survived: the
  core would still define a phantom v1 namespace, so adopters'
  `defined?(Capybara::Screenshot::Diff)` checks would keep passing.
- lib/snap_diff.rb required config_legacy.rb and image_compare.rb -- the
  only reason being that those forwarders happened to install the v1
  surface for bare `require "snap_diff"` processes.
- SnapDiff.start yields the two legacy holders, so it cannot outlive them.

All three move to lib/snap_diff/legacy_shims.rb, which now holds the whole
v1 surface as code: const_missing forwarders, CONFIG_MAPPING and its
generator, the derived forwarders that were in config_legacy.rb
(Screenshot.active?, Diff.configure/.compare/.default_options),
SnapDiff.start, and the eager AVAILABLE_DRIVERS / Comparison aliases.
config_legacy.rb and image_compare.rb are now requires only.

Config keeps the setting list as Config::SETTINGS and names nothing
legacy; a new test pins CONFIG_MAPPING.keys == SETTINGS so the split
cannot drift into storage with no accessor (or the reverse).

Behaviour is unchanged, including the awkward part: bare
`require "snap_diff"` still answers Capybara::Screenshot.active?,
Diff.default_options, Diff::AVAILABLE_DRIVERS and Diff::Comparison,
exactly as it did when it reached through the v1 forwarders (verified
before/after in a fresh process).

The reverse gate's allowlist is now EMPTY.

* test: alias-only gate demands a real single-expression forwarder

The rule was `body.include?("SnapDiff")` plus a following `end`, which
accepted anything that merely mentioned the canonical namespace:

  def x
    SnapDiff.config.a ? b : c   # a conditional -- real behaviour
  end

  def x; SnapDiff.config.a; File.write(...); end   # two statements

Both are now rejected: a semicolon is never alias-shaped (it is how
several statements, or a whole def, hide inside one "line"), and a
forwarder body must match the whole of FORWARDER_BODY -- one method-call
chain rooted at SnapDiff, at most one argument list and one block.
Mutation-checked with exactly those two shapes plus a genuine forwarder,
which still passes.

* build: gemspec resolves the version from snap_diff/version

The gemspec required capybara/screenshot/diff/version and read
Capybara::Screenshot::Diff::VERSION -- a build-time dependency on a file
3.0 deletes, so `gem build` would have failed the moment it did. Same
value (the legacy constant is an alias of SnapDiff::VERSION), one fewer
3.0 blocker. The legacy constant stays for adopters who read it.

Found by the 3.0 dry run; fixed here because it is one line and in
scope.

* fix: legacy VERSION vanished from six entry points

Capybara::Screenshot::Diff::VERSION raised NameError -- and defined?
returned nil -- under "snap_diff", snap_diff/dsl, both integrations,
snap_diff/static and, worst, the LEGACY capybara_screenshot_diff/dsl.

Cause: the constant was assigned by capybara/screenshot/diff/version.rb,
and the 3.0-readiness pass stopped the core requiring that forwarder.
legacy_shims deliberately omits VERSION from its const_missing map -- it
is one of the documented eager exceptions -- so nothing filled the gap and
adopters' `defined?` feature detection silently went false.

Assign it in legacy_shims next to the other eager aliases: that file is
required by every entry point, canonical and legacy, so it is the only
place the eager exceptions can actually be eager. version.rb drops the
assignment (a second one is a duplicate-constant warning, not a safety
net) and becomes a require.

The suite could not have caught it: EAGER_USER_FACING was only probed for
the four legacy entry points, and VERSION was not in the list at all. New
EAGER_EVERYWHERE probe covers all 15 entry points including
capybara_screenshot_diff/dsl, which is in none of the existing lists --
exactly why it was the legacy entry that lost VERSION unnoticed. Red
without the alias, naming all six.

Also corrects two now-false claims in the legacy_shims header: it said
AVAILABLE_DRIVERS was aliased in config_legacy.rb (it is aliased in this
file) and that VERSION/Comparison were defined by their own forwarder
files (nothing requires those anymore).

* test: reverse gate catches require_relative escapes

LEGACY_REQUIRE anchored straight on the opening quote, so

  require_relative "../capybara/screenshot/diff/version"

passed the gate while genuinely loading the v1 file (confirmed via
$LOADED_FEATURES). Every core file sits one directory below lib/, so that
is a one-line escape, not a hypothetical. Allow an optional (\.{1,2}/)* --
mutation-checked with exactly that line.

Two smaller fixes while here: the header claimed comments are ignored when
only WHOLE-LINE ones are (trailing notes on a live code line are scanned,
which is the behaviour worth keeping -- the header now says so), and the
stale-allowlist check now reports a deleted allowlisted file instead of
raising Errno::ENOENT on it.

* test: forwarder rule takes simple pass-through args only

The tightened rule rejected ternaries and semicolons but left `(.*)` and
`{ .* }` unbounded, so both of these still passed as "forwarders":

  SnapDiff.config.x(File.exist?("/etc/passwd") ? raise("boom") : ENV.fetch("HOME"))
  SnapDiff.config.tap { |c| File.write("/tmp/pwned", c.inspect); exit 1 }

The block case also dodged the semicolon check, because the walk steps
over a def's body line without re-examining it -- that scan now runs over
every line up front.

An argument list is now names, commas, splats and keyword colons, or
Ruby's `...` forwarding. No parens, so no nested call; no `.`, so no bare
receiver call; no `?`/quote/`=`, so no conditional, literal or assignment.
The block form is gone entirely: nothing in these trees has a def left,
and an unbounded block is exactly the hole above.

Mutation-checked with both shapes above (rejected, the block twice) plus
three genuine forwarders that must not false-positive: a bare chain, an
argument pass-through, and CapybaraScreenshotDiff.serve(...) -- which the
first draft of this rule did reject.

* docs: flag the two beta moves that fail silently

Both are read-identical and only break on write, so nothing warns:

- stubbing Capybara::Screenshot::Diff::AVAILABLE_DRIVERS now only rebinds
  an alias -- the gem reads SnapDiff::Drivers::AVAILABLE_DRIVERS, so a
  downstream test stubbing it to [] stops exercising the no-drivers path
  and passes for the wrong reason;
- SnapDiff::Config::MAPPING is gone mid-beta, split into Config::SETTINGS
  and the @api-private LegacyShims::CONFIG_MAPPING.
…ap-diff#236)

* test: move the v1-surface tests into test/legacy/ and add rake test:canonical

The five tests whose SUBJECT is the v1 compatibility surface now live in
test/legacy/, so the 3.0 deletion is one more path on the same git rm:

  git rm -r lib/capybara* ... test/legacy

A directory rather than a list in the Rakefile: nothing to keep in sync.

- rake test           unchanged, runs everything (today's gate)
- rake test:canonical NEW, everything except test/legacy (the 3.0 gate)
- rake test:unit      test/unit + test/legacy, so the release gate keeps
                      its coverage (legacy/ marks lifetime, not kind)

errors_alias_test.rb was mixed: the four CapybaraScreenshotDiff::* alias
pairs are v1 surface, the hierarchy assertions outlive them. Split rather
than moved whole -- test/unit/errors_test.rb keeps the two canonical tests
verbatim, so no assertion is lost at 3.0.

530 runs, 1519 assertions, 0 failures (unchanged).

* test: point the whole canonical suite at SnapDiff names

The suite still spoke v1 everywhere, so it would have broken on the 3.0
deletion even though the gem no longer does. Mechanical, no behaviour and
no assertion values changed:

- harness: test_helper + system_test_case load snap_diff/integrations/*
  and configure through SnapDiff.config; the support stubs (DSLStub,
  ScreenshoterStub, TestDoubles, DriverCoverage, NonMinitest) stop
  reopening gem namespaces and become plain top-level modules
- 33 test files were defined inside module Capybara::Screenshot(::Diff) /
  CapybaraScreenshotDiff -- de-nested to top-level classes, so no bare
  constant resolves into a namespace 3.0 deletes
- 270 legacy constant/accessor/session call sites repointed
  (CapybaraScreenshotDiff.registry -> SnapDiff.session, .reporters ->
  SnapDiff::Reporting.reporters, Capybara::Screenshot.root ->
  SnapDiff.config.root, ...) and 25 legacy require paths
- legacy-surface tests now require the v1 entry point themselves, since
  the shared harness no longer loads it

Three claims would have become tautologies under a blind repoint
(assert_same SnapDiff.session, SnapDiff.session and friends): they were
forwarder-identity claims about the v1 view. Preserved verbatim in the new
test/legacy/legacy_forwarders_test.rb together with SnapDiff.start, which
yields the two v1 holders and cannot outlive them.

rake test:unit 534 runs, 1526 assertions, 0F/0E (was 530/1519)
rake test      562 runs, 1571 assertions, 0F/0E/1S (was 558/1564)
+4 runs: legacy_forwarders_test keeps the v1 claim where the canonical
file also kept its own version (register-appends, reporters_mutex, serve).

* test: split the mixed config/entry-point files along the same line

Four files asserted the canonical behaviour AND the v1 view of it in one
place, so a receiver repoint turned real claims into tautologies. Each is
now two files; the v1 half is verbatim, and the canonical half stands on
its own after 3.0:

- snap_diff_config_test        -> + test/legacy/legacy_config_accessors_test
  (CONFIG_MAPPING completeness, the mattr_accessor round trips, active?
  through the legacy forwarder, SnapDiff.start)
- config_default_timing_test   -> + test/legacy/legacy_config_default_timing_test
  canonical keeps snap_diff + snap_diff/integrations/minitest and reads
  SnapDiff.config only; the legacy file re-runs the SAME probe scripts
  under the v1 entries and adds the both-surfaces-agree loop, which is
  exactly what check_both asserted -- one source of truth, no drift
- support_load_probe_test      -> + test/legacy/legacy_entry_point_probe_test
  (advertised v1 constants, the CapybaraScreenshotDiff session surface,
  EAGER_USER_FACING / EAGER_EVERYWHERE under their OLD names)
- errors_alias_test (earlier commit) -> test/unit/errors_test

snap_diff-capybara joins CANONICAL_ENTRY_POINTS: 3.0 keeps that entry
point (repointed at snap_diff/integrations/minitest), and it was covered
only as a legacy entry, so it would have lost all coverage.

Also repointed the last legacy call sites the sweep left: the rspec
fixtures stubbed Capybara::Screenshot::Diff.pending_if_new, which the core
stopped reading in snap-diff#235 -- a silent no-op stub, now SnapDiff.config.

rake test:unit 544 runs, 1544 assertions, 0F/0E
rake test      572 runs, 1589 assertions, 0F/0E/1S

* test: port the canonical claims that only a legacy-surface test was pinning

Audit of every assertion moving into test/legacy/, asking: if this file
vanished at 3.0, would any CANONICAL behaviour become untested? Two hits,
both now duplicated (not moved) into a canonical test -- the v1 originals
stay put, they still guard the v1 contract for all of 2.x:

- namespace_forwarding_test was the only place proving SnapDiff::Drivers
  .loaded is ONE hash mutated in place (it asserted the v1 LOADED_DRIVERS
  constant is that same object, and that registering through it shows up
  canonically). Utils.find_driver_class_for caches through .loaded, so a
  copy-returning refactor would break user driver registration silently.
  -> drivers_test ".loaded is a single hash mutated in place"
     mutation: `.loaded.dup[...] = ...` -> red, Expected :probe_driver, got nil

- the entry-point probe was the only place asserting an entry point defines
  its advertised CONSTANTS when it is the ONLY require (the f89cea2 bug
  class) -- but only for the v1 names.
  -> support_load_probe_test "every canonical entry point defines its
     advertised constants standalone", same claim over snap_diff/dsl,
     /integrations/minitest, /integrations/rspec, snap_diff-capybara.
     Entry-specific, because bare snap_diff carries neither DSL nor
     reporters by design.
     mutation: a bogus constant in the list -> red, naming it

Also: attempts_reporter_test now requires snap_diff/attempts_reporter --
stable_screenshoter pulls it in lazily and the v1 umbrella was what loaded
it eagerly, so it was the one canonical test the deletion actually broke.

Judged legacy-only and safe to lose at 3.0: const_missing/eager alias
semantics, deprecation warn-once + silencing, CONFIG_MAPPING completeness,
the alias-only scan of lib/capybara*, and the CapybaraScreenshotDiff
session/reporter forwarders -- every one is about a name 3.0 deletes, and
its canonical counterpart is pinned in test/unit/.

rake test 574 runs, 1593 assertions, 0F/0E/1S

* fix: the canonical gate demanded SnapDiff.start, which 3.0 deletes

Caught by independent review: `rake test:canonical` in the deleted tree is
1F, not the 0F I published.

CANONICAL_SURFACE listed `start`, applied to all 7 canonical entry points.
SnapDiff.start is defined only in lib/snap_diff/legacy_shims.rb:169 and
yields the two v1 config holders, so it cannot outlive them (snap-diff#235 decided
this). A canonical gate demanding a method 3.0 deletes is a gate that goes
red the day the deletion lands -- and I widened it in e789c8a by adding
snap_diff-capybara. My own PR body filed .start under "safe to lose at 3.0".

  mutation (start put back, deleted tree):
    require "snap_diff"                       -> missing: start
    require "snap_diff/dsl"                   -> missing: start
    require "snap_diff/integrations/minitest" -> missing: start
    ... all 7 entry points

.start keeps full coverage on the legacy side: legacy_forwarders_test pins
what it yields and that it applies a setting, and a new per-entry-point
probe in legacy_entry_point_probe_test pins the availability claim the
canonical gate used to make -- for the entries that actually keep it.

Also, per review:

- "bare require never loads the umbrella" moves to legacy_forwarders_test.
  Its subject is lib/capybara_screenshot_diff.rb; once 3.0 deletes that
  file the $LOADED_FEATURES grep is empty by construction and the guard can
  never fail again. (-1 canonical run: 458 -> 457.)
- backtrace_filter_test built synthetic paths under lib/capybara_screenshot_diff/.
  Pure string inputs to a prefix matcher, so no assertion changes -- but one
  of them named the real file the filter defaults to, which 3.0 deletes.

rake test:unit      547 runs, 1550 assertions, 0F/0E/0S
rake test           575 runs, 1595 assertions, 0F/0E/1S
rake test:canonical 457 runs, 1288 assertions, 0F/0E/1S
  ... and 457/1288/0F/0E/1S in the deleted tree, identical.
…nap-diff#238)

Six gaps a coverage+mutation audit measured, each closed with a test that
was proven to go red against the mutation it is meant to catch.

The headline is test/unit/deletion_3_0_test.rb: the alias-only gate and the
reverse gate are static PROXIES for "git rm the v1 surface and the gem still
loads". This runs it -- copies lib/ to a tmpdir, applies the deletion set and
the two edits it needs, and requires every canonical entry point in a fresh
subprocess. Every probe hard-asserts the deletion is in effect BEFORE it
asserts anything about the surface, because a run that cannot tell the
deleted tree from the intact one is not evidence.

Also: the vips tolerance floor, SnapDiff.compare's default_options merge,
Reporters::Default#generate's equal-path cleanup, Viewport.prepare!'s
selenium arm, and all four screenshot_area path-segment combinations.

No lib/ changes.
…nt deprecation strategy (snap-diff#237)

* fix: circular require warning, partial-migration constant crash, silent deprecation strategy

Three defects found by running the gem against a real consumer project
(jetthoughts.github.io, upgraded in Docker against its committed baselines).

1. drivers.rb <-> utils.rb formed a require cycle. Harmless functionally,
   but Ruby shouts "circular require considered harmful" with an 18-frame
   backtrace whenever $VERBOSE is on -- which Rake::TestTask sets by
   default, i.e. every standard Rails/Minitest suite. Deferred the require
   into Drivers.for, the only method that needs Utils.

2. A canonical require left most of the v1 constant surface unresolvable,
   including Capybara::Screenshot::Os, which killed a real suite before a
   test ran. Two causes, both fixed at the class level: the eager aliases
   lived in lib/capybara* forwarders that only legacy entry points load
   (moved to legacy_shims, which every entry point loads), and the
   const_missing shims named their replacement without loading it (they
   now require the target unit, and raise a NameError naming the SnapDiff
   name and UPGRADING.md if it truly cannot load).

3. Exercising all 14 legacy APIs a real setup file touches produced zero
   warnings: Deprecation.warn was reachable only through const_missing, so
   config delegators and eager aliases were structurally silent. Added one
   migration notice per process, fired from every hookable door, and
   corrected the docs that claimed every legacy name warns.

* fix: gate the driver-leaf autoloads on what this process can actually load

Review follow-up.

The unconditional `autoload :VipsDriver` made `const_defined?(:VipsDriver)`
true on a box without ruby-vips, where `const_get` then raises. Neither
driver gem is a runtime dependency and the documented v1 pattern is
`Diff.driver = :vips if defined?(...Drivers::VipsDriver)`, so that branch
started being taken and then dying. v1.12.0 loaded vips_driver.rb only from
find_driver_class_for, so `defined?` was nil there. Both leaves are now
declared only when their gem is present.

Also: `autoload :Utils` instead of a deferred require inside Drivers.for.
Removing the bottom-of-file require narrowed the surface -- `require
"snap_diff/drivers"` stopped defining SnapDiff::Utils, which drivers_test
caught. Autoload breaks the cycle without narrowing anything.

And `Capybara::Screenshot::Diff.default_options`, a documented v1 read, is
hand-written rather than a generated delegator, so it fired no migration
notice. One line, plus its own row in the per-door guard table.
`rake test:canonical` is defined as "exactly what must still pass once
test/legacy/ and the v1 trees are gone". Three times in one day a test
asserting LEGACY behaviour was written into test/unit/, i.e. into that
suite: a canonical surface table demanding the shim-only SnapDiff.start
(snap-diff#236), three legacy-constant probes in a canonical file (snap-diff#237), and a
pre-existing umbrella guard snap-diff#236 had to relocate. Each would have failed
the day the deletion landed, long after its author moved on. Reviews
caught all three; the fourth would ship.

The test-tree twin of core_tree_has_no_legacy_deps_test.rb: no file under
test/unit/ or test/integration/ may require a doomed path, name a v1
namespace constant, or use a shim-only name (SnapDiff.start,
.silence_deprecations, SnapDiff::Deprecation, suppress_migration_notice!).
test/legacy/ is deliberately not policed -- exercising the legacy surface
is its job.

Same conventions as the twin: file:line: reason -- `code`, whole-line
comments ignored, a vacuity guard, and a sub-test that fails on stale
allowlist entries. The allowlist holds two entries, both gates rather
than tests of behaviour (deletion_3_0_test.rb, which names the deletion
set by construction, and the twin gate's own pattern literal). A third
entry means canonical tests are still entangled and needs a decision, not
a green build.

This file cannot scan itself: a line-level allowlist has to quote the
lines it blesses, and every quote is itself an offence -- no fixed point
exists.
The stray detector matched a bare /snap_diff/ substring against
$LOADED_FEATURES. CI checks this repo out at .../snap_diff-capybara/, so
every gem under vendor/bundle matched and the gate aborted on nokogiri.
It passed locally only because the dev checkout is named
capybara-screenshot-diff. Anchored on /lib/snap_diff(/|.rb).
* ci: exercise the vips driver on JRuby

The JRuby cells install libvips and ruby-vips already -- the setup action
installs libvips unconditionally and gems.rb declares ruby-vips with no
platform guard -- so `Drivers.detect_available` has always reported
`vips, chunky_png` there, and the vips-gated unit tests have always run.

What never ran on JRuby was the integration path: `bin/rake test` leaves
SCREENSHOT_DRIVER unset, and test/system_test_case.rb defaults that to
chunky_png. So capture -> compare -> annotate, plus the vips cache flush
in that file's teardown, were MRI-only.

Point the JRuby cells at vips. Non-JRuby cells get chunky_png spelled
out, which is the default they already had.

Verified locally on JRuby 10.0.6.0 + libvips 8.18.5:

  test/unit/drivers/vips_driver_test.rb
    50 runs, 104 assertions, 0 failures, 0 errors, 0 skips

  test/integration (cuprite, SCREENSHOT_DRIVER=vips)
    28 runs, 45 assertions, 0 failures, 0 errors, 1 skips

Identical to the MRI 4.0.6 control on the same machine (28 runs, 45
assertions, 0 failures, 0 errors, 1 skips).

* ci: give the JRuby cells a budget that fits the suite

Separate from the vips switch and a fix for a failure already on master:
every JRuby cell of run 32638878557 (master b1d01af, chunky_png, no
change from this branch) burns all three attempts on
`Timeout of 420000ms hit` and is killed at the 20-minute job cap. The
cells have stopped gating anything.

The suite outgrew the budget -- it is ~600 tests now. Measured on JRuby
10.0.6.0 locally, same machine, same suite, only SCREENSHOT_DRIVER
differing:

  vips        598 runs, 1683 assertions, 0 failures, 0 errors -- 390.4s
  chunky_png  598 runs, 1683 assertions, 0 failures, 0 errors -- 434.4s

vips is the faster of the two, so this is not driver cost. Raise the
per-attempt budget to 15 minutes and the job cap to 25 so one attempt
plus setup fits with headroom. A passing cell still costs ~8 minutes; a
failing one costs 25 instead of 20, but produces a result rather than a
cancellation.
The retry budget was larger than the job cap on both engines, so the last
attempt was always killed partway and the cell reported `cancelled`.

Measured on master run 32643567648 (post-snap-diff#243), the 5 JRuby cells:

  jruby-10.0 rails81  suite 713s, no hang, 1 attempt   -> 12m02  pass
  jruby-10.0 rails71  suite 545s + 6m hang -> 15m t/o;
                      attempt 2 clean at 543s          -> 24m20  pass
  jruby-10.0 rails80  attempt 1 hung -> 15m t/o;
                      attempt 2 killed at the 25m cap  -> cancelled
  jruby-10.0 rails72  same                             -> cancelled
  jruby-head rails81  same                             -> cancelled

3 of 5 JRuby cells gate nothing. The cause is arithmetic, not the driver:
max_attempts 3 x timeout_minutes 15 = 45 min against timeout-minutes 25.
Attempt 3 could never start, and attempt 2 had only 25 - 15 - 0.7 = 9.3 min
to finish a run that measures 9-12 min -- so whether a cell survived a hang
came down to which gemfile it drew. MRI has the same shape, smaller: 3x3 = 9
against a cap of 8.

  - JRuby job cap 25 -> 31, so 1 + 15 + 15 fits.
  - max_attempts 3 -> 2 on both engines, since 3 was never reachable.

A doubly-hung JRuby cell now costs 31 min instead of 25, but today's 25 min
buys no verdict at all. Coverage, drivers and the full-ci/cron opt-in model
are unchanged.

Also adds the concurrency group Lint never had, so superseded PR pushes stop
running the linter to completion.
…action (removed in 2.1) (snap-diff#246)

2.0 is the transitional release: the contract is published before it is
enforced. The legacy-namespace half of that promise already warns. The
driver half -- everything 2.1 removes so that libvips becomes the only
backend -- warned about nothing. A user on `driver: :auto` without libvips
had no way to learn that 2.1 stops comparing for them.

Six warnings, each once per process, each silenceable through the existing
switches (`SnapDiff.silence_deprecations`, SNAP_DIFF_SILENCE_DEPRECATIONS):

- chunky_png SELECTED, from Utils.find_driver_class_for -- the one funnel
  every selection surface ends up in (`driver:` per comparison,
  SnapDiff.config.driver, the legacy Diff.driver=).
- `:auto` FALLING BACK to chunky_png because libvips is absent. Same funnel,
  its own message and key: these users never asked for chunky_png.
- shift_distance_limit SET, from Config#shift_distance_limit= and from
  Comparison#initialize (non-nil only -- default_options carries the key on
  every comparison). One shared key, so it warns once whichever fires.
- Drivers.loaded, the documented custom-driver registration point.
- Drivers.available, i.e. driver detection.
- `include SnapDiff::Driver` by a class that is not one of the gem's own.

NOT warned on, and documented as silent: Drivers.for (the gem calls it for
every comparison, so warning there would fire on vips-only setups that
nothing here affects), detection itself (runs at load, before user code),
and the eager LOADED_DRIVERS / AVAILABLE_DRIVERS constant aliases (nothing
to hook). Each entry names the warning-capable equivalent instead.

The machinery is SnapDiff::Removal -- same channel, same discipline and the
same silencing switches as SnapDiff::Deprecation, in its own file: the
legacy shims and their deprecation channel are part of what the deletion
removes, while these call sites (utils, config, comparison, drivers) are
core files that outlive them and cannot depend on a doomed file.
SnapDiff.silence_deprecations moved there for the same reason -- it is the
one switch that silences both halves -- which is why the canonical-suite
gate no longer lists it as shim-only surface.

Two internal seams keep the gem from warning at itself: Drivers.registry
(the unannounced registry the gem reads) and reading the
AVAILABLE_DRIVERS constant directly instead of .available. test_helper
suppresses the channel the same way it already suppresses the migration
notice: this suite runs its whole matrix on chunky_png by design.

Evidence: 13 subprocess probes ("once per process" cannot be measured
inside one long-lived suite), mutation-tested -- dropping the dedup goes 9
red, dropping the silence check 2 red, routing an internal read back
through .loaded 5 red, unscoping the mixin hook 5 red, warning on the
presence of shift_distance_limit rather than a value 4 red.

rake test:unit 585/0, rake test 613/0/1, rake test:canonical 482/0/1,
standardrb clean.
2.0 is the transitional release; 2.1 deletes the legacy namespace trees.
The migration notice, its pinning test, the reverse gate's constant and
the deletion test's name all still said 3.0 -- text that ships to users
in 2.0.

The notice test now asserts the whole clause rather than the version
alone, so a wrong deprecation WINDOW fails it too, not just a wrong
removal version.
…nap-diff#248)

JRuby joins every live Ruby thread at interpreter teardown
(Ruby.tearDown -> ThreadService.teardown -> Thread#join) where MRI just
kills them. Puma's reactor thread parks in a native KQueue.poll/epoll_wait
inside nio4r that no interrupt can wake, so a Capybara-booted Puma keeps a
JRuby process alive forever after an otherwise green run (snap-diff#244).

The process that actually hangs is usually not the suite's own: it is one
of the Open3.capture2e subprocesses run by the RSpec fixtures, which load
support/setup_capybara, boot Puma and Chrome, finish their example and
never exit -- the parent then blocks in capture2e with no timeout, which
is the reported "stalls mid-run" shape.

Capybara's stock :puma block builds its Puma::Server into a block-local
and joins it, so nothing keeps a handle and Capybara::Server has no #stop.
Register an equivalent block that keeps the handle and stop the server for
real once the framework is done. Puma::Server#stop closes the reactor's
input queue and wakes the selector, which is the only thing that gets that
thread out of the native poll.

Ferrum is not implicated: its threads park on Queue#pop, which JRuby
interrupts cleanly, so no browser quit is needed.
Deletes everything 2.0 announced as removed in 2.1, in one release:

- The v1 namespace trees (lib/capybara/, lib/capybara_screenshot_diff/,
  the two gem-name entry points, legacy_shims.rb, deprecation.rb,
  test/legacy/). SnapDiff.configure is the single config entry point;
  SnapDiff.start yielded the two v1 holders and could not outlive them.
- The chunky_png driver and shift_distance_limit (chunky-only, no libvips
  equivalent).
- The whole driver abstraction: the SnapDiff::Driver mixin, the
  SnapDiff::Drivers registry (.loaded/.available/.for/.registry/
  .detect_available), AVAILABLE_DRIVERS, Utils.detect_available_drivers,
  and :auto selection. Comparison and Screenshoter construct
  Drivers::VipsDriver directly.
- The `driver:` config setting. With one backend it cannot select
  anything, and accept-and-ignore would let a config claim a backend
  choice that does not exist.
- snap_diff/removal.rb, which existed only to warn about the above.
  SnapDiff.silence_deprecations goes with it: no channel is left.

ruby-vips becomes a real gemspec runtime dependency (>= 2.0, < 3), so a
missing binding is a resolver error rather than a runtime one.

Fixes a latent bug the deletion exposed: libvips caches loader operations
on filename + mtime, and mtime has one-second resolution, so rewriting a
screenshot path and re-reading it within the same second served the
PREVIOUS image. It was masked because a Comparison built without an
explicit driver defaulted to chunky_png, which always re-read the file.
VipsDriver#from_file now passes `revalidate: true`; the cache-flush
workarounds in system_test_case.rb and vips_driver_test.rb are gone.

Gates: legacy_deletion_test (simulated a deletion that has happened) and
legacy_tree_is_alias_only_test (its subject is gone) are replaced by
removed_surface_test, which asserts absence -- no removed path back under
lib/, no removed name defined in a fresh process -- behind a gate line
that proves it measured this repo's lib/. The two reverse gates survive,
repurposed to catch removed names in strings and docstrings.

test:canonical was "everything except test/legacy/", which is now exactly
test; the tasks converged and the second name is gone. `rake test` is THE
gate. test:benchmark is deleted (it required a script not in this repo).
@pftg

pftg commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Opened against the wrong remote by mistake; the upstream is snap-diff/snap_diff-capybara. Reopening there.

@pftg pftg closed this Aug 23, 2026
@pftg
pftg deleted the v2.1/delete-legacy-and-drivers branch August 23, 2026 18:35
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.

5 participants