diff --git a/CHANGELOG.md b/CHANGELOG.md
index 531e8003..1c36dd68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,13 @@ kept as history. This entry is the one to read if you are coming from **1.15.1**
is offline CLIP via the `informers` gem; custom backends (local VLM,
decision APIs) register by name via `SnapDiff::AI.register`. Zero new hard
dependencies. See `docs/ai.md`.
+- **Report contributions registry.** Core reporters and the assertion failure
+ message no longer reference the AI module at all: `SnapDiff::Contributions`
+ is the single extension point (Minitest/SimpleCov style — modules
+ self-register, core renders plain `{source:, text:, data:}` payloads).
+ Loading `snap_diff/ai` opts into annotations; `fail_on:` claims the one
+ failure-suppression slot. Any module can contribute annotations the same
+ way. See `docs/ai.md`.
### Upgrading from 1.15.1: change the version, run your suite
diff --git a/docs/ai.md b/docs/ai.md
index a468877d..47f41e6e 100644
--- a/docs/ai.md
+++ b/docs/ai.md
@@ -18,20 +18,39 @@ One line per diff in the test output, and — automatically, via the
shared store — a verdict badge plus one-line summary per failure in
`snap_diff_report.html`. No files, no duplicate storage.
-## How the HTML report gets AI annotations
+## How reports get AI annotations
-Both reporters read the same shared store: `AISimple` writes each result
-into `SnapDiff::AI` (keyed by screenshot name), and the HTML reporter
-attaches `SnapDiff::AI[name]` to the failure entry — behind a `defined?`
-guard, so `html.rb` never requires the AI module and nothing changes
-when AI triage isn't loaded. No wiring between the two reporters:
+Core never names the AI module. It exposes one registry,
+`SnapDiff::Contributions`, and loading `snap_diff/ai` self-registers
+into it — the same shape as Minitest plugins appending to the
+`CompositeReporter`, or SimpleCov formatters receiving a plain payload:
+
+- `AISimple` writes each result into the shared `SnapDiff::AI` store
+ (keyed by screenshot name).
+- `SnapDiff::AI.annotate(name)` adapts a stored result to the generic
+ `{source:, text:, data:}` contribution shape.
+- The HTML reporter and the assertion failure message ask
+ `Contributions.annotations_for(name)` and render whatever comes back —
+ empty when AI was never loaded, so the no-AI report is byte-clean.
```ruby
require "snap_diff/reporters/html" # already auto-registers
-require "snap_diff/reporters/ai_simple"
+require "snap_diff/reporters/ai_simple" # self-registers into Contributions
SnapDiff::Reporting.register(SnapDiff::Reporters::AISimple.new)
```
+Your own module can contribute the same way — no edits to core:
+
+```ruby
+module TicketLinker
+ def self.annotate(name)
+ ticket = JIRA_FOR[name]
+ ticket && {source: "jira", text: ticket}
+ end
+end
+SnapDiff::Contributions.register(TicketLinker)
+```
+
The report then shows, per failure: an `AI: real bug` / `AI: flaky` badge
in the sidebar and top strip, plus `similarity · confidence · summary`
when the backend provides them. Under fork-parallel, results merge in the
@@ -47,11 +66,12 @@ parent before the report renders.
## Architecture
```
-lib/snap_diff/ai.rb # backend registry, verdict thresholds, shared result store
+lib/snap_diff/contributions.rb # core registry: annotations + the one failure gate
+lib/snap_diff/ai.rb # backend registry, verdict thresholds, shared store; self-registers
lib/snap_diff/ai/backends/clip.rb # built-in offline backend (informers)
-lib/snap_diff/reporters/ai_simple.rb # record/finalize/summary; writes the store
-lib/snap_diff/reporters/html.rb # annotates failures from the store (defined? guard)
-lib/snap_diff/screenshot_assertion.rb# validate: fail-gate + AI line in the failure message
+lib/snap_diff/reporters/ai_simple.rb # record/finalize/summary; suppression via Contributions
+lib/snap_diff/reporters/html.rb # renders Contributions.annotations_for — no AI reference
+lib/snap_diff/screenshot_assertion.rb# validate: Contributions gate + annotation lines
```
A backend is any object with `#call(name:, base:, current:, meta:) -> Hash`.
diff --git a/lib/snap_diff.rb b/lib/snap_diff.rb
index c0ce8493..386380fe 100644
--- a/lib/snap_diff.rb
+++ b/lib/snap_diff.rb
@@ -47,6 +47,7 @@ def self.assert_single_gem!(loaded_specs = Gem.loaded_specs)
require "capybara/dsl"
require "snap_diff/config"
require "snap_diff/comparison"
+require "snap_diff/contributions"
require "snap_diff/legacy_shims"
require "snap_diff/version"
# SnapDiff.session/.reset/.pending_screenshots_message are part of the
diff --git a/lib/snap_diff/ai.rb b/lib/snap_diff/ai.rb
index 4eb15698..e137b398 100644
--- a/lib/snap_diff/ai.rb
+++ b/lib/snap_diff/ai.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require "snap_diff/contributions"
+
# Optional AI triage. A backend is any object responding to
# #call(name:, base:, current:, meta:) -> Hash; register one by name or
# pass an instance. Builders run lazily, so optional gems load only when
@@ -7,8 +9,9 @@
# unless a reporter is configured with fail_on: (see AISimple).
#
# Results live in a process-wide store so ANY consumer can read them --
-# the AISimple reporter writes, the HTML reporter annotates from it, the
-# fail-gate consults it. Keyed by screenshot name; later writes win.
+# the AISimple reporter writes, and reports pick them up through the
+# SnapDiff::Contributions registry (no consumer names this module).
+# Keyed by screenshot name; later writes win.
module SnapDiff
module AI
# The only verdicts a backend may return; anything else maps to
@@ -20,16 +23,14 @@ module AI
@mutex = Mutex.new
class << self
- # Optional fail-gate, set by AISimple when configured with fail_on:.
- # ScreenshotAssertion#validate consults it on a pixel diff: verdicts
- # the gate accepts suppress the failure, the rest still fail.
- attr_accessor :gate
-
- def gated_result(name, difference) = gate&.gated_result(name, difference)
-
- # No gate -> everything fails, exactly as without AI.
- def fails?(verdict) = gate ? gate.fails?(verdict) : true
+ # Report contribution (SnapDiff::Contributions): whatever the store
+ # holds for this screenshot, rendered one line plus the raw payload.
+ def annotate(name)
+ result = self[name]
+ result && {source: "ai", text: format(result), data: result}
+ end
+ # Register a lazy backend factory under a symbolic name, replacing any prior factory.
def register(name, &build)
@mutex.synchronize { @backends[name.to_sym] = build }
end
@@ -113,3 +114,8 @@ def merge_state!(state)
end
require "snap_diff/ai/backends/clip"
+
+# Minitest-style self-registration: loading this file opts into
+# annotating reports. Core (HTML reporter, assertion message) only ever
+# talks to SnapDiff::Contributions and never references this module.
+SnapDiff::Contributions.register(SnapDiff::AI)
diff --git a/lib/snap_diff/contributions.rb b/lib/snap_diff/contributions.rb
new file mode 100644
index 00000000..cd590a3d
--- /dev/null
+++ b/lib/snap_diff/contributions.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+# Contribution points: how OPTIONAL modules (AI triage today, anything
+# else tomorrow) feed the reports and the failure decision without the
+# reporters or the assertion knowing those modules exist.
+#
+# The contract follows the two proven shapes in the ecosystem:
+# Minitest's CompositeReporter (plugins append themselves to a
+# core-owned list; core calls a uniform interface and never names a
+# plugin) and SimpleCov's formatter pipeline (consumers receive a plain
+# data payload, never a plugin's class). A contributor is any object
+# responding to #annotate(name) -> {source:, text:, data:} or nil;
+# reports render whatever comes back.
+#
+# Failure suppression is deliberately a SINGLE slot (it was
+# SnapDiff::AI.gate before): two gates with different accept-lists
+# would silently suppress each other's real bugs, so registration
+# replaces the previous gate rather than stacking.
+module SnapDiff
+ module Contributions
+ @providers = []
+ @suppression = nil
+ @mutex = Mutex.new
+
+ class << self
+ # Register a report contributor. The provider must respond to
+ # #annotate(name), returning {source:, text:, data: (optional)}
+ # or nil. Registering the same object twice is a no-op -- identity,
+ # not ==: two distinct providers that happen to compare equal
+ # (e.g. Structs with equal fields) must BOTH contribute.
+ def register(provider)
+ @mutex.synchronize { @providers << provider unless @providers.any? { |p| p.equal?(provider) } }
+ end
+
+ # All contributions for one screenshot, in registration order.
+ # Empty when nothing is registered -- the no-AI default.
+ def annotations_for(name)
+ @mutex.synchronize { @providers.dup }.filter_map { |provider| provider.annotate(name) }
+ end
+
+ # The one failure gate: #suppress(name, difference) ->
+ # {source:, text:} (failure waived) or nil (failure stands).
+ # nil clears the slot (test teardown, reconfiguration).
+ def register_suppression(provider)
+ @mutex.synchronize { @suppression = provider }
+ end
+
+ # Cheap probe so callers can skip building `difference` entirely
+ # when no gate is registered.
+ def any_suppressor?
+ @mutex.synchronize { !@suppression.nil? }
+ end
+
+ # Ask the current suppressor to evaluate a screenshot difference.
+ # Return its {source:, text:} waiver, or nil when no gate waives the failure.
+ def suppression_for(name, difference)
+ @mutex.synchronize { @suppression }&.suppress(name, difference)
+ end
+ end
+ end
+end
diff --git a/lib/snap_diff/reporters/ai_simple.rb b/lib/snap_diff/reporters/ai_simple.rb
index a7895305..25b810f6 100644
--- a/lib/snap_diff/reporters/ai_simple.rb
+++ b/lib/snap_diff/reporters/ai_simple.rb
@@ -33,9 +33,13 @@ def initialize(backend: nil, flaky: nil, intentional: nil, fail_on: nil)
# fresh regression.
@memo = {}
@memo_mutex = Mutex.new
- AI.gate = self if @fail_on
+ # The one failure-gate slot in SnapDiff::Contributions -- core
+ # consults it without knowing AI exists.
+ Contributions.register_suppression(self) if @fail_on
end
+ # Analyze differing assertions and store their results, reusing gate-time analysis.
+ # Do nothing when the backend is unavailable.
def record(assertions)
return unless @backend
@@ -47,15 +51,24 @@ def record(assertions)
end
end
- # Fail-gate entry point, called by ScreenshotAssertion#validate on a
- # pixel diff. Analysis runs there (before the error message is
- # built) and is memoized, so #record never re-analyzes.
- def gated_result(name, difference)
- return unless @fail_on && @backend
+ # Failure-gate contract (SnapDiff::Contributions), consulted by
+ # ScreenshotAssertion#validate on a pixel diff, before the error
+ # message is built. Analysis is memoized, so #record never
+ # re-analyzes. Returns {source:, text:} to waive the failure,
+ # nil to let it stand. "unknown" ALWAYS fails -- AI can downgrade
+ # a diff, never vouch for one it could not classify.
+ def suppress(name, difference)
+ return unless @backend
+
+ # nil analysis (the backend raised) -> the pixel failure stands.
+ result = analyze_once(name, difference)
+ return unless result
- analyze_once(name, difference)
+ {source: "ai", text: AI.format(result)} unless fails?(result[:verdict])
end
+ # Return whether a verdict must fail under the configured fail_on policy.
+ # Unknown verdicts always fail; requires a reporter configured with fail_on.
def fails?(verdict) = verdict == "unknown" || @fail_on.include?(verdict)
# Results are already in the shared store -- nothing to write out.
diff --git a/lib/snap_diff/reporters/html.rb b/lib/snap_diff/reporters/html.rb
index b0429e81..ca82a2ed 100644
--- a/lib/snap_diff/reporters/html.rb
+++ b/lib/snap_diff/reporters/html.rb
@@ -96,8 +96,9 @@ def summary
"[snap_diff] Report: #{output_path}" if @finalized
end
+ # Attach available contributions and return the rendered HTML report.
def render
- attach_ai_annotations
+ attach_annotations
ERB.new(File.read(self.class.template_path)).result(binding)
end
@@ -112,6 +113,7 @@ def self.default_output_path
private
+ # Build a report entry from a comparison, omitting unavailable images and metrics.
def failure_entry_for(name, compare)
difference = compare.difference
{
@@ -127,19 +129,18 @@ def failure_entry_for(name, compare)
}.compact
end
- # Advisory AI triage annotations, attached at RENDER time: HTML
- # records before the AI reporter (auto-registration runs first) and
- # fork-parallel merges land after record, so only the final render
- # can see every verdict. HTML never requires the AI module -- the
- # annotation appears iff the user opted into AI triage.
- def attach_ai_annotations
- return unless defined?(SnapDiff::AI)
-
+ # Contributed annotations (AI triage, ...), attached at RENDER
+ # time: HTML records before contributing reporters
+ # (auto-registration runs first) and fork-parallel merges land
+ # after record, so only the final render can see everything.
+ # HTML never names a contributor -- it renders whatever
+ # SnapDiff::Contributions returns, empty by default.
+ def attach_annotations
failures.each do |entry|
- # || would create a nil :ai key on misses; keep the entry clean.
- if (annotation = SnapDiff::AI[entry[:name]])
- entry[:ai] ||= annotation
- end
+ # ||= would create a nil :annotations key on misses; keep the
+ # entry clean.
+ annotations = SnapDiff::Contributions.annotations_for(entry[:name])
+ entry[:annotations] ||= annotations unless annotations.empty?
end
end
diff --git a/lib/snap_diff/reporters/templates/report.html.erb b/lib/snap_diff/reporters/templates/report.html.erb
index e4ad5993..86c5aa34 100644
--- a/lib/snap_diff/reporters/templates/report.html.erb
+++ b/lib/snap_diff/reporters/templates/report.html.erb
@@ -274,7 +274,15 @@
'
 + ')
