Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,27 @@ jobs:
- name: Test
run: bundle exec rake test:unit

# Idempotent so a failed run can be re-dispatched (bit us at beta1):
# skip when the tag already exists at HEAD, fail loudly if it exists
# at a different commit -- never retag.
- name: Create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v${{ github.event.inputs.version }}" -m "v${{ github.event.inputs.version }}"
git push origin "v${{ github.event.inputs.version }}"
TAG="v${{ github.event.inputs.version }}"
# checkout doesn't fetch tags; pull this one if it exists remotely.
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG" 2>/dev/null || true
if EXISTING=$(git rev-parse -q --verify "refs/tags/$TAG^{commit}"); then
if [ "$EXISTING" = "$(git rev-parse HEAD)" ]; then
echo "Tag $TAG already exists at HEAD; skipping tag creation."
else
echo "::error::Tag $TAG already exists at $EXISTING, but HEAD is $(git rev-parse HEAD). Refusing to retag."
exit 1
fi
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
fi

- name: Publish to RubyGems
uses: rubygems/release-gem@v1
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Stop shipping UI bugs. Take screenshots in your Capybara tests, commit baselines

**Why this gem?** Baselines live in git — review UI changes in pull requests like you review code. Runs offline, works in CI, zero vendor lock-in. Unlike Percy/Chromatic (paid SaaS), nothing to sign up for. Unlike BackstopJS, no Node required.

