snap_diff-capybara 0.0.1 → 2.0.0.beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +190 -0
- data/CODE_OF_CONDUCT.md +129 -0
- data/LICENSE.txt +21 -0
- data/Rakefile +65 -0
- data/capybara-screenshot-diff.gemspec +29 -0
- data/docs/RELEASE_PREP.md +44 -0
- data/docs/UPGRADING.md +702 -0
- data/docs/architecture.md +289 -0
- data/docs/ci-integration.md +238 -0
- data/docs/configuration.md +395 -0
- data/docs/docker-testing.md +24 -0
- data/docs/drivers.md +102 -0
- data/docs/framework-setup.md +87 -0
- data/docs/images/snap_diff_annotated.png +0 -0
- data/docs/images/snap_diff_web_ui.png +0 -0
- data/docs/migration-guide.md +286 -0
- data/docs/organization.md +204 -0
- data/docs/reporters.md +46 -0
- data/docs/thread_safety.md +112 -0
- data/gems.rb +39 -0
- data/lib/capybara/screenshot/diff/annotation_service.rb +7 -0
- data/lib/capybara/screenshot/diff/area_calculator.rb +7 -0
- data/lib/capybara/screenshot/diff/browser_helpers.rb +7 -0
- data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
- data/lib/capybara/screenshot/diff/cucumber.rb +3 -0
- data/lib/capybara/screenshot/diff/difference.rb +8 -0
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +9 -0
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +7 -0
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +7 -0
- data/lib/capybara/screenshot/diff/drivers.rb +9 -0
- data/lib/capybara/screenshot/diff/image_compare.rb +12 -0
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +7 -0
- data/lib/capybara/screenshot/diff/os.rb +14 -0
- data/lib/capybara/screenshot/diff/region.rb +107 -0
- data/lib/capybara/screenshot/diff/reporters/default.rb +109 -0
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +7 -0
- data/lib/capybara/screenshot/diff/screenshoter.rb +7 -0
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -0
- data/lib/capybara/screenshot/diff/utils.rb +7 -0
- data/lib/capybara/screenshot/diff/vcs.rb +7 -0
- data/lib/capybara/screenshot/diff/version.rb +15 -0
- data/lib/capybara/screenshot/diff.rb +3 -0
- data/lib/capybara-screenshot-diff.rb +3 -0
- data/lib/capybara_screenshot_diff/attempts_reporter.rb +7 -0
- data/lib/capybara_screenshot_diff/cucumber.rb +8 -0
- data/lib/capybara_screenshot_diff/dsl.rb +12 -0
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +8 -0
- data/lib/capybara_screenshot_diff/minitest.rb +14 -0
- data/lib/capybara_screenshot_diff/reporters/html.rb +7 -0
- data/lib/capybara_screenshot_diff/rspec.rb +8 -0
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +71 -0
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +7 -0
- data/lib/capybara_screenshot_diff/snap.rb +7 -0
- data/lib/capybara_screenshot_diff/snap_manager.rb +7 -0
- data/lib/capybara_screenshot_diff/static.rb +9 -0
- data/lib/capybara_screenshot_diff.rb +50 -0
- data/lib/snap_diff/annotation_service.rb +84 -0
- data/lib/snap_diff/area_calculator.rb +56 -0
- data/lib/snap_diff/attempts_reporter.rb +51 -0
- data/lib/snap_diff/browser_helpers.rb +121 -0
- data/lib/snap_diff/capture/viewport.rb +40 -0
- data/lib/snap_diff/comparison.rb +238 -0
- data/lib/snap_diff/comparison_result.rb +104 -0
- data/lib/snap_diff/config.rb +78 -0
- data/lib/snap_diff/deprecation.rb +84 -0
- data/lib/snap_diff/driver.rb +37 -0
- data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
- data/lib/snap_diff/drivers/vips_driver.rb +171 -0
- data/lib/snap_diff/drivers.rb +14 -0
- data/lib/snap_diff/dsl.rb +143 -0
- data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
- data/lib/snap_diff/image_preprocessor.rb +68 -0
- data/lib/snap_diff/integrations/cucumber.rb +22 -0
- data/lib/snap_diff/integrations/minitest.rb +70 -0
- data/lib/snap_diff/integrations/rspec.rb +68 -0
- data/lib/snap_diff/legacy_shims.rb +99 -0
- data/lib/snap_diff/os.rb +17 -0
- data/lib/snap_diff/reporters/html.rb +143 -0
- data/lib/snap_diff/reporters/templates/report.html.erb +463 -0
- data/lib/snap_diff/reporting.rb +53 -0
- data/lib/snap_diff/screenshot_assertion.rb +143 -0
- data/lib/snap_diff/screenshot_matcher.rb +113 -0
- data/lib/snap_diff/screenshot_namer.rb +81 -0
- data/lib/snap_diff/screenshoter.rb +129 -0
- data/lib/snap_diff/snap.rb +66 -0
- data/lib/snap_diff/snap_manager.rb +125 -0
- data/lib/snap_diff/stable_screenshoter.rb +103 -0
- data/lib/snap_diff/static.rb +11 -0
- data/lib/snap_diff/utils.rb +38 -0
- data/lib/snap_diff/vcs.rb +35 -0
- data/lib/snap_diff/version.rb +5 -0
- data/lib/snap_diff.rb +65 -0
- metadata +160 -10
- data/README.md +0 -3
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8"/>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
6
|
+
<title>SnapDiff Report — <%= failed %> failures</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0f111a;
|
|
11
|
+
--bg2: #181a27;
|
|
12
|
+
--bg3: #1e2035;
|
|
13
|
+
--bg4: #282a40;
|
|
14
|
+
--border: #2d2f45;
|
|
15
|
+
--text: #e8eaed;
|
|
16
|
+
--text2: #9aa0b4;
|
|
17
|
+
--text3: #8b90a8;
|
|
18
|
+
--accent: #7c6cf0;
|
|
19
|
+
--accent-dim: #5b4dc7;
|
|
20
|
+
--accent-bg: rgba(124,108,240,.12);
|
|
21
|
+
--red: #f87171;
|
|
22
|
+
--red-dim: rgba(248,113,113,.15);
|
|
23
|
+
--green: #4ade80;
|
|
24
|
+
--green-dim: rgba(74,222,128,.15);
|
|
25
|
+
--orange: #fb923c;
|
|
26
|
+
--surface: #13151f;
|
|
27
|
+
--radius: 8px;
|
|
28
|
+
--radius-sm: 5px;
|
|
29
|
+
--sidebar-w: 260px;
|
|
30
|
+
--bar-h: 44px;
|
|
31
|
+
--zoom-h: 36px;
|
|
32
|
+
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
33
|
+
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
|
34
|
+
}
|
|
35
|
+
body { font-family: var(--font); background: var(--bg); color: var(--text); height: 100dvh; display: flex; flex-direction: column; overflow: hidden; -webkit-font-smoothing: antialiased; }
|
|
36
|
+
|
|
37
|
+
/* ── Top bar ─────────────────────────────────────────────────────── */
|
|
38
|
+
#topbar { height: var(--bar-h); background: var(--bg2); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 .75rem; flex-shrink: 0; gap: .5rem; z-index: 20; }
|
|
39
|
+
.logo { display: flex; align-items: center; gap: .4rem; font-size: .75rem; font-weight: 700; color: var(--text); white-space: nowrap; }
|
|
40
|
+
.logo svg { flex-shrink: 0; }
|
|
41
|
+
.sep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
|
|
42
|
+
.nav-group { display: flex; align-items: center; gap: .2rem; }
|
|
43
|
+
.nav-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text2); cursor: pointer; transition: all .12s; }
|
|
44
|
+
.nav-btn:hover { background: var(--bg4); color: var(--text); }
|
|
45
|
+
.nav-counter { font-size: .6875rem; font-family: var(--font-mono); color: var(--text2); padding: 0 .25rem; }
|
|
46
|
+
#topbar-title { font-size: .75rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
47
|
+
.diff-badge { font-size: .625rem; font-family: var(--font-mono); font-weight: 600; padding: .1rem .375rem; border-radius: 10px; white-space: nowrap; flex-shrink: 0; }
|
|
48
|
+
.diff-badge-fail { background: var(--red-dim); color: var(--red); }
|
|
49
|
+
.diff-badge-pass { background: var(--green-dim); color: var(--green); }
|
|
50
|
+
.spacer { flex: 1; }
|
|
51
|
+
.stats { display: flex; gap: .3rem; align-items: center; flex-shrink: 0; }
|
|
52
|
+
.stat-pill { font-size: .625rem; font-family: var(--font-mono); font-weight: 500; padding: .125rem .4rem; border-radius: 10px; display: flex; align-items: center; gap: .2rem; }
|
|
53
|
+
.stat-pill .dot { width: 5px; height: 5px; border-radius: 50%; }
|
|
54
|
+
.stat-fail { background: var(--red-dim); color: var(--red); }
|
|
55
|
+
.stat-fail .dot { background: var(--red); }
|
|
56
|
+
.stat-pass { background: var(--green-dim); color: var(--green); }
|
|
57
|
+
.stat-pass .dot { background: var(--green); }
|
|
58
|
+
.stat-total { background: var(--bg4); color: var(--text2); }
|
|
59
|
+
.stat-total .dot { background: var(--text3); }
|
|
60
|
+
|
|
61
|
+
/* ── Layout ──────────────────────────────────────────────────────── */
|
|
62
|
+
#layout { display: flex; flex: 1; overflow: hidden; }
|
|
63
|
+
|
|
64
|
+
/* ── Sidebar ─────────────────────────────────────────────────────── */
|
|
65
|
+
#sidebar { width: var(--sidebar-w); flex-shrink: 0; background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
|
66
|
+
#search-wrap { padding: .4rem .5rem; position: relative; }
|
|
67
|
+
.search-icon { position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: var(--text3); pointer-events: none; }
|
|
68
|
+
#search { width: 100%; padding: .375rem .5rem .375rem 2.125rem; font-size: .6875rem; font-family: var(--font); background: var(--bg3); color: var(--text); border: 1px solid transparent; border-radius: var(--radius); outline: none; transition: border-color .15s; }
|
|
69
|
+
#search:focus { border-color: var(--accent); background: var(--bg); }
|
|
70
|
+
#search::placeholder { color: var(--text3); }
|
|
71
|
+
#sidebar-list { flex: 1; overflow-y: auto; padding: .375rem; display: flex; flex-direction: column; gap: .375rem; }
|
|
72
|
+
.thumb-btn { width: 100%; text-align: left; background: transparent; border: 2px solid transparent; border-radius: var(--radius); padding: .3rem; cursor: pointer; transition: border-color .1s, background .1s; }
|
|
73
|
+
.thumb-btn:hover { background: var(--bg3); border-color: var(--border); }
|
|
74
|
+
.thumb-btn.active { border-color: var(--accent); background: var(--accent-bg); }
|
|
75
|
+
.thumb-btn.active .thumb-name { color: var(--text); }
|
|
76
|
+
.thumb-img-wrap { aspect-ratio: 16/10; background: var(--surface); border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
|
|
77
|
+
.thumb-img-wrap img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
|
78
|
+
.thumb-footer { display: flex; align-items: center; justify-content: space-between; margin-top: .25rem; gap: .2rem; }
|
|
79
|
+
.thumb-name { font-size: .625rem; font-weight: 500; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
|
|
80
|
+
.thumb-badge { font-size: .5rem; font-family: var(--font-mono); font-weight: 600; padding: .0625rem .25rem; border-radius: 8px; flex-shrink: 0; }
|
|
81
|
+
.thumb-badge-fail { background: var(--red-dim); color: var(--red); }
|
|
82
|
+
.thumb-badge-pass { background: var(--green-dim); color: var(--green); }
|
|
83
|
+
|
|
84
|
+
/* ── Main ────────────────────────────────────────────────────────── */
|
|
85
|
+
#main { flex: 1; overflow: hidden; display: flex; flex-direction: column; background: var(--bg); }
|
|
86
|
+
|
|
87
|
+
/* ── Toolbar ─────────────────────────────────────────────────────── */
|
|
88
|
+
#toolbar { display: flex; align-items: center; padding: .375rem .75rem; gap: .5rem; background: var(--bg2); flex-shrink: 0; }
|
|
89
|
+
.toolbar-label { font-size: .625rem; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: var(--text3); white-space: nowrap; }
|
|
90
|
+
.btn-group { display: flex; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
91
|
+
.tbtn { padding: .3rem .5rem; font-size: .625rem; font-family: var(--font); font-weight: 500; background: transparent; color: var(--text3); border: none; border-right: 1px solid var(--border); cursor: pointer; transition: all .1s; white-space: nowrap; display: flex; align-items: center; gap: .25rem; }
|
|
92
|
+
.tbtn:last-child { border-right: none; }
|
|
93
|
+
.tbtn:hover { background: var(--bg3); color: var(--text2); }
|
|
94
|
+
.tbtn.active { background: var(--accent); color: #fff; }
|
|
95
|
+
.tbtn svg { width: 13px; height: 13px; opacity: .8; }
|
|
96
|
+
.tbtn.active svg { opacity: 1; }
|
|
97
|
+
/* Annotation toggle */
|
|
98
|
+
.toggle-btn { padding: .3rem .625rem; font-size: .625rem; font-family: var(--font); font-weight: 500; background: var(--bg); color: var(--text3); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: all .1s; display: flex; align-items: center; gap: .3rem; white-space: nowrap; }
|
|
99
|
+
.toggle-btn:hover { color: var(--text2); border-color: var(--text3); }
|
|
100
|
+
.toggle-btn.active { background: var(--red-dim); color: var(--red); border-color: var(--red); }
|
|
101
|
+
.toggle-pip { width: 7px; height: 7px; border-radius: 50%; background: var(--text3); transition: background .1s; }
|
|
102
|
+
.toggle-btn.active .toggle-pip { background: var(--red); }
|
|
103
|
+
|
|
104
|
+
/* ── View area ───────────────────────────────────────────────────── */
|
|
105
|
+
#view-area { flex: 1; overflow: hidden; padding: .5rem; }
|
|
106
|
+
#view-inner { width: 100%; height: 100%; }
|
|
107
|
+
|
|
108
|
+
/* Both (side-by-side) */
|
|
109
|
+
.view-both { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; width: 100%; height: 100%; }
|
|
110
|
+
.img-panel { display: flex; flex-direction: column; min-height: 0; }
|
|
111
|
+
.img-label { font-size: .5625rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text3); margin-bottom: .25rem; display: flex; align-items: center; gap: .25rem; }
|
|
112
|
+
.dot-base { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
|
|
113
|
+
.dot-new { width: 5px; height: 5px; border-radius: 50%; background: var(--orange); }
|
|
114
|
+
.dot-heat { width: 5px; height: 5px; border-radius: 50%; background: #f59e0b; }
|
|
115
|
+
/* img-box: scrollable container for per-image zoom+pan */
|
|
116
|
+
.img-box { background: var(--surface); border-radius: var(--radius); overflow: hidden; flex: 1; position: relative; }
|
|
117
|
+
.img-box.pannable { overflow: auto; cursor: move; }
|
|
118
|
+
.img-box.dragging { cursor: move; }
|
|
119
|
+
.img-box img { display: block; object-fit: contain; transform-origin: 0 0; }
|
|
120
|
+
/* At 100% zoom, image fills the box */
|
|
121
|
+
.img-box[data-zoom="100"] img { width: 100%; height: 100%; }
|
|
122
|
+
|
|
123
|
+
/* Single image */
|
|
124
|
+
.view-single { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
|
125
|
+
.view-single .img-box { flex: 1; }
|
|
126
|
+
|
|
127
|
+
/* ── Zoom bar ────────────────────────────────────────────────────── */
|
|
128
|
+
#zoom-bar { height: var(--zoom-h); background: var(--bg2); border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: center; gap: .5rem; flex-shrink: 0; padding: 0 .75rem; }
|
|
129
|
+
.zoom-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text2); cursor: pointer; transition: all .1s; }
|
|
130
|
+
.zoom-btn:hover { background: var(--bg4); color: var(--text); }
|
|
131
|
+
.zoom-label { font-size: .625rem; font-family: var(--font-mono); color: var(--text2); min-width: 3rem; text-align: center; }
|
|
132
|
+
.zoom-fit { font-size: .625rem; font-family: var(--font); font-weight: 500; background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text2); padding: .2rem .5rem; cursor: pointer; transition: all .1s; }
|
|
133
|
+
.zoom-fit:hover { background: var(--bg4); color: var(--text); }
|
|
134
|
+
|
|
135
|
+
/* Scrollbar */
|
|
136
|
+
::-webkit-scrollbar { width: 5px; height: 5px; }
|
|
137
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
138
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
139
|
+
|
|
140
|
+
@media (max-width: 768px) {
|
|
141
|
+
#layout { flex-direction: column; }
|
|
142
|
+
#sidebar { width: 100%; max-height: 110px; flex-direction: row; }
|
|
143
|
+
#search-wrap { display: none; }
|
|
144
|
+
#sidebar-list { flex-direction: row; overflow-x: auto; overflow-y: hidden; }
|
|
145
|
+
.thumb-btn { width: auto; min-width: 120px; flex-shrink: 0; }
|
|
146
|
+
.view-both { grid-template-columns: 1fr; }
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
149
|
+
</head>
|
|
150
|
+
<body>
|
|
151
|
+
|
|
152
|
+
<header id="topbar">
|
|
153
|
+
<div class="logo">
|
|
154
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M3 12h18"/></svg>
|
|
155
|
+
SnapDiff
|
|
156
|
+
</div>
|
|
157
|
+
<div class="sep"></div>
|
|
158
|
+
<div class="nav-group">
|
|
159
|
+
<button class="nav-btn" id="nav-prev" aria-label="Previous"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 18l-6-6 6-6"/></svg></button>
|
|
160
|
+
<span class="nav-counter" id="nav-counter">1/<%= failed %></span>
|
|
161
|
+
<button class="nav-btn" id="nav-next" aria-label="Next"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 18l6-6-6-6"/></svg></button>
|
|
162
|
+
</div>
|
|
163
|
+
<span id="topbar-title"></span>
|
|
164
|
+
<span id="topbar-badge" class="diff-badge diff-badge-fail"></span>
|
|
165
|
+
<div class="spacer"></div>
|
|
166
|
+
<div class="stats">
|
|
167
|
+
<span class="stat-pill stat-fail"><span class="dot"></span><%= failed %> failed</span>
|
|
168
|
+
<span class="stat-pill stat-pass"><span class="dot"></span><%= passed %> passed</span>
|
|
169
|
+
<span class="stat-pill stat-total"><span class="dot"></span><%= total %> total</span>
|
|
170
|
+
</div>
|
|
171
|
+
</header>
|
|
172
|
+
|
|
173
|
+
<div id="layout">
|
|
174
|
+
<aside id="sidebar">
|
|
175
|
+
<div id="search-wrap">
|
|
176
|
+
<svg class="search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
177
|
+
<input id="search" type="search" placeholder="Filter…" autocomplete="off"/>
|
|
178
|
+
</div>
|
|
179
|
+
<nav id="sidebar-list"></nav>
|
|
180
|
+
</aside>
|
|
181
|
+
|
|
182
|
+
<main id="main">
|
|
183
|
+
<div id="toolbar">
|
|
184
|
+
<span class="toolbar-label">View</span>
|
|
185
|
+
<div class="btn-group" id="view-btns">
|
|
186
|
+
<button class="tbtn active" data-view="both" title="Both (1)">
|
|
187
|
+
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1" y="1" width="5" height="12" rx="1"/><rect x="8" y="1" width="5" height="12" rx="1"/></svg>
|
|
188
|
+
</button>
|
|
189
|
+
<button class="tbtn" data-view="base" title="Base only (2)">
|
|
190
|
+
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2" width="10" height="10" rx="1"/><path d="M5 7h4" stroke-width="1.5"/></svg>
|
|
191
|
+
</button>
|
|
192
|
+
<button class="tbtn" data-view="new" title="New only (3)">
|
|
193
|
+
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2" width="10" height="10" rx="1"/><path d="M5 7h4M7 5v4" stroke-width="1.5"/></svg>
|
|
194
|
+
</button>
|
|
195
|
+
<button class="tbtn" data-view="heatmap" title="Heatmap (4)">
|
|
196
|
+
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1" y="1" width="12" height="12" rx="1"/><circle cx="7" cy="7" r="3" fill="currentColor" opacity=".3"/><circle cx="7" cy="7" r="1.5" fill="currentColor" opacity=".6"/></svg>
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="sep"></div>
|
|
200
|
+
<button class="toggle-btn" id="annotate-toggle" title="Show annotated versions (A)">
|
|
201
|
+
<span class="toggle-pip"></span> Annotated
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div id="view-area">
|
|
206
|
+
<div id="view-inner"></div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div id="zoom-bar">
|
|
210
|
+
<button class="zoom-btn" id="zoom-out" title="Zoom out (-)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M8 11h6"/></svg></button>
|
|
211
|
+
<span class="zoom-label" id="zoom-label">100%</span>
|
|
212
|
+
<button class="zoom-btn" id="zoom-in" title="Zoom in (+)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M11 8v6M8 11h6"/></svg></button>
|
|
213
|
+
<button class="zoom-fit" id="zoom-fit">Fit to width</button>
|
|
214
|
+
</div>
|
|
215
|
+
</main>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<script>
|
|
219
|
+
(function() {
|
|
220
|
+
'use strict';
|
|
221
|
+
var DATA = <%= failures.to_json.gsub("</", "<\\/") %>;
|
|
222
|
+
var state = { current: 0, view: 'both', annotated: false, zoom: 100, search: '' };
|
|
223
|
+
var $ = function(id) { return document.getElementById(id); };
|
|
224
|
+
|
|
225
|
+
var searchEl = $('search'), sidebarList = $('sidebar-list');
|
|
226
|
+
var topTitle = $('topbar-title'), topBadge = $('topbar-badge'), navCounter = $('nav-counter');
|
|
227
|
+
var viewInner = $('view-inner'), viewArea = $('view-area');
|
|
228
|
+
var viewBtns = document.querySelectorAll('#view-btns .tbtn');
|
|
229
|
+
var annotateBtn = $('annotate-toggle');
|
|
230
|
+
|
|
231
|
+
/* ── Filtered list ────────────────────────────────────────────────── */
|
|
232
|
+
var filtered = [];
|
|
233
|
+
function rebuildFiltered() {
|
|
234
|
+
var q = state.search.toLowerCase();
|
|
235
|
+
filtered = [];
|
|
236
|
+
DATA.forEach(function(item, i) { if (!q || item.name.toLowerCase().indexOf(q) !== -1) filtered.push(i); });
|
|
237
|
+
if (filtered.length && filtered.indexOf(state.current) === -1) state.current = filtered[0];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* ── Sidebar ──────────────────────────────────────────────────────── */
|
|
241
|
+
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
242
|
+
|
|
243
|
+
function buildSidebar() {
|
|
244
|
+
sidebarList.innerHTML = '';
|
|
245
|
+
filtered.forEach(function(idx) {
|
|
246
|
+
var item = DATA[idx];
|
|
247
|
+
var btn = document.createElement('button');
|
|
248
|
+
btn.className = 'thumb-btn' + (idx === state.current ? ' active' : '');
|
|
249
|
+
btn.title = item.name;
|
|
250
|
+
btn.dataset.idx = idx;
|
|
251
|
+
/* Thumbnail: annotated mode → heatmap; normal → new image (no annotation) */
|
|
252
|
+
var thumb = state.annotated ? (item.heatmap || item.new || item.original || '') : (item.new || item.original || '');
|
|
253
|
+
var hasDiff = item.diff || item.heatmap;
|
|
254
|
+
var badgeText = hasDiff
|
|
255
|
+
? (item.diff_level != null ? item.diff_level + '%' : 'changed')
|
|
256
|
+
: 'pass';
|
|
257
|
+
btn.innerHTML =
|
|
258
|
+
'<div class="thumb-img-wrap"><img src="' + esc(thumb) + '" alt="" loading="lazy"/></div>' +
|
|
259
|
+
'<div class="thumb-footer">' +
|
|
260
|
+
'<div class="thumb-name">' + esc(item.name) + '</div>' +
|
|
261
|
+
'<span class="thumb-badge ' + (hasDiff ? 'thumb-badge-fail' : 'thumb-badge-pass') + '">' + badgeText + '</span>' +
|
|
262
|
+
'</div>';
|
|
263
|
+
btn.addEventListener('click', function() { selectItem(+this.dataset.idx); });
|
|
264
|
+
sidebarList.appendChild(btn);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ── Selection ────────────────────────────────────────────────────── */
|
|
269
|
+
function selectItem(idx) {
|
|
270
|
+
if (!filtered.length) return;
|
|
271
|
+
if (filtered.indexOf(idx) === -1) idx = filtered[Math.max(0, Math.min(idx, filtered.length - 1))];
|
|
272
|
+
state.current = idx;
|
|
273
|
+
render();
|
|
274
|
+
buildSidebar();
|
|
275
|
+
var a = sidebarList.querySelector('.thumb-btn.active');
|
|
276
|
+
if (a) a.scrollIntoView({ block: 'nearest' });
|
|
277
|
+
}
|
|
278
|
+
function selectNext() { var p = filtered.indexOf(state.current); if (p < filtered.length - 1) selectItem(filtered[p + 1]); }
|
|
279
|
+
function selectPrev() { var p = filtered.indexOf(state.current); if (p > 0) selectItem(filtered[p - 1]); }
|
|
280
|
+
|
|
281
|
+
/* ── Render ───────────────────────────────────────────────────────── */
|
|
282
|
+
function render() {
|
|
283
|
+
var item = DATA[state.current];
|
|
284
|
+
if (!item) return;
|
|
285
|
+
|
|
286
|
+
topTitle.textContent = item.name;
|
|
287
|
+
var pos = filtered.indexOf(state.current);
|
|
288
|
+
navCounter.textContent = (pos + 1) + '/' + filtered.length;
|
|
289
|
+
var hasDiff = item.diff || item.heatmap;
|
|
290
|
+
if (hasDiff && item.diff_level != null) {
|
|
291
|
+
topBadge.textContent = item.diff_level + '% diff';
|
|
292
|
+
} else {
|
|
293
|
+
topBadge.textContent = hasDiff ? 'changed' : 'pass';
|
|
294
|
+
}
|
|
295
|
+
topBadge.className = 'diff-badge ' + (hasDiff ? 'diff-badge-fail' : 'diff-badge-pass');
|
|
296
|
+
|
|
297
|
+
/* Resolve image sources based on view + annotated toggle */
|
|
298
|
+
var ann = state.annotated;
|
|
299
|
+
var baseImg = ann ? (item.base_diff || item.original || '') : (item.original || '');
|
|
300
|
+
var newImg = ann ? (item.diff || item.new || '') : (item.new || '');
|
|
301
|
+
var heatImg = item.heatmap || '';
|
|
302
|
+
|
|
303
|
+
var baseLbl = ann ? 'Annotated Base' : 'Baseline';
|
|
304
|
+
var newLbl = ann ? 'Annotated New' : 'Current';
|
|
305
|
+
|
|
306
|
+
var html = '';
|
|
307
|
+
switch (state.view) {
|
|
308
|
+
case 'both':
|
|
309
|
+
html = '<div class="view-both">' +
|
|
310
|
+
'<div class="img-panel"><div class="img-label"><span class="dot-base"></span> ' + baseLbl + '</div><div class="img-box"><img src="' + esc(baseImg) + '" alt="Base"/></div></div>' +
|
|
311
|
+
'<div class="img-panel"><div class="img-label"><span class="dot-new"></span> ' + newLbl + '</div><div class="img-box"><img src="' + esc(newImg) + '" alt="New"/></div></div>' +
|
|
312
|
+
'</div>';
|
|
313
|
+
break;
|
|
314
|
+
case 'base':
|
|
315
|
+
html = '<div class="view-single"><div class="img-label"><span class="dot-base"></span> ' + baseLbl + '</div><div class="img-box"><img src="' + esc(baseImg) + '" alt="Base"/></div></div>';
|
|
316
|
+
break;
|
|
317
|
+
case 'new':
|
|
318
|
+
html = '<div class="view-single"><div class="img-label"><span class="dot-new"></span> ' + newLbl + '</div><div class="img-box"><img src="' + esc(newImg) + '" alt="New"/></div></div>';
|
|
319
|
+
break;
|
|
320
|
+
case 'heatmap':
|
|
321
|
+
html = '<div class="view-single"><div class="img-label"><span class="dot-heat"></span> Heatmap</div><div class="img-box"><img src="' + esc(heatImg) + '" alt="Heatmap"/></div></div>';
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
viewInner.innerHTML = html;
|
|
325
|
+
applyZoom();
|
|
326
|
+
initPan();
|
|
327
|
+
|
|
328
|
+
/* Update button states */
|
|
329
|
+
viewBtns.forEach(function(b) { b.classList.toggle('active', b.dataset.view === state.view); });
|
|
330
|
+
annotateBtn.classList.toggle('active', state.annotated);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* ── View switching ───────────────────────────────────────────────── */
|
|
334
|
+
viewBtns.forEach(function(btn) {
|
|
335
|
+
btn.addEventListener('click', function() { state.view = this.dataset.view; render(); buildSidebar(); });
|
|
336
|
+
});
|
|
337
|
+
function toggleAnnotated() { state.annotated = !state.annotated; render(); buildSidebar(); }
|
|
338
|
+
annotateBtn.addEventListener('click', toggleAnnotated);
|
|
339
|
+
|
|
340
|
+
/* Click on image toggles annotated mode (ignore drags) */
|
|
341
|
+
var clickStart = null;
|
|
342
|
+
viewArea.addEventListener('mousedown', function(e) { clickStart = { x: e.clientX, y: e.clientY }; });
|
|
343
|
+
viewArea.addEventListener('click', function(e) {
|
|
344
|
+
if (!clickStart) return;
|
|
345
|
+
var dx = Math.abs(e.clientX - clickStart.x), dy = Math.abs(e.clientY - clickStart.y);
|
|
346
|
+
clickStart = null;
|
|
347
|
+
if (dx > 3 || dy > 3) return; /* was a drag, not a click */
|
|
348
|
+
if (e.target.closest('.img-box')) toggleAnnotated();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/* ── Per-image zoom ────────────────────────────────────────────────── */
|
|
352
|
+
function applyZoom() {
|
|
353
|
+
var boxes = viewInner.querySelectorAll('.img-box');
|
|
354
|
+
var s = state.zoom / 100;
|
|
355
|
+
boxes.forEach(function(box) {
|
|
356
|
+
var img = box.querySelector('img');
|
|
357
|
+
if (!img) return;
|
|
358
|
+
if (state.zoom === 100) {
|
|
359
|
+
img.style.width = '100%';
|
|
360
|
+
img.style.height = '100%';
|
|
361
|
+
img.style.objectFit = 'contain';
|
|
362
|
+
box.classList.remove('pannable');
|
|
363
|
+
} else {
|
|
364
|
+
img.style.width = (s * 100) + '%';
|
|
365
|
+
img.style.height = 'auto';
|
|
366
|
+
img.style.objectFit = '';
|
|
367
|
+
box.classList.add('pannable');
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
$('zoom-label').textContent = state.zoom + '%';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* ── Synchronized drag-to-pan across all img-boxes ───────────────── */
|
|
374
|
+
var syncing = false;
|
|
375
|
+
var dragBox = null, dragStartX, dragStartY, dragScrollL, dragScrollT;
|
|
376
|
+
|
|
377
|
+
function syncScroll(source) {
|
|
378
|
+
if (syncing) return;
|
|
379
|
+
syncing = true;
|
|
380
|
+
viewInner.querySelectorAll('.img-box').forEach(function(box) {
|
|
381
|
+
if (box !== source) { box.scrollLeft = source.scrollLeft; box.scrollTop = source.scrollTop; }
|
|
382
|
+
});
|
|
383
|
+
syncing = false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* Document-level listeners registered once (avoid accumulation on re-render) */
|
|
387
|
+
document.addEventListener('mousemove', function(e) {
|
|
388
|
+
if (!dragBox) return;
|
|
389
|
+
dragBox.scrollLeft = dragScrollL - (e.clientX - dragStartX);
|
|
390
|
+
dragBox.scrollTop = dragScrollT - (e.clientY - dragStartY);
|
|
391
|
+
});
|
|
392
|
+
document.addEventListener('mouseup', function() {
|
|
393
|
+
if (dragBox) { dragBox.classList.remove('dragging'); dragBox = null; }
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
function initPan() {
|
|
397
|
+
viewInner.querySelectorAll('.img-box').forEach(function(box) {
|
|
398
|
+
box.addEventListener('scroll', function() { syncScroll(box); });
|
|
399
|
+
box.addEventListener('mousedown', function(e) {
|
|
400
|
+
if (state.zoom <= 100) return;
|
|
401
|
+
dragBox = box; box.classList.add('dragging');
|
|
402
|
+
dragStartX = e.clientX; dragStartY = e.clientY;
|
|
403
|
+
dragScrollL = box.scrollLeft; dragScrollT = box.scrollTop;
|
|
404
|
+
e.preventDefault();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function zoomTo(v) { state.zoom = Math.max(25, Math.min(500, v)); applyZoom(); }
|
|
410
|
+
|
|
411
|
+
$('zoom-in').addEventListener('click', function() { zoomTo(state.zoom + 50); });
|
|
412
|
+
$('zoom-out').addEventListener('click', function() { zoomTo(state.zoom - 50); });
|
|
413
|
+
$('zoom-fit').addEventListener('click', function() { zoomTo(100); });
|
|
414
|
+
|
|
415
|
+
/* Ctrl/Cmd/Alt + scroll wheel to zoom (smooth, small steps) */
|
|
416
|
+
viewArea.addEventListener('wheel', function(e) {
|
|
417
|
+
if (!(e.ctrlKey || e.metaKey || e.altKey)) return;
|
|
418
|
+
e.preventDefault();
|
|
419
|
+
/* Use 5% steps for smooth zoom; clamp deltaY to avoid trackpad acceleration */
|
|
420
|
+
var step = 3;
|
|
421
|
+
var direction = e.deltaY < 0 ? 1 : -1;
|
|
422
|
+
zoomTo(state.zoom + direction * step);
|
|
423
|
+
}, { passive: false });
|
|
424
|
+
|
|
425
|
+
/* ── Nav ───────────────────────────────────────────────────────────── */
|
|
426
|
+
$('nav-prev').addEventListener('click', selectPrev);
|
|
427
|
+
$('nav-next').addEventListener('click', selectNext);
|
|
428
|
+
|
|
429
|
+
/* ── Search ────────────────────────────────────────────────────────── */
|
|
430
|
+
searchEl.addEventListener('input', function() { state.search = this.value; rebuildFiltered(); buildSidebar(); if (filtered.length) render(); });
|
|
431
|
+
|
|
432
|
+
/* ── Keyboard ──────────────────────────────────────────────────────── */
|
|
433
|
+
document.addEventListener('keydown', function(e) {
|
|
434
|
+
if (e.target.tagName === 'INPUT') { if (e.key === 'Escape') searchEl.blur(); return; }
|
|
435
|
+
switch (e.key) {
|
|
436
|
+
case 'ArrowRight': case 'ArrowDown': e.preventDefault(); selectNext(); break;
|
|
437
|
+
case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); selectPrev(); break;
|
|
438
|
+
case '1': state.view = 'both'; render(); buildSidebar(); break;
|
|
439
|
+
case '2': state.view = 'base'; render(); buildSidebar(); break;
|
|
440
|
+
case '3': state.view = 'new'; render(); buildSidebar(); break;
|
|
441
|
+
case '4': state.view = 'heatmap'; render(); buildSidebar(); break;
|
|
442
|
+
case 'a': case 'A': state.annotated = !state.annotated; render(); buildSidebar(); break;
|
|
443
|
+
case '/': e.preventDefault(); searchEl.focus(); break;
|
|
444
|
+
case '+': case '=': zoomTo(state.zoom + 25); break;
|
|
445
|
+
case '-': zoomTo(state.zoom - 25); break;
|
|
446
|
+
case '0': zoomTo(100); break;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
/* ── Init ──────────────────────────────────────────────────────────── */
|
|
451
|
+
if (!DATA.length) {
|
|
452
|
+
sidebarList.innerHTML = '<div class="empty-state"><div class="empty-text">No failures</div></div>';
|
|
453
|
+
topTitle.textContent = 'All clear';
|
|
454
|
+
topBadge.style.display = 'none';
|
|
455
|
+
} else {
|
|
456
|
+
rebuildFiltered();
|
|
457
|
+
buildSidebar();
|
|
458
|
+
render();
|
|
459
|
+
}
|
|
460
|
+
}());
|
|
461
|
+
</script>
|
|
462
|
+
</body>
|
|
463
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SnapDiff
|
|
4
|
+
# Process-global reporter lifecycle: registration, per-test notification,
|
|
5
|
+
# end-of-suite finalization. One list of reporters for the whole process,
|
|
6
|
+
# guarded by one mutex.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately separate from the per-test session lifecycle (the
|
|
9
|
+
# thread-local AssertionRegistry reached through
|
|
10
|
+
# CapybaraScreenshotDiff.registry): reporters outlive any single test,
|
|
11
|
+
# the registry does not. CapybaraScreenshotDiff keeps its public
|
|
12
|
+
# reporters/reporters_mutex/finalize_reporters! methods as thin shims
|
|
13
|
+
# over this module.
|
|
14
|
+
module Reporting
|
|
15
|
+
@reporters = []
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_reader :reporters, :mutex
|
|
20
|
+
|
|
21
|
+
# Delivers a finished test's assertions to every registered reporter.
|
|
22
|
+
# Iterates over a snapshot so a reporter mutating the list mid-notify
|
|
23
|
+
# cannot affect the current round. A raising reporter is warned about
|
|
24
|
+
# and skipped; the rest are still notified.
|
|
25
|
+
def notify(assertions)
|
|
26
|
+
return if assertions.nil? || assertions.empty?
|
|
27
|
+
|
|
28
|
+
reporters_snapshot = @mutex.synchronize { @reporters.dup }
|
|
29
|
+
return if reporters_snapshot.empty?
|
|
30
|
+
|
|
31
|
+
reporters_snapshot.each do |reporter|
|
|
32
|
+
reporter.record(assertions)
|
|
33
|
+
rescue => e
|
|
34
|
+
warn "[capybara-screenshot-diff] Reporter failed: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# End-of-suite hook: finalizes each reporter and prints its summary.
|
|
39
|
+
# A raising reporter is warned about and skipped; the rest are still
|
|
40
|
+
# finalized.
|
|
41
|
+
def finalize!
|
|
42
|
+
@mutex.synchronize { @reporters.dup }.each do |reporter|
|
|
43
|
+
reporter.finalize
|
|
44
|
+
if (msg = reporter.summary)
|
|
45
|
+
$stdout.puts msg
|
|
46
|
+
end
|
|
47
|
+
rescue => e
|
|
48
|
+
warn "[snap_diff] Reporter #{reporter.class} failed (#{e.class}: #{e.message})"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "screenshot_namer"
|
|
5
|
+
|
|
6
|
+
module SnapDiff
|
|
7
|
+
class ScreenshotAssertion
|
|
8
|
+
attr_reader :name, :args
|
|
9
|
+
attr_accessor :compare, :caller
|
|
10
|
+
|
|
11
|
+
def initialize(name, **args)
|
|
12
|
+
@name = name
|
|
13
|
+
@args = args
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# One-line debugging summary. Never triggers the (expensive, file-touching)
|
|
17
|
+
# comparison itself: an unprocessed comparison shows as "pending".
|
|
18
|
+
def inspect
|
|
19
|
+
return "#<#{self.class.name} #{name.inspect} (no comparison)>" unless compare
|
|
20
|
+
|
|
21
|
+
state = if compare.processed?
|
|
22
|
+
compare.difference.different? ? "different" : "matches"
|
|
23
|
+
else
|
|
24
|
+
"pending"
|
|
25
|
+
end
|
|
26
|
+
"#<#{self.class.name} #{name.inspect} #{state} new=#{compare.image_path} base=#{compare.base_image_path}>"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate
|
|
30
|
+
return unless compare
|
|
31
|
+
|
|
32
|
+
if compare.different?
|
|
33
|
+
"Screenshot does not match for '#{name}': #{compare.error_message}\n#{caller.join("\n")}"
|
|
34
|
+
else
|
|
35
|
+
archive_baseline!
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Commits the baseline after a passing comparison: the base image is
|
|
41
|
+
# moved over the actual image, so the captured screenshot becomes the
|
|
42
|
+
# recorded baseline again. This is the file-mutating half of the verify
|
|
43
|
+
# flow, kept explicit and separate from the pure "are they different?"
|
|
44
|
+
# question. Idempotent: a second call is a no-op.
|
|
45
|
+
def archive_baseline!
|
|
46
|
+
return unless compare && !compare.different? && compare.base_image_path.exist?
|
|
47
|
+
|
|
48
|
+
FileUtils.mv(compare.base_image_path, compare.image_path, force: true)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate!
|
|
52
|
+
error_msg = validate
|
|
53
|
+
|
|
54
|
+
if error_msg
|
|
55
|
+
raise CapybaraScreenshotDiff::ExpectationNotMet.new(error_msg, caller)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Verifies that all scheduled screenshots do not show any unintended differences.
|
|
60
|
+
#
|
|
61
|
+
# @param screenshots [Array(Array(Array(String), String, Comparison))] The list of match screenshots jobs. Defaults to all screenshots taken during the test.
|
|
62
|
+
# @return [Array, nil] Returns an array of error messages if there are screenshot differences, otherwise nil.
|
|
63
|
+
# @note This method is typically called at the end of a test to assert all screenshots are as expected.
|
|
64
|
+
def self.verify_screenshots!(screenshots)
|
|
65
|
+
return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
|
|
66
|
+
|
|
67
|
+
test_screenshot_errors = screenshots.map do |assertion|
|
|
68
|
+
assertion.validate
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
test_screenshot_errors.compact!
|
|
72
|
+
|
|
73
|
+
test_screenshot_errors.empty? ? nil : test_screenshot_errors
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Asserts that an image has not changed compared to its baseline.
|
|
77
|
+
#
|
|
78
|
+
# @param backtrace [Array(String)] The caller context, used for error reporting.
|
|
79
|
+
# @param name [String] The name of the screenshot being verified.
|
|
80
|
+
# @param comparison [Object] The comparison object containing the result and details of the comparison.
|
|
81
|
+
# @return [String, nil] Returns an error message if the screenshot differs from the baseline, otherwise nil.
|
|
82
|
+
# @note Legacy entry point; delegates to the instance verify flow
|
|
83
|
+
# (pure question + explicit #archive_baseline! on pass).
|
|
84
|
+
def self.assert_image_not_changed(backtrace, name, comparison)
|
|
85
|
+
assertion = new(name)
|
|
86
|
+
assertion.caller = backtrace
|
|
87
|
+
assertion.compare = comparison
|
|
88
|
+
assertion.validate
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class AssertionRegistry
|
|
93
|
+
attr_reader :assertions, :screenshot_namer, :new_screenshots
|
|
94
|
+
|
|
95
|
+
def initialize
|
|
96
|
+
@assertions = []
|
|
97
|
+
@new_screenshots = []
|
|
98
|
+
@screenshot_namer = SnapDiff::ScreenshotNamer.new
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def add_assertion(assertion)
|
|
102
|
+
return unless assertion&.compare
|
|
103
|
+
|
|
104
|
+
@assertions.push(assertion)
|
|
105
|
+
|
|
106
|
+
assertion
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def assertions_present?
|
|
110
|
+
!@assertions.empty?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def record_new_screenshot(name)
|
|
114
|
+
@new_screenshots.push(name)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def new_screenshots_present?
|
|
118
|
+
!@new_screenshots.empty?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def verify(screenshots = CapybaraScreenshotDiff.assertions)
|
|
122
|
+
return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
|
|
123
|
+
|
|
124
|
+
failed_assertions = CapybaraScreenshotDiff.registry.failed_assertions
|
|
125
|
+
failed_screenshot = failed_assertions.first
|
|
126
|
+
result = ScreenshotAssertion.verify_screenshots!(screenshots)
|
|
127
|
+
|
|
128
|
+
if result
|
|
129
|
+
raise CapybaraScreenshotDiff::ExpectationNotMet.new(result.join("\n\n"), failed_screenshot.caller)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def failed_assertions
|
|
134
|
+
assertions.filter { |screenshot_assert| screenshot_assert.compare&.different? }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def reset
|
|
138
|
+
@assertions.clear
|
|
139
|
+
@new_screenshots.clear
|
|
140
|
+
@screenshot_namer = SnapDiff::ScreenshotNamer.new
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|