' +
'';
btn.addEventListener('click', function() { selectItem(+this.dataset.idx); });
@@ -311,20 +319,32 @@
}
topBadge.className = 'diff-badge ' + (hasDiff ? 'diff-badge-fail' : 'diff-badge-pass');
- /* AI triage strip: shown only when an advisory verdict exists */
+ /* Contribution strip: the verdict layout when a contributor supplied
+ one (AI triage), source + text for text-only annotations */
var aiBar = $('ai-bar');
- if (item.ai && item.ai.verdict) {
+ var note = (item.annotations || []).find(function(a) { return a.data && a.data.verdict; });
+ if (note) {
+ var d = note.data;
var aiV = $('ai-verdict');
- aiV.textContent = 'AI: ' + item.ai.verdict.replace('_', ' ');
- aiV.className = 'ai-badge' + aiClass(item.ai.verdict);
+ aiV.textContent = note.source.toUpperCase() + ': ' + d.verdict.replace('_', ' ');
+ aiV.className = 'ai-badge' + aiClass(d.verdict);
var bits = [];
- if (item.ai.similarity != null) bits.push('similarity ' + item.ai.similarity);
- if (item.ai.confidence != null) bits.push('confidence ' + item.ai.confidence);
- if (item.ai.summary) bits.push(item.ai.summary);
+ if (d.similarity != null) bits.push('similarity ' + d.similarity);
+ if (d.confidence != null) bits.push('confidence ' + d.confidence);
+ if (d.summary) bits.push(d.summary);
$('ai-text').textContent = bits.join(' · ');
aiBar.className = 'visible';
} else {
- aiBar.className = '';
+ var plain = (item.annotations || [])[0];
+ if (plain) {
+ var plainV = $('ai-verdict');
+ plainV.textContent = plain.source.toUpperCase();
+ plainV.className = 'ai-badge';
+ $('ai-text').textContent = plain.text || '';
+ aiBar.className = 'visible';
+ } else {
+ aiBar.className = '';
+ }
}
/* Resolve image sources based on view + annotated toggle */
diff --git a/lib/snap_diff/screenshot_assertion.rb b/lib/snap_diff/screenshot_assertion.rb
index 1b8cc21c..32d0d304 100644
--- a/lib/snap_diff/screenshot_assertion.rb
+++ b/lib/snap_diff/screenshot_assertion.rb
@@ -60,22 +60,26 @@ def inspect
"#<#{self.class.name} #{name.inspect} #{state} new=#{compare.image_path} base=#{compare.base_image_path}>"
end
+ # Return an annotated failure message for an unsuppressed screenshot mismatch.
+ # Return nil for absent, matching, or suppressed comparisons; archive matching baselines.
def validate
return unless compare
if compare.different?
- # Optional AI gate (SnapDiff::Reporters::AISimple with fail_on:):
- # the verdict is computed here, before the message is built, so
- # the failure text can quote it and accepted verdicts can skip it.
- ai = SnapDiff::AI.gated_result(name, compare.difference) if defined?(SnapDiff::AI) && SnapDiff::AI.gate
- if ai && !SnapDiff::AI.fails?(ai[:verdict])
- $stdout.puts "[snap_diff:ai] #{name}: failure suppressed -- #{SnapDiff::AI.format(ai)}"
+ # Optional contribution gate (e.g. AI triage with fail_on:): a
+ # registered suppressor runs BEFORE the message is built, so a
+ # waived diff never fails and a standing failure can quote what
+ # the contributors know. any_suppressor? keeps the no-gate path
+ # from even touching compare.difference.
+ if Contributions.any_suppressor? && (suppressed = Contributions.suppression_for(name, compare.difference))
+ $stdout.puts "[snap_diff:#{suppressed[:source]}] #{name}: failure suppressed -- #{suppressed[:text]}"
return nil
end
message = "Screenshot does not match for '#{name}': #{compare.error_message}\n#{caller.join("\n")}"
- ai ||= SnapDiff::AI[name] if defined?(SnapDiff::AI)
- message += "\n AI triage: #{SnapDiff::AI.format(ai)}" if ai
+ Contributions.annotations_for(name).each do |note|
+ message += "\n #{note[:source].upcase} triage: #{note[:text]}"
+ end
message
else
archive_baseline!
diff --git a/test/integration/ai_triage_test.rb b/test/integration/ai_triage_test.rb
index 6cd9d0b1..72c7e42f 100644
--- a/test/integration/ai_triage_test.rb
+++ b/test/integration/ai_triage_test.rb
@@ -19,17 +19,22 @@ class AiTriageTest < ActiveSupport::TestCase
# Force with RUN_AI_TESTS=1. Fails OPEN: when git can't tell (shallow
# checkout, no origin/master) or we're on master, the tests run.
AI_SURFACE = %r{\A(?:
- lib/snap_diff/(?:ai\.rb|reporters/ai_simple\.rb)
- | test/(?:unit/reporters/ai_simple_test\.rb|integration/ai_triage_test\.rb|fixtures/ai_triage_case\.rb)
+ lib/snap_diff/(?:ai\.rb|contributions\.rb|reporters/ai_simple\.rb)
+ | test/(?:unit/(?:reporters/ai_simple_test|contributions_test)\.rb|integration/ai_triage_test\.rb|fixtures/ai_triage_case\.rb)
)\z}x
+ # Decide whether to run AI integration tests from the files changed against origin/master.
+ # Run unconditionally when forced, on master, or when Git cannot determine the changes.
def self.ai_surface_changed?
return true if ENV["RUN_AI_TESTS"] == "1"
- branch, = Open3.capture2e("git", "rev-parse", "--abbrev-ref", "HEAD")
+ # capture2 (stdout only) + status: with capture2e a missing ref prints
+ # "fatal: ..." INTO the string and looks like a valid merge-base.
+ branch, = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
return true if branch.strip == "master"
- merge_base, = Open3.capture2e("git", "merge-base", "HEAD", "origin/master")
- return true if merge_base.strip.empty?
- changed, = Open3.capture2e("git", "diff", "--name-only", merge_base.strip, "HEAD")
+ merge_base, status = Open3.capture2("git", "merge-base", "HEAD", "origin/master")
+ return true unless status.success? && !merge_base.strip.empty?
+ changed, status = Open3.capture2("git", "diff", "--name-only", merge_base.strip, "HEAD")
+ return true unless status.success?
changed.split("\n").any? { |path| path.match?(AI_SURFACE) }
end
diff --git a/test/unit/contributions_test.rb b/test/unit/contributions_test.rb
new file mode 100644
index 00000000..fd35ba38
--- /dev/null
+++ b/test/unit/contributions_test.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ContributionsTest < Minitest::Test
+ Provider = Struct.new(:note) do
+ # Return the configured annotation for any screenshot name.
+ def annotate(name) = note
+ end
+
+ # Save the existing providers and clear registry annotations for an isolated test.
+ def setup
+ # Snapshot the shared registry and restore it afterwards -- other
+ # tests (and ai.rb's load-time self-registration) rely on providers
+ # registered before this file runs.
+ @saved_providers = SnapDiff::Contributions.instance_variable_get(:@providers).dup
+ SnapDiff::Contributions.instance_variable_get(:@providers).clear
+ SnapDiff::AI.clear_results! if defined?(SnapDiff::AI)
+ end
+
+ # Clear failure suppression and restore the providers saved before the test.
+ def teardown
+ SnapDiff::Contributions.register_suppression(nil)
+ SnapDiff::Contributions.instance_variable_set(:@providers, @saved_providers)
+ end
+
+ # Verify that an empty registry produces no annotations.
+ def test_annotations_empty_without_providers
+ assert_empty SnapDiff::Contributions.annotations_for("never-recorded-name")
+ end
+
+ # Verify that absent annotations are skipped without changing provider order.
+ def test_annotations_skip_nil_and_keep_order
+ first = Provider.new({source: "first", text: "one"})
+ nothing = Provider.new(nil)
+ second = Provider.new({source: "second", text: "two"})
+ [first, nothing, second].each { |p| SnapDiff::Contributions.register(p) }
+
+ assert_equal %w[first second], SnapDiff::Contributions.annotations_for("x").map { |a| a[:source] }
+ end
+
+ # Verify that registering one provider twice yields only one annotation.
+ def test_registering_the_same_provider_twice_is_a_no_op
+ provider = Provider.new({source: "ai", text: "t"})
+ 2.times { SnapDiff::Contributions.register(provider) }
+
+ assert_equal 1, SnapDiff::Contributions.annotations_for("x").size
+ end
+
+ # Verify that distinct providers contribute even when their values compare equal.
+ def test_distinct_providers_that_compare_equal_both_contribute
+ # Structs with equal fields are == but NOT the same provider.
+ 2.times { SnapDiff::Contributions.register(Provider.new({source: "ai", text: "t"})) }
+
+ assert_equal 2, SnapDiff::Contributions.annotations_for("x").size
+ end
+
+ # Verify that an unset suppression slot neither advertises a gate nor waives failures.
+ def test_no_suppressor_by_default
+ refute SnapDiff::Contributions.any_suppressor?
+ assert_nil SnapDiff::Contributions.suppression_for("x", Object.new)
+ end
+
+ # Verify that the latest suppressor replaces an earlier gate that would waive the failure.
+ def test_suppression_single_slot_replaces
+ waive = ->(_name, _diff) { {source: "ai", text: "FLAKY"} }
+ waiving = Object.new
+ waiving.define_singleton_method(:suppress) { |name, diff| waive.call(name, diff) }
+ standing = Object.new
+ standing.define_singleton_method(:suppress) { |_name, _diff| nil }
+
+ SnapDiff::Contributions.register_suppression(waiving)
+ SnapDiff::Contributions.register_suppression(standing)
+
+ # Last registration wins: the earlier gate must not keep waiving.
+ assert_nil SnapDiff::Contributions.suppression_for("x", Object.new)
+ end
+
+ # Verify that a registered suppressor returns its source and explanation unchanged.
+ def test_suppression_returns_the_waiver
+ waiving = Object.new
+ waiving.define_singleton_method(:suppress) { |_name, _diff| {source: "ai", text: "FLAKY (clip)"} }
+ SnapDiff::Contributions.register_suppression(waiving)
+
+ assert SnapDiff::Contributions.any_suppressor?
+ assert_equal({source: "ai", text: "FLAKY (clip)"}, SnapDiff::Contributions.suppression_for("x", Object.new))
+ end
+end
diff --git a/test/unit/reporters/ai_simple_test.rb b/test/unit/reporters/ai_simple_test.rb
index 21d0a050..2bee8693 100644
--- a/test/unit/reporters/ai_simple_test.rb
+++ b/test/unit/reporters/ai_simple_test.rb
@@ -25,14 +25,17 @@ def different? = difference.different?
def error_message = "diff details"
end
+ # Clear stored AI results before each reporter test.
def setup
SnapDiff::AI.clear_results!
end
+ # Clear the suppression gate installed by a reporter test.
def teardown
- SnapDiff::AI.gate = nil
+ SnapDiff::Contributions.register_suppression(nil)
end
+ # Build a screenshot assertion with a controllable comparison and a fixed caller trace.
def gate_assertion(name, different: true)
SnapDiff::ScreenshotAssertion.new(name).tap do |a|
a.compare = GateCompare.new(StubDifference.new(different: different))
@@ -177,6 +180,7 @@ def test_gate_still_fails_real_bugs_and_quotes_ai_in_the_message
assert_includes message, "AI triage: REAL_BUG (custom, similarity=0.5)"
end
+ # Verify that an unknown verdict preserves the screenshot failure and appears in its message.
def test_gate_always_fails_unknown_verdicts
SnapDiff::Reporters::AISimple.new(backend: similarity_backend(nil), fail_on: %w[real_bug])
@@ -185,6 +189,21 @@ def test_gate_always_fails_unknown_verdicts
assert_includes message, "AI triage: UNKNOWN"
end
+ # Verify that a backend exception logs a warning and leaves the screenshot mismatch standing.
+ def test_gate_lets_the_failure_stand_when_analysis_fails
+ # The backend raising must not turn validation itself into an error:
+ # the screenshot mismatch is the failure the developer needs.
+ exploding = ->(name:, base:, current:, meta:) { raise "model server unreachable" }
+ SnapDiff::Reporters::AISimple.new(backend: exploding, fail_on: %w[real_bug])
+
+ message = nil
+ _out, err = capture_io { message = gate_assertion("checkout").validate }
+
+ assert_includes message, "Screenshot does not match for 'checkout'"
+ assert_includes err, "Backend failed"
+ end
+
+ # Verify that validation and reporting share a single backend analysis for the same diff.
def test_gate_analysis_is_memoized_for_the_reporter_pass
calls = 0
counting_backend = ->(name:, base:, current:, meta:) {
@@ -293,10 +312,18 @@ def reporter = HtmlReporterStub.new
end
HtmlAssertion = Struct.new(:name, :compare)
+ # Clear AI results and save annotation providers before testing HTML rendering.
def setup
SnapDiff::AI.clear_results!
+ @saved_providers = SnapDiff::Contributions.instance_variable_get(:@providers).dup
end
+ # Restore annotation providers so custom contributors do not leak into later tests.
+ def teardown
+ SnapDiff::Contributions.instance_variable_set(:@providers, @saved_providers)
+ end
+
+ # Build an HTML reporter that writes report.html into the supplied directory.
def html_reporter(dir)
SnapDiff::Reporters::HTML.new(output_path: File.join(dir, "report.html"))
end
@@ -305,6 +332,7 @@ def failed_assertion(name)
HtmlAssertion.new(name, HtmlCompare.new(HtmlDifference.new(ratio: 0.02)))
end
+ # Verify that rendering attaches stored AI data and text only to the matching screenshot.
def test_failures_carry_ai_annotation_after_render
SnapDiff::AI.record_result(
name: "checkout", verdict: "real_bug", backend: "clip",
@@ -317,12 +345,15 @@ def test_failures_carry_ai_annotation_after_render
reporter.finalize
checkout, plain = reporter.failures
- assert_equal "real_bug", checkout[:ai][:verdict]
- assert_equal "CTA clipped", checkout[:ai][:summary]
- refute plain.key?(:ai)
+ annotation = checkout[:annotations].find { |a| a[:source] == "ai" }
+ assert_equal "real_bug", annotation[:data][:verdict]
+ assert_equal "CTA clipped", annotation[:data][:summary]
+ assert_equal "REAL_BUG (clip, similarity=0.7312) -- CTA clipped", annotation[:text]
+ refute plain.key?(:annotations)
end
end
+ # Verify that rendering includes AI results recorded after HTML collected the failure.
def test_ai_result_recorded_after_html_record_still_renders
# HTML auto-registers before AISimple, so its record runs first; the
# annotation must attach at render regardless of reporter order.
@@ -332,10 +363,11 @@ def test_ai_result_recorded_after_html_record_still_renders
SnapDiff::AI.record_result(name: "checkout", verdict: "flaky", backend: "clip", similarity: 0.9912)
reporter.finalize
- assert_equal "flaky", reporter.failures.first[:ai][:verdict]
+ assert_equal "flaky", reporter.failures.first[:annotations].first[:data][:verdict]
end
end
+ # Verify that the generated report contains the AI strip and stored verdict.
def test_rendered_report_includes_ai_bar_markup
SnapDiff::AI.record_result(name: "checkout", verdict: "flaky", backend: "clip", similarity: 0.9912)
@@ -350,17 +382,39 @@ def test_rendered_report_includes_ai_bar_markup
end
end
+ # Verify that an empty AI store adds no annotation keys to failures or serialized report data.
def test_rendered_report_without_ai_stays_clean
- # AI not enabled (store empty): entries must not gain an :ai key, and
- # the serialized DATA must contain no ai annotations at all.
+ # AI not enabled (store empty): entries must not gain an :annotations
+ # key, and the serialized DATA must contain no annotations at all.
Dir.mktmpdir do |dir|
reporter = html_reporter(dir)
reporter.record([failed_assertion("checkout"), failed_assertion("plain")])
reporter.finalize
- reporter.failures.each { |entry| refute entry.key?(:ai) }
+ reporter.failures.each { |entry| refute entry.key?(:annotations) }
+ html = File.read(File.join(dir, "report.html"))
+ refute_includes html, '"annotations":'
+ end
+ end
+
+ # Verify that a contribution without a verdict retains its source and text in the report.
+ def test_text_only_contribution_renders_its_text
+ # A contributor without a verdict payload (the documented
+ # TicketLinker shape) must still show its text, not a bare label.
+ linker = Object.new
+ linker.define_singleton_method(:annotate) { |name| {source: "jira", text: "PROJ-123"} }
+ SnapDiff::Contributions.instance_variable_get(:@providers) << linker
+
+ Dir.mktmpdir do |dir|
+ reporter = html_reporter(dir)
+ reporter.record([failed_assertion("checkout")])
+ reporter.finalize
+
html = File.read(File.join(dir, "report.html"))
- refute_includes html, '"ai":'
+ assert_includes html, '"source":"jira"'
+ assert_includes html, '"text":"PROJ-123"'
+ # and the sidebar badge JS renders the text, not just the source
+ assert_includes html, "note.text"
end
end
end