> **2.0 experiment (alpha):** the gem is moving to a `SnapDiff` canonical namespace. Opt in with `gem "capybara-screenshot-diff", "2.0.0.alpha1"` (prereleases are never installed by default — normal installs stay on 1.x). Legacy names keep working with a one-time deprecation warning, silenceable via `SnapDiff.silence_deprecations = true` or `SNAP_DIFF_SILENCE_DEPRECATIONS=1`. See the [upgrade guide](docs/UPGRADING.md) and share feedback on [#166](https://github.com/snap-diff/snap_diff-capybara/issues/166).
> **2.0 experiment (alpha):** the gem is moving to a `SnapDiff` canonical namespace. Opt in with `gem "capybara-screenshot-diff", "2.0.0.beta1"` (or the latest 2.0.0 prerelease; prereleases are never installed by default — normal installs stay on 1.x). Legacy names keep working with a one-time deprecation warning, silenceable via `SnapDiff.silence_deprecations = true` or `SNAP_DIFF_SILENCE_DEPRECATIONS=1`. See the [upgrade guide](docs/UPGRADING.md) and share feedback on [#166](https://github.com/snap-diff/snap_diff-capybara/issues/166).
>
> Starting with the 2.0 prereleases the gem is also published as [`snap_diff-capybara`](https://rubygems.org/gems/snap_diff-capybara) — identical content and versions under the forward-looking name, matching this repository. Install either; don't install both.

Expand Down
6 changes: 3 additions & 3 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Version 2.0 introduces a new canonical namespace (`SnapDiff`) for cleaner, more discoverable code. The public DSL remains unchanged — your existing `screenshot` and `assert_matches_screenshot` calls work without modification. This guide covers the optional migration path for settings and the new namespace.

**Status:** `2.0.0.alpha1` is an opt-in prerelease. RubyGems never installs prereleases by default resolution — normal `bundle update` keeps you on the 1.x line. The final 2.0.0 ships only after adopter feedback; please report anything surprising on [#166](https://github.com/snap-diff/snap_diff-capybara/issues/166).
**Status:** `2.0.0.beta1` is an opt-in prerelease. RubyGems never installs prereleases by default resolution — normal `bundle update` keeps you on the 1.x line. The final 2.0.0 ships only after adopter feedback; please report anything surprising on [#166](https://github.com/snap-diff/snap_diff-capybara/issues/166).

**Estimated upgrade time:** 5–15 minutes (most users need only the Gemfile pin)

Expand All @@ -18,7 +18,7 @@ Version 2.0 introduces a new canonical namespace (`SnapDiff`) for cleaner, more

```ruby
# In your Gemfile — the exact prerelease version is required to opt in
gem "capybara-screenshot-diff", "2.0.0.alpha1"
gem "capybara-screenshot-diff", "2.0.0.beta1" # or the latest 2.0.0 prerelease
```

```bash
Expand Down Expand Up @@ -218,7 +218,7 @@ All settings and baselines are compatible with v1.x. Simply pin your Gemfile bac

### Summary Checklist

- [ ] Pin `gem "capybara-screenshot-diff", "2.0.0.alpha1"` in your Gemfile
- [ ] Pin `gem "capybara-screenshot-diff", "2.0.0.beta1"` (or the latest 2.0.0 prerelease) in your Gemfile
- [ ] Run `bundle install`
- [ ] Run your test suite to verify no regressions
- [ ] (Optional) Migrate config to the `SnapDiff` namespace
Expand Down
21 changes: 21 additions & 0 deletions lib/snap_diff.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# frozen_string_literal: true

# Dual-install guard: capybara-screenshot-diff and snap_diff-capybara ship
# identical files. With BOTH activated, every require silently resolves
# from whichever gem activated first, so version skew between the two is
# undetectable. Refuse that setup at the entry point. Local dev from
# source loads neither spec, so the guard fires only when both are
# genuinely installed as gems.
module SnapDiff
DualInstallError = Class.new(StandardError)

# @api private
def self.assert_single_gem!(loaded_specs = Gem.loaded_specs)
return unless loaded_specs.key?("capybara-screenshot-diff") && loaded_specs.key?("snap_diff-capybara")

raise DualInstallError,
"Both `capybara-screenshot-diff` and `snap_diff-capybara` gems are installed. " \
"They ship identical files, so files load from whichever gem activated first " \
"and versions can silently diverge. Remove one of them from your Gemfile."
end
end
SnapDiff.assert_single_gem!

# None of these requires ever leads back to this file: config_legacy is a
# leaf (see its own header comment), the image_compare forwarder pulls in
# snap_diff/comparison plus the legacy const_missing shims, and
Expand Down
34 changes: 26 additions & 8 deletions lib/snap_diff/deprecation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ module SnapDiff
#
# Internal until the v2 namespace transition; not a public contract.
#
# Warn-once-per-subject deprecation helper. Dormant: nothing in the
# current codebase calls this yet -- it exists so the gated v2 shim layer
# (ADR-004's +const_missing+-based legacy constant shim and the
# mattr_accessor-to-Config method shims) has pre-tested warning
# machinery to call into once it lands.
# Warn-once-per-subject deprecation engine for the legacy-namespace
# shims: snap_diff/legacy_shims routes every +const_missing+ hit on an
# old +Capybara::Screenshot::Diff+ / +CapybaraScreenshotDiff+ constant
# through {.warn}, so each deprecated name warns exactly once per
# process (ADR-004's v2 namespace transition).
module Deprecation
# Everything under lib/ is "the gem"; the first caller frame outside
# it is the user code that referenced the deprecated name (same
# filtering idea as BacktraceFilter in error_with_filtered_backtrace).
GEM_LIB_DIR = File.expand_path("..", __dir__) + File::SEPARATOR
# Emission channel: Kernel#warn, not a direct +$stderr.puts+.
#
# Kernel#warn delegates to +Warning.warn+ (Ruby >= 2.4), so anything
Expand Down Expand Up @@ -40,7 +44,7 @@ def warn(subject, replacement, category:)
end
return unless first_time

Kernel.warn(message_for(subject, replacement, category))
Kernel.warn(message_for(subject, replacement, category, caller_locations(1)))
end

# @api private
Expand All @@ -55,9 +59,23 @@ def reset!

private

def message_for(subject, replacement, category)
"[snap_diff deprecation] `#{subject}` is deprecated (#{category}); " \
def message_for(subject, replacement, category, locations)
message = "[snap_diff deprecation] `#{subject}` is deprecated (#{category}); " \
"use `#{replacement}` instead."
origin = origin_for(locations)
origin ? "#{message} (called from #{origin})" : message
end

# First frame outside the gem's lib dir, formatted "file:line";
# nil when every frame is internal (or paths are unavailable).
def origin_for(locations)
(locations || []).each do |location|
path = location.absolute_path || location.path
next if path.nil? || path.start_with?(GEM_LIB_DIR)

return "#{path}:#{location.lineno}"
end
nil
end
end
end
Expand Down
11 changes: 11 additions & 0 deletions test/unit/snap_diff_deprecation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ def capture_warnings
assert_match(/New::Thing/, lines.first)
end

# Actionable attribution: the first caller frame OUTSIDE the gem's lib
# dir is named, so users can find the deprecated reference. This test
# file plays the part of "user code" -- the warning must point here.
test "warning names the caller's file and line" do
lines = capture_warnings do
SnapDiff::Deprecation.warn("Old::Where", "New::Where", category: :constant)
end

assert_match(/called from #{Regexp.escape(File.expand_path(__FILE__))}:\d+/, lines.first)
end

test "warns separately for different subjects" do
lines = capture_warnings do
SnapDiff::Deprecation.warn("Old::A", "New::A", category: :constant)
Expand Down
41 changes: 41 additions & 0 deletions test/unit/snap_diff_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,47 @@ class SnapDiffTest < ActiveSupport::TestCase
assert status.success?, "expected bare `require \"snap_diff\"` to annotate a difference, got:\n#{out}"
end

# Acyclicity contract (the #208 deadlock-class fix): the lean
# `require "snap_diff"` entry must NEVER pull the umbrella
# capybara_screenshot_diff.rb back in. The old autoload wiring had
# snap_diff <-> capybara_screenshot_diff requiring each other, which
# produced load-order deadlocks/partially-initialized constants; #208
# broke the cycle, but until now only discipline guarded it -- a probe
# that reintroduced the cycle left the whole suite green. This asserts
# the contract as data: after a bare require, the umbrella file must be
# absent from $LOADED_FEATURES.
test "bare require \"snap_diff\" never loads the umbrella capybara_screenshot_diff" do
script = <<~RUBY
require "snap_diff"
umbrella = $LOADED_FEATURES.grep(%r{/lib/capybara_screenshot_diff\\.rb\\z})
abort("umbrella loaded via: \#{umbrella.join(", ")}") unless umbrella.empty?
RUBY

out, status = Open3.capture2e(RbConfig.ruby, "-Ilib", "-e", script)

assert status.success?, "expected bare `require \"snap_diff\"` to keep the umbrella unloaded, got:\n#{out}"
end

# Dual-install guard: both gem names ship identical files, so with BOTH
# activated every require silently resolves from whichever gem activated
# first -- version skew between them is undetectable. The entry point
# refuses that setup outright.
test "raises when both capybara-screenshot-diff and snap_diff-capybara are activated" do
specs = {"capybara-screenshot-diff" => :spec, "snap_diff-capybara" => :spec}

error = assert_raises(SnapDiff::DualInstallError) { SnapDiff.assert_single_gem!(specs) }

assert_match(/capybara-screenshot-diff/, error.message)
assert_match(/snap_diff-capybara/, error.message)
assert_match(/remove one/i, error.message)
end

test "dual-install guard passes single-gem installs and local dev from source" do
SnapDiff.assert_single_gem!({"capybara-screenshot-diff" => :spec})
SnapDiff.assert_single_gem!({"snap_diff-capybara" => :spec})
SnapDiff.assert_single_gem!({}) # local dev from source: neither spec loaded
end

test ".start yields the same objects Diff.configure yields" do
yielded = []
Capybara::Screenshot::Diff.configure { |screenshot, diff| yielded << [screenshot, diff] }
Expand Down
Loading