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.
@@ -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