diff --git a/test/unit/capture/viewport_test.rb b/test/unit/capture/viewport_test.rb index 121ce251..e9a85111 100644 --- a/test/unit/capture/viewport_test.rb +++ b/test/unit/capture/viewport_test.rb @@ -21,6 +21,33 @@ class ViewportTest < ActiveSupport::TestCase Viewport.prepare!([800, 600]) end assert_includes error.message, "[800, 600]" + assert_includes error.message, "Actual: unknown" + end + end + end + + # The selenium arm of the ternary was never taken -- the test above + # stubs selenium? to false, and nothing else calls prepare! -- so the + # whole `session.driver.browser.manage.window.size` chain (the only + # reason the message is ever useful) was unexecuted. Under selenium the + # message must name the size the browser ACTUALLY has. + test "prepare! reports the real window size when the driver is selenium" do + window = Struct.new(:size).new("(width: 1024, height: 768)") + manage = Struct.new(:window).new(window) + browser = Struct.new(:manage).new(manage) + driver = Struct.new(:browser).new(browser) + session = Struct.new(:driver).new(driver) + + BrowserHelpers.stub(:window_size_is_wrong?, true) do + BrowserHelpers.stub(:selenium?, true) do + BrowserHelpers.stub(:session, session) do + error = assert_raises(SnapDiff::WindowSizeMismatchError) do + Viewport.prepare!([800, 600]) + end + + assert_includes error.message, "Expected: [800, 600]" + assert_includes error.message, "Actual: (width: 1024, height: 768)" + end end end end diff --git a/test/unit/deletion_3_0_test.rb b/test/unit/deletion_3_0_test.rb new file mode 100644 index 00000000..31cfdb8c --- /dev/null +++ b/test/unit/deletion_3_0_test.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" +require "tmpdir" +require "fileutils" +require "unit/support_load_probe_test" # single source of truth for the canonical entry-point tables + +# THE 3.0 DELETION, ACTUALLY RUN. +# +# legacy_tree_is_alias_only_test.rb and core_tree_has_no_legacy_deps_test.rb +# are STATIC proxies for one claim: `git rm` the v1 surface and the gem still +# loads. This test stops proxying. It copies lib/ to a tmpdir, performs the +# deletion, applies the edits the deletion needs, and requires every canonical +# entry point in a fresh subprocess. +# +# THE GATE LINE (see GATE_SCRIPT) is why this is evidence rather than +# decoration. An earlier lane's "green" run turned out to have measured the +# INTACT tree: BUNDLE_GEMFILE pointed at the gemspec, which unshifts the real +# lib/ onto $LOAD_PATH ahead of any -I. A run that cannot tell the deleted +# tree from the intact one proves nothing, so before asserting anything about +# the surface every probe HARD-ASSERTS that the deletion is in effect -- the +# deleted names are gone AND every snap_diff file that loaded came from the +# tmpdir. The subprocess is isolated from bundler as well (see #probe), but +# that isolation is precisely the thing that silently stopped working last +# time -- the gate line is what notices when it does. +class Deletion30Test < ActiveSupport::TestCase + PROJECT_ROOT = Pathname.new(File.expand_path("../..", __dir__)) + + # The 3.0 `git rm`, verbatim from the Rakefile's header comment (minus + # test/legacy, which this test does not load). + DELETED = %w[ + capybara + capybara_screenshot_diff + capybara-screenshot-diff.rb + capybara_screenshot_diff.rb + snap_diff/legacy_shims.rb + snap_diff/deprecation.rb + ].freeze + + # The edits the deletion needs, as [file, exact line to remove or replace, + # replacement or nil]. Exact-match on purpose: if one of these lines is + # reworded, the edit must go red here rather than silently not applying and + # leaving the probe to fail somewhere confusing. + EDITS = [ + # The one line in the canonical entry point that 3.0 drops. + ["snap_diff.rb", %(require "snap_diff/legacy_shims"), nil], + # The new gem name's Bundler entry point is KEPT, repointed off the v1 + # umbrella. It matches neither gate's file glob, so this is the only + # thing that checks its post-3.0 shape at all. + ["snap_diff-capybara.rb", + %(require "capybara_screenshot_diff/minitest"), + %(require "snap_diff/integrations/minitest")] + ].freeze + + ENTRY_POINTS = SupportLoadProbeTest::CANONICAL_ENTRY_POINTS + + # Runs FIRST in every probe, before a single surface assertion. Proves the + # process is looking at the deleted tree and nothing else. + GATE_SCRIPT = <<~'RUBY' + tree = ENV.fetch("DELETED_TREE") + gate = [] + + # Defined in legacy_shims.rb; its presence means the deletion did not take. + gate << "SnapDiff.start is still defined" if SnapDiff.respond_to?(:start) + gate << "CapybaraScreenshotDiff is still defined" if defined?(CapybaraScreenshotDiff) + + deleted = $LOADED_FEATURES.grep(%r{/snap_diff/(legacy_shims|deprecation)\.rb\z}) + gate << "deleted files loaded: #{deleted.join(", ")}" unless deleted.empty? + + v1 = $LOADED_FEATURES.grep(%r{/lib/capybara(-screenshot-diff|_screenshot_diff|/screenshot)}) + gate << "v1 tree loaded: #{v1.join(", ")}" unless v1.empty? + + # The BUNDLE_GEMFILE trap: files resolving from the INTACT lib/ while the + # tmpdir sits unused on the load path. + strays = $LOADED_FEATURES.grep(/snap_diff/).reject { |f| f.start_with?(tree) } + gate << "loaded from outside the deleted tree: #{strays.join(", ")}" unless strays.empty? + + unless gate.empty? + abort("GATE: this process is NOT running the deleted tree, so nothing below is evidence:\n- " + gate.join("\n- ")) + end + RUBY + + test "every canonical entry point loads and keeps its surface after the 3.0 deletion" do + in_deleted_tree do |tree| + failures = ENTRY_POINTS.filter_map do |entry, methods| + probe(tree, <<~RUBY) + require #{entry.inspect} + #{GATE_SCRIPT} + missing = #{methods.inspect}.reject { |m| SnapDiff.respond_to?(m) } + missing << "VERSION" unless defined?(SnapDiff::VERSION) + abort("missing: \#{missing.join(", ")}") unless missing.empty? + RUBY + end + + assert_empty failures, <<~MSG + `git rm` of the v1 surface breaks canonical entry point(s) -- 3.0 is a + refactor, not a deletion, until these load: + + #{failures.join("\n")} + MSG + end + end + + test "every canonical entry point defines its advertised constants after the 3.0 deletion" do + in_deleted_tree do |tree| + failures = SupportLoadProbeTest::CANONICAL_ADVERTISED_CONSTANTS.filter_map do |entry, constants| + probe(tree, <<~RUBY) + require #{entry.inspect} + #{GATE_SCRIPT} + missing = #{constants.inspect}.reject { |c| Object.const_defined?(c) } + abort("missing: \#{missing.join(", ")}") unless missing.empty? + RUBY + end + + assert_empty failures, <<~MSG + Entry point(s) lose their advertised constants once the v1 surface is deleted: + + #{failures.join("\n")} + MSG + end + end + + # The gate line has to be able to FAIL, or it is a comment with an `if` + # around it. Same probe, run against an UNTOUCHED copy of lib/: every + # surface assertion would pass there, so only the gate can reject it. + test "the gate line rejects an intact tree" do + Dir.mktmpdir("snapdiff_intact") do |dir| + tree = copy_lib_to(dir) + + failure = probe(tree, <<~RUBY) + require "snap_diff" + #{GATE_SCRIPT} + RUBY + + assert failure, "the gate line passed on an INTACT tree -- it cannot distinguish the deletion" + assert_includes failure, "SnapDiff.start is still defined" + end + end + + private + + # $LOADED_FEATURES holds resolved real paths, and Dir.mktmpdir hands back + # the symlinked /var form on macOS -- so the gate's "outside the tree" + # check compared /private/var/... against /var/... and rejected the very + # tree it had just built. Resolve once, here. + def copy_lib_to(dir) + FileUtils.cp_r(PROJECT_ROOT.join("lib").to_s, File.join(dir, "lib")) + File.realpath(File.join(dir, "lib")) + end + + # Yields the path to a lib/ with the 3.0 deletion applied. + def in_deleted_tree + Dir.mktmpdir("snapdiff_deleted") do |dir| + tree = copy_lib_to(dir) + + DELETED.each do |path| + target = File.join(tree, path) + assert File.exist?(target), "3.0 deletion set names #{path}, which does not exist" + FileUtils.rm_rf(target) + end + + EDITS.each do |file, line, replacement| + target = Pathname.new(File.join(tree, file)) + source = target.read + + assert_includes source, line, "3.0 edit for #{file} no longer matches the file" + target.write(source.sub(line + "\n", replacement ? replacement + "\n" : "")) + end + + yield tree + end + end + + # A fresh process with ONLY +tree+ on the load path. + # + # `chdir: tree` is the load-bearing half, and NOT a detail. Scrubbing + # RUBYOPT/BUNDLE_GEMFILE is not sufficient on its own: with the cwd still + # inside the project, RubyGems auto-discovers gems.rb, puts + # `-rbundler/setup` BACK into RUBYOPT, and the gemspec unshifts the real + # lib/ ahead of the -I dir -- measured, this exact scrub with cwd at the + # project root loads 24 files from the intact tree. Running from the + # tmpdir means there is no gems.rb to find. Both defenses are here because + # the gate line inside the script is the only one that says so out loud + # when they stop working. + def probe(tree, script) + preamble = <<~RUBY + $LOAD_PATH.unshift(#{tree.inspect}) + #{SupportLoadProbeTest::CUCUMBER_RUNTIME_STUB} + RUBY + env = {"DELETED_TREE" => tree, "RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil, "RUBYLIB" => nil} + out, status = Open3.capture2e(env, RbConfig.ruby, "-e", preamble + script, chdir: tree) + + "#{script.lines.first.strip} -> #{out}" unless status.success? + end +end diff --git a/test/unit/reporters/default_test.rb b/test/unit/reporters/default_test.rb index ccff23fd..825242e3 100644 --- a/test/unit/reporters/default_test.rb +++ b/test/unit/reporters/default_test.rb @@ -40,6 +40,33 @@ class DefaultReporterTest < ActiveSupport::TestCase assert_not reporter.heatmap_diff_path.exist?, "heatmap diff should be cleaned" end + # The test above calls clean_tmp_files directly, so #generate's OWN call to + # it on the equal path had no coverage: deleting that line left the suite + # green while every passing comparison leaked the previous run's diff + # artifacts, which the next run then reports as stale output. + test "#generate removes the previous run's diff artifacts when the images are equal" do + driver = SnapDiff::Drivers::VipsDriver.new + reporter = SnapDiff::Reporters::Default.new(driver.find_difference_region(build_comparison_for(driver, "a.png", "b.png"))) + reporter.generate # a.png vs b.png differ: writes the artifacts + + assert_predicate reporter.annotated_image_path, :exist? + assert_predicate reporter.heatmap_diff_path, :exist? + + # Equal images, but the SAME artifact paths as the comparison above, so + # what gets cleaned is exactly what got written. + equal_image = driver.from_file(TEST_IMAGES_DIR.join("a.png")) + equal_difference = driver.find_difference_region( + SnapDiff::Comparison::Images.new(equal_image, equal_image, {}, driver, @_tmpdir / "a.png", @_tmpdir / "b.png") + ) + assert_predicate equal_difference, :equal? + + assert_nil SnapDiff::Reporters::Default.new(equal_difference).generate + + assert_not reporter.annotated_image_path.exist?, "diff should be cleaned on the equal path" + assert_not reporter.annotated_base_image_path.exist?, "base diff should be cleaned on the equal path" + assert_not reporter.heatmap_diff_path.exist?, "heatmap diff should be cleaned on the equal path" + end + test "failure message reports metrics without leaking image objects" do driver = SnapDiff::Drivers::VipsDriver.new comparison = build_comparison_for(driver, "a.png", "b.png") diff --git a/test/unit/snap_diff_config_test.rb b/test/unit/snap_diff_config_test.rb index 0c236d0e..0f1c7be6 100644 --- a/test/unit/snap_diff_config_test.rb +++ b/test/unit/snap_diff_config_test.rb @@ -87,6 +87,69 @@ def config config.enabled = original_diff end + # The two path-segment flags were only ever exercised TOGETHER (both true + # in test/system_test_case.rb and the rspec fixtures, both nil everywhere + # else), so either `if` could be deleted or swapped for the other flag and + # the whole suite stayed green. All four combinations, with the segment + # sources stubbed so the assertion names the expected path literally + # rather than recomputing it. + SCREENSHOT_AREA_COMBINATIONS = [ + [nil, nil, "doc/screenshots"], + [true, nil, "doc/screenshots/fake_os"], + [nil, true, "doc/screenshots/fake_driver"], + [true, true, "doc/screenshots/fake_os/fake_driver"] + ].freeze + + test "screenshot_area appends the os and driver segments independently" do + original_os, original_driver, original_save_path = + config.add_os_path, config.add_driver_path, config.save_path + + SnapDiff::Os.stub(:name, "fake_os") do + Capybara.stub(:current_driver, :fake_driver) do + config.save_path = "doc/screenshots" + + SCREENSHOT_AREA_COMBINATIONS.each do |add_os_path, add_driver_path, expected| + config.add_os_path = add_os_path + config.add_driver_path = add_driver_path + + assert_equal expected, config.screenshot_area, + "screenshot_area with add_os_path=#{add_os_path.inspect}, " \ + "add_driver_path=#{add_driver_path.inspect}" + end + end + end + ensure + config.add_os_path = original_os + config.add_driver_path = original_driver + config.save_path = original_save_path + end + + # The vips tolerance floor in Config#default_options is the one literal in + # there that is not a stored setting, and deleting it left the full suite + # green (config.rb's then-arm had a hit count of 0): nothing ever asked for + # default_options with driver == :vips and no explicit tolerance. + test "default_options floors tolerance at 0.001 for vips and only for vips" do + original_driver, original_tolerance = config.driver, config.tolerance + + begin + config.tolerance = nil + + config.driver = :vips + assert_equal 0.001, config.default_options[:tolerance] + + config.driver = :chunky_png + assert_nil config.default_options[:tolerance] + + # An explicit tolerance still wins over the floor. + config.tolerance = 0.5 + config.driver = :vips + assert_equal 0.5, config.default_options[:tolerance] + ensure + config.driver = original_driver + config.tolerance = original_tolerance + end + end + test "writing root through config round-trips through the same Pathname coercion" do original = config.root diff --git a/test/unit/snap_diff_test.rb b/test/unit/snap_diff_test.rb index 0d7c958a..ff8a6277 100644 --- a/test/unit/snap_diff_test.rb +++ b/test/unit/snap_diff_test.rb @@ -18,6 +18,24 @@ class SnapDiffTest < ActiveSupport::TestCase assert_equal 0.02, result.driver_options[:tolerance] end + # The test above only pins that an EXPLICIT option round-trips, so + # dropping `config.default_options.merge` from .compare entirely left the + # full suite green -- callers who configure once and then call .compare + # with no options silently lost every configured default. + test ".compare with no options still carries the configured defaults" do + original = SnapDiff.config.tolerance + + begin + SnapDiff.config.tolerance = 0.0123 + + result = SnapDiff.compare(TEST_IMAGES_DIR / "a.png", TEST_IMAGES_DIR / "b.png") + + assert_equal 0.0123, result.driver_options[:tolerance] + ensure + SnapDiff.config.tolerance = original + end + end + # Regression test for a load-order bug: `require "snap_diff"` standalone # (nothing else preloaded) used to raise # `NameError: uninitialized constant ...ImageCompare::Drivers` because