Skip to content

Commit cfa66e4

Browse files
committed
Make snap_diff reporter notifications safer
1 parent b4f043c commit cfa66e4

4 files changed

Lines changed: 105 additions & 9 deletions

File tree

lib/capybara_screenshot_diff/reporters/html.rb

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,44 @@ def initialize(output_path: "tmp/snap_diff/index.html", embed_images: false)
1818
@failures = []
1919
@total = 0
2020
@finalized = false
21+
@mutex = Mutex.new
2122
end
2223

2324
def record(assertions)
25+
return if @finalized
26+
27+
failures = []
28+
total = 0
29+
2430
assertions.each do |assertion|
2531
compare = assertion.compare
2632
next unless compare
2733

28-
@total += 1
34+
total += 1
2935
next unless compare.difference&.different?
3036

31-
@failures << failure_entry_for(assertion.name, compare)
37+
failures << failure_entry_for(assertion.name, compare)
3238
rescue => e
3339
warn "[snap_diff] Reporter skipped '#{assertion.name}': #{e.message}" if ENV["DEBUG"]
3440
end
41+
42+
@mutex.synchronize do
43+
return if @finalized
44+
@total += total
45+
@failures.concat(failures)
46+
end
3547
end
3648

3749
def finalize
38-
return if @finalized
50+
@mutex.synchronize do
51+
return if @finalized
3952

40-
@finalized = true
41-
return if failures.empty?
53+
@finalized = true
54+
return if failures.empty?
4255

43-
write_report
44-
output_path
56+
write_report
57+
output_path
58+
end
4559
end
4660

4761
def passed = total - failures.size

lib/capybara_screenshot_diff/screenshot_assertion.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,15 +133,22 @@ def reporters
133133
@reporters ||= []
134134
end
135135

136+
def reporters_mutex
137+
@reporters_mutex ||= Mutex.new
138+
end
139+
136140
def_delegator :registry, :screenshot_namer
137141
def_delegator :registry, :verify
138142

139143
private
140144

141145
def notify_reporters(assertions)
142-
return if reporters.empty? || assertions.nil? || assertions.empty?
146+
return if assertions.nil? || assertions.empty?
147+
148+
reporters_snapshot = reporters_mutex.synchronize { reporters.dup }
149+
return if reporters_snapshot.empty?
143150

144-
reporters.each do |reporter|
151+
reporters_snapshot.each do |reporter|
145152
reporter.record(assertions)
146153
rescue => e
147154
warn "[capybara-screenshot-diff] Reporter failed: #{e.message}"

test/unit/reporters/html_reporter_test.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,32 @@ class HTMLReporterTest < ActiveSupport::TestCase
9898
assert_includes html, "data:image/png;base64,"
9999
end
100100

101+
test "#record and #finalize synchronize internal state" do
102+
reporter = HTML.new(output_path: @output_path)
103+
104+
fake_mutex = Class.new do
105+
attr_reader :synchronize_calls
106+
107+
def initialize
108+
@synchronize_calls = 0
109+
end
110+
111+
def synchronize
112+
@synchronize_calls += 1
113+
yield
114+
end
115+
end.new
116+
117+
# Using private state and a fake mutex here is intentional.
118+
# This test guards a tricky synchronization detail; avoid refactors unless behavior changes.
119+
reporter.instance_variable_set(:@mutex, fake_mutex)
120+
121+
reporter.record([build_passing_assertion("sync")])
122+
reporter.finalize
123+
124+
assert_operator fake_mutex.synchronize_calls, :>=, 1
125+
end
126+
101127
private
102128

103129
def build_passing_assertion(name)

test/unit/reporters_mutex_test.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module CapybaraScreenshotDiff
6+
class ReportersMutexTest < ActiveSupport::TestCase
7+
setup do
8+
@original_reporters = CapybaraScreenshotDiff.reporters.dup
9+
CapybaraScreenshotDiff.reporters.clear
10+
end
11+
12+
teardown do
13+
CapybaraScreenshotDiff.reporters.clear
14+
CapybaraScreenshotDiff.reporters.concat(@original_reporters)
15+
CapybaraScreenshotDiff.instance_variable_set(:@reporters_mutex, nil)
16+
end
17+
18+
test "reporters notification uses mutex snapshot" do
19+
fake_mutex = Class.new do
20+
attr_reader :synchronize_calls
21+
22+
def initialize
23+
@synchronize_calls = 0
24+
end
25+
26+
def synchronize
27+
@synchronize_calls += 1
28+
yield
29+
end
30+
end.new
31+
32+
CapybaraScreenshotDiff.instance_variable_set(:@reporters_mutex, fake_mutex)
33+
34+
reporter = Class.new do
35+
def record(_assertions); end
36+
end.new
37+
38+
CapybaraScreenshotDiff.reporters << reporter
39+
40+
assertion = CapybaraScreenshotDiff::ScreenshotAssertion.new("sample")
41+
assertion.compare = Object.new
42+
CapybaraScreenshotDiff.add_assertion(assertion)
43+
44+
CapybaraScreenshotDiff.reset
45+
46+
assert_operator fake_mutex.synchronize_calls, :>=, 1
47+
end
48+
end
49+
end

0 commit comments

Comments
 (0)