specbook 0.1.0
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 +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/app/controllers/specbook/application_controller.rb +4 -0
- data/app/controllers/specbook/viewer_controller.rb +179 -0
- data/app/views/specbook/viewer/_app_js.html.erb +1335 -0
- data/app/views/specbook/viewer/_screenshots_sidebar.html.erb +24 -0
- data/app/views/specbook/viewer/_styles.html.erb +178 -0
- data/app/views/specbook/viewer/_top_bar.html.erb +13 -0
- data/app/views/specbook/viewer/_traces_sidebar.html.erb +15 -0
- data/app/views/specbook/viewer/_viewer_panel.html.erb +44 -0
- data/app/views/specbook/viewer/show.html.erb +27 -0
- data/config/routes.rb +6 -0
- data/lib/generators/specbook/install/USAGE +25 -0
- data/lib/generators/specbook/install/install_generator.rb +19 -0
- data/lib/generators/specbook/install/templates/README +22 -0
- data/lib/generators/specbook/install/templates/specbook.rb +45 -0
- data/lib/specbook/configuration.rb +50 -0
- data/lib/specbook/engine.rb +7 -0
- data/lib/specbook/recorders/playwright_trace.rb +91 -0
- data/lib/specbook/recorders/screenshot.rb +713 -0
- data/lib/specbook/rspec.rb +15 -0
- data/lib/specbook/version.rb +3 -0
- data/lib/specbook.rb +13 -0
- metadata +150 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Screenshot recorder for spec presentations.
|
|
4
|
+
# Enable with: RECORD_SPECS=1 bundle exec rspec spec/system/
|
|
5
|
+
#
|
|
6
|
+
# Captures screenshots after Capybara actions AND assertions,
|
|
7
|
+
# with element bounding boxes for overlay highlights in the player.
|
|
8
|
+
# Works with both Selenium and Playwright drivers.
|
|
9
|
+
|
|
10
|
+
if ENV["RECORD_SPECS"]
|
|
11
|
+
require "fileutils"
|
|
12
|
+
|
|
13
|
+
module Specbook
|
|
14
|
+
module Recorders
|
|
15
|
+
module Screenshot
|
|
16
|
+
SCREENSHOT_BASE = Specbook.config.screenshot_root
|
|
17
|
+
RUN_TIMESTAMP = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
18
|
+
SCREENSHOT_DIR = SCREENSHOT_BASE.join(RUN_TIMESTAMP)
|
|
19
|
+
MAX_RUNS = Specbook.config.max_runs
|
|
20
|
+
|
|
21
|
+
mattr_accessor :current_example_name, :current_steps, :manifest, :step_counter,
|
|
22
|
+
:pending_assertions, :current_page, :current_gherkin_idx
|
|
23
|
+
|
|
24
|
+
self.manifest = []
|
|
25
|
+
self.step_counter = 0
|
|
26
|
+
self.current_gherkin_idx = -1
|
|
27
|
+
|
|
28
|
+
def self.reset_for_example!(example, page)
|
|
29
|
+
self.current_example_name = example.full_description
|
|
30
|
+
self.current_steps = []
|
|
31
|
+
self.step_counter = 0
|
|
32
|
+
self.pending_assertions = []
|
|
33
|
+
self.current_page = page
|
|
34
|
+
self.current_gherkin_idx = -1
|
|
35
|
+
self.step_sources = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
mattr_accessor :step_sources
|
|
39
|
+
self.step_sources = {}
|
|
40
|
+
|
|
41
|
+
def self.advance_gherkin_step!
|
|
42
|
+
self.current_gherkin_idx += 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Record step definition source code, keyed by gherkin index
|
|
46
|
+
def self.record_step_source!(file, line, body)
|
|
47
|
+
step_sources[current_gherkin_idx] = { file: file, line: line, body: body }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.capture!(page, description, target_element: nil, action_type: "navigate")
|
|
51
|
+
return unless current_example_name
|
|
52
|
+
|
|
53
|
+
# Flush any pending assertions before a new action
|
|
54
|
+
flush_assertions!(page) if pending_assertions.present? && action_type != "assert"
|
|
55
|
+
|
|
56
|
+
self.step_counter += 1
|
|
57
|
+
filename = "step_#{manifest.size.to_s.rjust(4, '0')}_#{step_counter.to_s.rjust(3, '0')}.png"
|
|
58
|
+
filepath = SCREENSHOT_DIR.join(filename)
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
bbox = target_element ? get_bounding_box(page, target_element) : nil
|
|
62
|
+
bbox = nil if bbox.is_a?(Hash) && (bbox.empty? || bbox["x"].nil?)
|
|
63
|
+
page.save_screenshot(filepath)
|
|
64
|
+
step_data = {
|
|
65
|
+
file: filename,
|
|
66
|
+
description: description,
|
|
67
|
+
url: page.current_url,
|
|
68
|
+
action: action_type,
|
|
69
|
+
bbox: bbox
|
|
70
|
+
}.compact
|
|
71
|
+
step_data[:gi] = current_gherkin_idx if current_gherkin_idx >= 0
|
|
72
|
+
current_steps << step_data
|
|
73
|
+
rescue StandardError
|
|
74
|
+
# Skip if screenshot fails
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.record_assertion!(description, elements: [], bboxes: nil, negative: false)
|
|
79
|
+
return unless current_example_name
|
|
80
|
+
|
|
81
|
+
if bboxes.nil?
|
|
82
|
+
page = current_page
|
|
83
|
+
bboxes = elements.filter_map { |el| get_bounding_box(page, el) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
pending_assertions << {
|
|
87
|
+
description: description,
|
|
88
|
+
bboxes: bboxes || [],
|
|
89
|
+
negative: negative
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.flush_assertions!(page)
|
|
94
|
+
return unless current_example_name
|
|
95
|
+
return if pending_assertions.blank?
|
|
96
|
+
|
|
97
|
+
self.step_counter += 1
|
|
98
|
+
filename = "step_#{manifest.size.to_s.rjust(4, '0')}_#{step_counter.to_s.rjust(3, '0')}.png"
|
|
99
|
+
filepath = SCREENSHOT_DIR.join(filename)
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
page.save_screenshot(filepath)
|
|
103
|
+
|
|
104
|
+
descriptions = pending_assertions.map do |a|
|
|
105
|
+
prefix = a[:negative] ? "✗ NOT: " : "✓ "
|
|
106
|
+
"#{prefix}#{a[:description]}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
all_bboxes = pending_assertions.flat_map { |a| a[:bboxes] }
|
|
110
|
+
|
|
111
|
+
step_data = {
|
|
112
|
+
file: filename,
|
|
113
|
+
description: descriptions.join(" | "),
|
|
114
|
+
url: page.current_url,
|
|
115
|
+
action: "assert",
|
|
116
|
+
assertions: pending_assertions.map { |a|
|
|
117
|
+
{
|
|
118
|
+
text: (a[:negative] ? "NOT: " : "") + a[:description],
|
|
119
|
+
bboxes: a[:bboxes],
|
|
120
|
+
negative: a[:negative]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}.compact
|
|
124
|
+
step_data[:gi] = current_gherkin_idx if current_gherkin_idx >= 0
|
|
125
|
+
current_steps << step_data
|
|
126
|
+
rescue StandardError
|
|
127
|
+
# Skip
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
self.pending_assertions = []
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.get_bounding_box(page, element)
|
|
134
|
+
return nil unless element.respond_to?(:native)
|
|
135
|
+
|
|
136
|
+
page.evaluate_script(<<~JS, element)
|
|
137
|
+
(function(el) {
|
|
138
|
+
if (!el) return null;
|
|
139
|
+
if (!el.offsetParent && getComputedStyle(el).position !== 'fixed' && getComputedStyle(el).position !== 'sticky') return null;
|
|
140
|
+
var s = getComputedStyle(el);
|
|
141
|
+
if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') return null;
|
|
142
|
+
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
143
|
+
var rect = el.getBoundingClientRect();
|
|
144
|
+
var vw = document.documentElement.clientWidth || window.innerWidth;
|
|
145
|
+
var vh = document.documentElement.clientHeight || window.innerHeight;
|
|
146
|
+
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
147
|
+
if (rect.right <= 0 || rect.bottom <= 0 || rect.x >= vw || rect.y >= vh) return null;
|
|
148
|
+
var x = Math.max(0, rect.x), y = Math.max(0, rect.y);
|
|
149
|
+
var w = Math.min(vw, rect.right) - x, h = Math.min(vh, rect.bottom) - y;
|
|
150
|
+
if (w <= 2 || h <= 2) return null;
|
|
151
|
+
return { x: Math.round(x), y: Math.round(y), width: Math.round(w), height: Math.round(h) };
|
|
152
|
+
})(arguments[0])
|
|
153
|
+
JS
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.finalize_example!(example)
|
|
159
|
+
# Allow Turnip features through even without screenshots (service/model specs)
|
|
160
|
+
return if current_steps.blank? && !example.metadata[:turnip]
|
|
161
|
+
|
|
162
|
+
# Extract describe/context group hierarchy for sidebar grouping
|
|
163
|
+
groups = []
|
|
164
|
+
eg = example.example_group
|
|
165
|
+
while eg
|
|
166
|
+
groups.unshift(eg.description) if eg.description.present?
|
|
167
|
+
eg = eg.superclass
|
|
168
|
+
break if eg == ::RSpec::Core::ExampleGroup || !eg.respond_to?(:description)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# For Turnip features, use the scenario name (last group) instead of the step chain
|
|
172
|
+
desc = if example.metadata[:turnip] && groups.length >= 2
|
|
173
|
+
groups.last
|
|
174
|
+
else
|
|
175
|
+
example.description
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
entry = {
|
|
179
|
+
name: current_example_name,
|
|
180
|
+
description: desc,
|
|
181
|
+
groups: groups,
|
|
182
|
+
file: example.metadata[:file_path].sub("./", ""),
|
|
183
|
+
line: example.metadata[:line_number],
|
|
184
|
+
type: example.metadata[:adversarial] ? "adversarial" : "happy",
|
|
185
|
+
status: example.exception ? "failed" : "passed",
|
|
186
|
+
steps: current_steps.dup
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# For Turnip features, embed the Gherkin steps + step sources for the sidebar
|
|
190
|
+
if example.metadata[:turnip]
|
|
191
|
+
gherkin = extract_gherkin(example.metadata[:file_path], desc)
|
|
192
|
+
if gherkin && step_sources.present?
|
|
193
|
+
bg_len = (gherkin[:background] || []).length
|
|
194
|
+
(gherkin[:background] || []).each_with_index do |s, i|
|
|
195
|
+
s[:source] = step_sources[i] if step_sources[i]
|
|
196
|
+
end
|
|
197
|
+
(gherkin[:scenario] || []).each_with_index do |s, i|
|
|
198
|
+
s[:source] = step_sources[bg_len + i] if step_sources[bg_len + i]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
entry[:gherkin] = gherkin
|
|
202
|
+
end
|
|
203
|
+
self.step_sources = {}
|
|
204
|
+
|
|
205
|
+
manifest << entry
|
|
206
|
+
self.current_steps = []
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.extract_gherkin(file_path, scenario_name)
|
|
210
|
+
path = file_path.start_with?("./") ? file_path[2..] : file_path
|
|
211
|
+
full_path = Specbook.config.feature_root.join(path)
|
|
212
|
+
return nil unless File.exist?(full_path)
|
|
213
|
+
|
|
214
|
+
content = File.read(full_path)
|
|
215
|
+
result = { background: [], scenario: [] }
|
|
216
|
+
current = nil
|
|
217
|
+
|
|
218
|
+
content.each_line do |line|
|
|
219
|
+
stripped = line.rstrip
|
|
220
|
+
if stripped =~ /^\s*Background:/
|
|
221
|
+
current = :background
|
|
222
|
+
elsif stripped =~ /^\s*Scenario:\s*(.+)/
|
|
223
|
+
if $1.strip == scenario_name
|
|
224
|
+
current = :scenario
|
|
225
|
+
elsif current == :scenario
|
|
226
|
+
break # Hit next scenario, done
|
|
227
|
+
else
|
|
228
|
+
current = nil
|
|
229
|
+
end
|
|
230
|
+
elsif current && stripped =~ /^\s+(Given|And|When|Then)\s+(.+)/
|
|
231
|
+
result[current] << { keyword: $1, text: $2.strip }
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
result
|
|
235
|
+
rescue StandardError
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def self.write_manifest!
|
|
240
|
+
FileUtils.mkdir_p(SCREENSHOT_DIR)
|
|
241
|
+
File.write(
|
|
242
|
+
SCREENSHOT_DIR.join("manifest.json"),
|
|
243
|
+
JSON.pretty_generate(manifest)
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Patch Capybara actions to capture screenshots with element tracking
|
|
251
|
+
module Specbook
|
|
252
|
+
module Recorders
|
|
253
|
+
module CapybaraScreenshotPatch
|
|
254
|
+
def visit(path, **)
|
|
255
|
+
# Flush pending assertions BEFORE navigating (while old page is still visible)
|
|
256
|
+
Specbook::Recorders::Screenshot.flush_assertions!(page) if Specbook::Recorders::Screenshot.pending_assertions.present?
|
|
257
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(page, "Navigate to #{path}", action_type: "navigate") }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def click_on(locator = nil, **opts)
|
|
261
|
+
el = _find_target_element(locator, opts)
|
|
262
|
+
# Capture BEFORE click — shows what was clicked, not the post-click state
|
|
263
|
+
Specbook::Recorders::Screenshot.capture!(page, "Click '#{locator || opts[:text] || 'element'}'", target_element: el, action_type: "click")
|
|
264
|
+
super
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def click_link(locator = nil, **opts)
|
|
268
|
+
el = begin
|
|
269
|
+
if opts[:href]
|
|
270
|
+
find(:link, href: opts[:href], wait: 0)
|
|
271
|
+
else
|
|
272
|
+
find_link(locator || opts[:text], wait: 0)
|
|
273
|
+
end
|
|
274
|
+
rescue Capybara::ElementNotFound, Capybara::Ambiguous
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
Specbook::Recorders::Screenshot.capture!(page, "Click link '#{locator || opts[:text] || opts[:href]}'", target_element: el, action_type: "click")
|
|
278
|
+
super
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def click_button(locator = nil, **opts)
|
|
282
|
+
# Find element for bbox before clicking
|
|
283
|
+
el = begin
|
|
284
|
+
find_button(locator || opts[:text], wait: 0)
|
|
285
|
+
rescue Capybara::ElementNotFound, Capybara::Ambiguous
|
|
286
|
+
begin; find(:css, "button", text: locator || opts[:text], wait: 0); rescue; nil; end
|
|
287
|
+
end
|
|
288
|
+
Specbook::Recorders::Screenshot.capture!(page, "Click button '#{locator || opts[:text]}'", target_element: el, action_type: "click")
|
|
289
|
+
super
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def fill_in(locator = nil, with:, **opts)
|
|
293
|
+
el = begin; find_field(locator || opts[:id] || opts[:name]); rescue Capybara::ElementNotFound; nil; end
|
|
294
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(page, "Type '#{with.to_s.truncate(30)}' into '#{locator}'", target_element: el, action_type: "fill") }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def select(value = nil, from: nil, **opts)
|
|
298
|
+
el = begin; find_field(from); rescue Capybara::ElementNotFound; nil; end
|
|
299
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(page, "Select '#{value}' from '#{from}'", target_element: el, action_type: "select") }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def choose(locator = nil, **opts)
|
|
303
|
+
el = begin
|
|
304
|
+
find(:radio_button, locator)
|
|
305
|
+
rescue Capybara::ElementNotFound, Capybara::Ambiguous
|
|
306
|
+
begin; find(:css, "label", text: locator); rescue; nil; end
|
|
307
|
+
end
|
|
308
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(page, "Choose '#{locator}'", target_element: el, action_type: "check") }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def check(locator = nil, **opts)
|
|
312
|
+
el = begin; find_field(locator); rescue Capybara::ElementNotFound; nil; end
|
|
313
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(page, "Check '#{locator}'", target_element: el, action_type: "check") }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def uncheck(locator = nil, **opts)
|
|
317
|
+
el = begin; find_field(locator); rescue Capybara::ElementNotFound; nil; end
|
|
318
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(page, "Uncheck '#{locator}'", target_element: el, action_type: "check") }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def attach_file(locator = nil, path = nil, **opts)
|
|
322
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(page, "Attach file '#{locator}'", action_type: "fill") }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def accept_confirm(text = nil, &block)
|
|
326
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(page, "Accept confirm dialog", action_type: "confirm") }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def _find_target_element(locator, opts, type: nil)
|
|
332
|
+
text = locator || opts[:text]
|
|
333
|
+
case type
|
|
334
|
+
when :link
|
|
335
|
+
find_link(text) rescue find(:link, text: text) rescue nil
|
|
336
|
+
when :button
|
|
337
|
+
find_button(text) rescue find(:button, text: text) rescue find(:css, "button", text: text) rescue find(:css, "[type=submit]", text: text) rescue nil
|
|
338
|
+
else
|
|
339
|
+
find(locator) rescue find_link(locator) rescue find_button(locator) rescue find(:css, "button", text: locator) rescue nil
|
|
340
|
+
end
|
|
341
|
+
rescue Capybara::ElementNotFound, Capybara::Ambiguous
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Prepend onto Capybara::Session to intercept assertions.
|
|
347
|
+
# After each assertion passes, use Capybara's own finders to locate the matched
|
|
348
|
+
# element and get its bounding box. No independent JS DOM searches.
|
|
349
|
+
module CapybaraSessionAssertionPatch
|
|
350
|
+
def assert_text(*args, **opts)
|
|
351
|
+
super.tap do
|
|
352
|
+
if Specbook::Recorders::Screenshot.current_example_name
|
|
353
|
+
actual_text = args.find { |a| a.is_a?(String) || a.is_a?(Regexp) }
|
|
354
|
+
if actual_text
|
|
355
|
+
text_str = actual_text.to_s
|
|
356
|
+
# Capybara confirmed this text exists — find the element containing it
|
|
357
|
+
bboxes = capybara_text_bbox(text_str)
|
|
358
|
+
Specbook::Recorders::Screenshot.record_assertion!("Has text: '#{text_str.truncate(60)}'", bboxes: bboxes)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def assert_no_text(*args, **opts)
|
|
365
|
+
if Specbook::Recorders::Screenshot.current_example_name
|
|
366
|
+
actual_text = args.find { |a| a.is_a?(String) || a.is_a?(Regexp) }
|
|
367
|
+
# Passing NOT assertion = success, show green
|
|
368
|
+
Specbook::Recorders::Screenshot.record_assertion!("Confirmed no text: '#{actual_text.to_s.truncate(60)}'")
|
|
369
|
+
end
|
|
370
|
+
super
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def assert_selector(*args, **opts)
|
|
374
|
+
super.tap do
|
|
375
|
+
if Specbook::Recorders::Screenshot.current_example_name
|
|
376
|
+
desc = humanize_selector(args, opts)
|
|
377
|
+
bboxes = capybara_element_bbox(*args, **opts)
|
|
378
|
+
Specbook::Recorders::Screenshot.record_assertion!("Has #{desc}", bboxes: bboxes)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def assert_no_selector(*args, **opts)
|
|
384
|
+
if Specbook::Recorders::Screenshot.current_example_name
|
|
385
|
+
desc = humanize_selector(args, opts)
|
|
386
|
+
# Passing NOT assertion = success, show green
|
|
387
|
+
Specbook::Recorders::Screenshot.record_assertion!("Confirmed no #{desc}")
|
|
388
|
+
end
|
|
389
|
+
super
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
# Convert raw Capybara selector args to human-readable description
|
|
395
|
+
def humanize_selector(args, opts)
|
|
396
|
+
parts = []
|
|
397
|
+
args.each do |a|
|
|
398
|
+
s = a.to_s
|
|
399
|
+
# Clean up CSS selectors: "css span[title='Exempt']" → "'Exempt' element"
|
|
400
|
+
if s =~ /^css$/
|
|
401
|
+
next # skip the :css symbol, use the next arg
|
|
402
|
+
elsif s =~ /\[title=['"](.*?)['"]\]/
|
|
403
|
+
parts << "'#{$1}' element"
|
|
404
|
+
elsif s =~ /\[class\*=['"](.*?)['"]\]/
|
|
405
|
+
parts << "'#{$1}' styled element"
|
|
406
|
+
elsif s =~ /^\.bg-(\w+)/
|
|
407
|
+
# Tailwind background class — describe the color
|
|
408
|
+
color = $1.sub(/-\d+$/, '')
|
|
409
|
+
parts << "#{color} status indicator"
|
|
410
|
+
elsif s =~ /^[.#\[]/
|
|
411
|
+
# CSS selector — simplify
|
|
412
|
+
clean = s.gsub(/\[.*?\]/, '').gsub(/[.#]/, ' ').strip
|
|
413
|
+
parts << (clean.presence || "element")
|
|
414
|
+
else
|
|
415
|
+
parts << s
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
text_filter = opts[:text]
|
|
419
|
+
parts << "'#{text_filter.to_s.truncate(30)}'" if text_filter
|
|
420
|
+
parts.join(" ").presence || "element"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Get the page session — works whether self is Session or Node::Element
|
|
424
|
+
def _page_session
|
|
425
|
+
# self IS the page when prepended on Capybara::Session
|
|
426
|
+
# self.session when prepended on Capybara::Node::Base
|
|
427
|
+
self.is_a?(Capybara::Session) ? self : self.session
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Get bbox of the element Capybara matched for a selector assertion
|
|
431
|
+
def capybara_element_bbox(*args, **opts)
|
|
432
|
+
find_opts = opts.except(:wait, :count, :minimum, :maximum, :between)
|
|
433
|
+
el = find(*args, **find_opts, wait: 0)
|
|
434
|
+
bbox = Specbook::Recorders::Screenshot.get_bounding_box(_page_session, el)
|
|
435
|
+
if !bbox && !self.is_a?(Capybara::Session)
|
|
436
|
+
# Scoped element — try getting bbox of self (the parent) as fallback
|
|
437
|
+
bbox = Specbook::Recorders::Screenshot.get_bounding_box(_page_session, self)
|
|
438
|
+
end
|
|
439
|
+
bbox ? [bbox] : []
|
|
440
|
+
rescue Capybara::ElementNotFound, Capybara::Ambiguous => e
|
|
441
|
+
if e.is_a?(Capybara::Ambiguous)
|
|
442
|
+
el = first(*args, **find_opts, wait: 0)
|
|
443
|
+
bbox = el ? Specbook::Recorders::Screenshot.get_bounding_box(_page_session, el) : nil
|
|
444
|
+
bbox ? [bbox] : []
|
|
445
|
+
elsif !self.is_a?(Capybara::Session)
|
|
446
|
+
# Element not found within scope — highlight the scope element itself
|
|
447
|
+
bbox = Specbook::Recorders::Screenshot.get_bounding_box(_page_session, self)
|
|
448
|
+
bbox ? [bbox] : []
|
|
449
|
+
else
|
|
450
|
+
[]
|
|
451
|
+
end
|
|
452
|
+
rescue StandardError
|
|
453
|
+
[]
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Get bbox for a text assertion — find the tightest element via Capybara.
|
|
457
|
+
# Avoids div/section/main/body which are often large container elements.
|
|
458
|
+
# Tries leaf-level tags first, falls back to any element picking smallest bbox.
|
|
459
|
+
def capybara_text_bbox(text_str)
|
|
460
|
+
page_session = _page_session
|
|
461
|
+
escaped = Regexp.escape(text_str)
|
|
462
|
+
|
|
463
|
+
# Strategy 1: leaf-level tags only (no div/section/article/main)
|
|
464
|
+
leaf_tags = "p, span, td, th, li, h1, h2, h3, h4, h5, h6, a, label, button, strong, em, small, b, i, mark, code, badge"
|
|
465
|
+
el = page_session.first(:css, leaf_tags, text: /#{escaped}/i, wait: 0)
|
|
466
|
+
if el
|
|
467
|
+
bbox = Specbook::Recorders::Screenshot.get_bounding_box(page_session, el)
|
|
468
|
+
return [bbox] if bbox.is_a?(Hash) && bbox["x"]
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Strategy 2: any element, but pick the smallest bbox (tightest fit)
|
|
472
|
+
elements = page_session.all(:css, "*", text: /#{escaped}/i, wait: 0)
|
|
473
|
+
best_bbox = nil
|
|
474
|
+
best_area = Float::INFINITY
|
|
475
|
+
elements.each do |candidate|
|
|
476
|
+
bbox = Specbook::Recorders::Screenshot.get_bounding_box(page_session, candidate)
|
|
477
|
+
next unless bbox.is_a?(Hash) && bbox["width"].to_i > 0 && bbox["height"].to_i > 0
|
|
478
|
+
area = bbox["width"] * bbox["height"]
|
|
479
|
+
if area < best_area
|
|
480
|
+
best_area = area
|
|
481
|
+
best_bbox = bbox
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
return [best_bbox] if best_bbox
|
|
485
|
+
|
|
486
|
+
[]
|
|
487
|
+
rescue StandardError => e
|
|
488
|
+
Rails.logger.debug { "[Specbook::Recorders::Screenshot] capybara_text_bbox error: #{e.message}" } if defined?(Rails)
|
|
489
|
+
[]
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Session-level action patches — captures bbox for click/fill/select/choose
|
|
494
|
+
# These prepend on Session so they work from both test class AND step definitions
|
|
495
|
+
module CapybaraSessionActionPatch
|
|
496
|
+
def click_button(locator = nil, **opts)
|
|
497
|
+
el = begin; find_button(locator || opts[:text]); rescue; begin; find(:css, "button", text: locator || opts[:text]); rescue; nil; end; end
|
|
498
|
+
Specbook::Recorders::Screenshot.capture!(self, "Click button '#{locator || opts[:text]}'", target_element: el, action_type: "click")
|
|
499
|
+
super
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def click_link(locator = nil, **opts)
|
|
503
|
+
el = begin
|
|
504
|
+
opts[:href] ? find(:link, href: opts[:href]) : find_link(locator || opts[:text])
|
|
505
|
+
rescue; nil; end
|
|
506
|
+
Specbook::Recorders::Screenshot.capture!(self, "Click link '#{locator || opts[:text] || opts[:href]}'", target_element: el, action_type: "click")
|
|
507
|
+
super
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def click_on(locator = nil, **opts)
|
|
511
|
+
el = begin; find_link(locator); rescue; begin; find_button(locator); rescue; nil; end; end
|
|
512
|
+
Specbook::Recorders::Screenshot.capture!(self, "Click '#{locator}'", target_element: el, action_type: "click")
|
|
513
|
+
super
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def fill_in(locator = nil, with:, **opts)
|
|
517
|
+
el = begin; find_field(locator || opts[:id] || opts[:name]); rescue; nil; end
|
|
518
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(self, "Type '#{with.to_s.truncate(30)}' into '#{locator}'", target_element: el, action_type: "fill") }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def select(value = nil, from: nil, **opts)
|
|
522
|
+
el = begin; find_field(from); rescue; nil; end
|
|
523
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(self, "Select '#{value}' from '#{from}'", target_element: el, action_type: "select") }
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def choose(locator = nil, **opts)
|
|
527
|
+
# Radio buttons are often sr-only (hidden). Find the visible parent label card by text.
|
|
528
|
+
el = begin; find(:css, "label", text: /\A#{Regexp.escape(locator)}\z/); rescue; begin; find(:css, "label", text: locator); rescue; nil; end; end
|
|
529
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(self, "Choose '#{locator}'", target_element: el, action_type: "check") }
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def check(locator = nil, **opts)
|
|
533
|
+
el = begin; find_field(locator); rescue; nil; end
|
|
534
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(self, "Check '#{locator}'", target_element: el, action_type: "check") }
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def uncheck(locator = nil, **opts)
|
|
538
|
+
el = begin; find_field(locator); rescue; nil; end
|
|
539
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(self, "Uncheck '#{locator}'", target_element: el, action_type: "check") }
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def attach_file(locator = nil, path = nil, **opts)
|
|
543
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(self, "Attach file '#{locator}'", action_type: "fill") }
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def visit(path, **)
|
|
547
|
+
Specbook::Recorders::Screenshot.flush_assertions!(self) if Specbook::Recorders::Screenshot.pending_assertions.present?
|
|
548
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(self, "Navigate to #{path}", action_type: "navigate") }
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def accept_confirm(text = nil, &block)
|
|
552
|
+
super.tap { Specbook::Recorders::Screenshot.capture!(self, "Accept confirm dialog", action_type: "confirm") }
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Track Gherkin step index for Turnip features — hook into run_step
|
|
557
|
+
module TurnipGherkinTracker
|
|
558
|
+
def run_step(feature_file, step)
|
|
559
|
+
Specbook::Recorders::Screenshot.advance_gherkin_step!
|
|
560
|
+
|
|
561
|
+
# Capture step definition source code for the spec player
|
|
562
|
+
begin
|
|
563
|
+
description = step.respond_to?(:description) ? step.description : step.to_s
|
|
564
|
+
matches = methods.map { |m| m.to_s.start_with?("match: ") ? send(m.to_s, description) : nil }.compact
|
|
565
|
+
if matches.first
|
|
566
|
+
method_obj = method(matches.first.method_name)
|
|
567
|
+
source_file, source_line = method_obj.source_location
|
|
568
|
+
params = matches.first.params
|
|
569
|
+
if source_file
|
|
570
|
+
rel_path = source_file.sub(Specbook.config.feature_root.to_s + "/", "")
|
|
571
|
+
lines = File.readlines(source_file)
|
|
572
|
+
# Extract body lines between `step '...' do` and closing `end`
|
|
573
|
+
first_line = lines[source_line - 1]
|
|
574
|
+
step_indent = first_line[/^\s*/].length
|
|
575
|
+
body_lines = []
|
|
576
|
+
(source_line).upto(lines.size - 1) do |i| # start AFTER the step line
|
|
577
|
+
line = lines[i]
|
|
578
|
+
# Stop at closing end (same indent level as step)
|
|
579
|
+
break if line.rstrip =~ /\A\s{0,#{step_indent}}end\s*\z/
|
|
580
|
+
body_lines << line
|
|
581
|
+
end
|
|
582
|
+
body = body_lines.join
|
|
583
|
+
|
|
584
|
+
# Substitute Turnip params into the source so variables show resolved values.
|
|
585
|
+
# Only replace when the variable is a standalone argument — not after a dot
|
|
586
|
+
# (method call) or before a colon (hash key).
|
|
587
|
+
if params.any?
|
|
588
|
+
param_names = first_line[/\|([^|]+)\|/, 1]&.split(",")&.map(&:strip) || []
|
|
589
|
+
param_names.each_with_index do |pname, idx|
|
|
590
|
+
next unless params[idx]
|
|
591
|
+
val = params[idx].is_a?(String) ? %("#{params[idx]}") : params[idx].to_s
|
|
592
|
+
# Negative lookbehind for dot (method call), negative lookahead for colon (hash key)
|
|
593
|
+
body = body.gsub(/(?<!\.)(?<![:\w])#{Regexp.escape(pname)}(?![\w:])/) { val }
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
Specbook::Recorders::Screenshot.record_step_source!(rel_path, source_line, body)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
rescue StandardError
|
|
601
|
+
# Step source capture is best-effort
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
super
|
|
605
|
+
|
|
606
|
+
# Capture a screenshot after every Gherkin step so each has at least one image
|
|
607
|
+
# Skip if no page has been visited yet (background setup = blank screen)
|
|
608
|
+
begin
|
|
609
|
+
url = page.current_url rescue nil
|
|
610
|
+
return if url.blank? || url == "about:blank" || url == "data:,"
|
|
611
|
+
Specbook::Recorders::Screenshot.flush_assertions!(page) rescue nil
|
|
612
|
+
if Specbook::Recorders::Screenshot.current_steps.blank? ||
|
|
613
|
+
Specbook::Recorders::Screenshot.current_steps.last&.dig(:gi) != Specbook::Recorders::Screenshot.current_gherkin_idx
|
|
614
|
+
Specbook::Recorders::Screenshot.capture!(page, step.description, action_type: "gherkin") rescue nil
|
|
615
|
+
end
|
|
616
|
+
rescue StandardError
|
|
617
|
+
# Skip
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
Capybara::Session.prepend(Specbook::Recorders::CapybaraSessionActionPatch)
|
|
625
|
+
|
|
626
|
+
# Apply assertion patch via prepend so it takes priority
|
|
627
|
+
Capybara::Session.prepend(Specbook::Recorders::CapybaraSessionAssertionPatch)
|
|
628
|
+
# Also patch Node::Base so assertions on scoped elements (find(...).have_css) capture too
|
|
629
|
+
Capybara::Node::Base.prepend(Specbook::Recorders::CapybaraSessionAssertionPatch)
|
|
630
|
+
|
|
631
|
+
if defined?(Turnip::RSpec::Execute)
|
|
632
|
+
Turnip::RSpec::Execute.prepend(Specbook::Recorders::TurnipGherkinTracker)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
RSpec.configure do |config|
|
|
636
|
+
config.before(:suite) do
|
|
637
|
+
# Create timestamped run directory
|
|
638
|
+
FileUtils.mkdir_p(Specbook::Recorders::Screenshot::SCREENSHOT_DIR)
|
|
639
|
+
|
|
640
|
+
# Symlink "latest" for the spec player
|
|
641
|
+
latest = Specbook::Recorders::Screenshot::SCREENSHOT_BASE.join("latest")
|
|
642
|
+
FileUtils.rm_f(latest)
|
|
643
|
+
FileUtils.ln_s(Specbook::Recorders::Screenshot::RUN_TIMESTAMP, latest)
|
|
644
|
+
|
|
645
|
+
# Prune old runs beyond MAX_RUNS
|
|
646
|
+
runs = Dir.children(Specbook::Recorders::Screenshot::SCREENSHOT_BASE)
|
|
647
|
+
.select { |d| d =~ /^\d{8}_\d{6}$/ }
|
|
648
|
+
.sort
|
|
649
|
+
if runs.size > Specbook::Recorders::Screenshot::MAX_RUNS
|
|
650
|
+
runs[0..-(Specbook::Recorders::Screenshot::MAX_RUNS + 1)].each do |old|
|
|
651
|
+
FileUtils.rm_rf(Specbook::Recorders::Screenshot::SCREENSHOT_BASE.join(old))
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
config.before(:each, type: :system) do |example|
|
|
657
|
+
next if example.metadata[:record] == false
|
|
658
|
+
Specbook::Recorders::Screenshot.reset_for_example!(example, page)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Non-UI Turnip features (models, services) run as :model type — no page/browser
|
|
662
|
+
config.before(:each, type: :model) do |example|
|
|
663
|
+
next unless example.metadata[:turnip]
|
|
664
|
+
next if example.metadata[:record] == false
|
|
665
|
+
Specbook::Recorders::Screenshot.current_example_name = example.full_description
|
|
666
|
+
Specbook::Recorders::Screenshot.current_steps = []
|
|
667
|
+
Specbook::Recorders::Screenshot.step_counter = 0
|
|
668
|
+
Specbook::Recorders::Screenshot.pending_assertions = []
|
|
669
|
+
Specbook::Recorders::Screenshot.current_gherkin_idx = -1
|
|
670
|
+
Specbook::Recorders::Screenshot.step_sources = {}
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
config.after(:each, type: :system) do |example|
|
|
674
|
+
# Flush any pending assertions as the final step
|
|
675
|
+
Specbook::Recorders::Screenshot.flush_assertions!(page) rescue nil
|
|
676
|
+
# If no assertions were flushed, capture a plain final state
|
|
677
|
+
if Specbook::Recorders::Screenshot.current_steps.present? &&
|
|
678
|
+
Specbook::Recorders::Screenshot.current_steps.last&.dig(:action) != "assert"
|
|
679
|
+
Specbook::Recorders::Screenshot.capture!(page, "Final state", action_type: "assert") rescue nil
|
|
680
|
+
end
|
|
681
|
+
Specbook::Recorders::Screenshot.finalize_example!(example)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
config.after(:each, type: :model) do |example|
|
|
685
|
+
next unless example.metadata[:turnip]
|
|
686
|
+
Specbook::Recorders::Screenshot.finalize_example!(example)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Request Turnip features
|
|
690
|
+
config.before(:each, type: :request) do |example|
|
|
691
|
+
next unless example.metadata[:turnip]
|
|
692
|
+
next if example.metadata[:record] == false
|
|
693
|
+
Specbook::Recorders::Screenshot.current_example_name = example.full_description
|
|
694
|
+
Specbook::Recorders::Screenshot.current_steps = []
|
|
695
|
+
Specbook::Recorders::Screenshot.step_counter = 0
|
|
696
|
+
Specbook::Recorders::Screenshot.pending_assertions = []
|
|
697
|
+
Specbook::Recorders::Screenshot.current_gherkin_idx = -1
|
|
698
|
+
Specbook::Recorders::Screenshot.step_sources = {}
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
config.after(:each, type: :request) do |example|
|
|
702
|
+
next unless example.metadata[:turnip]
|
|
703
|
+
Specbook::Recorders::Screenshot.finalize_example!(example)
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
config.after(:suite) do
|
|
707
|
+
Specbook::Recorders::Screenshot.write_manifest!
|
|
708
|
+
puts "\n📸 Screenshots saved to tmp/spec_screenshots/ (#{Specbook::Recorders::Screenshot.manifest.size} examples recorded)"
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Action patches now on Capybara::Session via CapybaraSessionActionPatch
|
|
712
|
+
end
|
|
713
|
+
end
|