capybara-simulated 0.9.0 → 0.10.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.
@@ -19,6 +19,22 @@ module Capybara
19
19
  module MinitestTrace
20
20
  module_function
21
21
 
22
+ # Paint each live trace's final state, while the page is still the one the test ended on.
23
+ # Stored on the trace so `TracePersistence` finds it already taken; a passing test pays
24
+ # nothing (`real_failures` is what decides), and nothing here may raise into a teardown.
25
+ def capture_screenshots(test)
26
+ return if real_failures(test).empty?
27
+
28
+ Capybara::Simulated::Driver.each_live_on_thread(Thread.current) do |driver|
29
+ next unless driver.respond_to?(:tracing?) && driver.tracing?
30
+
31
+ shot = driver.trace_screenshot
32
+ driver.current_trace.metadata[:screenshot] = shot if shot
33
+ rescue Exception => e # rubocop:disable Lint/RescueException
34
+ warn "capybara-simulated: trace screenshot failed: #{e.class}: #{e.message}"
35
+ end
36
+ end
37
+
22
38
  # Where the test method is defined, as `path:line` (best effort).
23
39
  def source_file(test)
24
40
  loc = test.class.instance_method(test.name).source_location
@@ -44,6 +60,12 @@ if (dir = ENV['CSIM_TRACE_DIR']) && !dir.empty?
44
60
  # no-ops for every non-Capybara test.
45
61
  hook = Module.new do
46
62
  define_method(:after_teardown) do
63
+ # BEFORE the host's teardown, because that is where `Capybara.reset_sessions!` lives and the
64
+ # page is rebuilt by it: a screenshot taken after `super()` is a picture of the blank page
65
+ # the reset installed, stored as "the state the failing example ended in". (RSpec's
66
+ # `prepend_after` already runs first, so only this host needs the split.) The trace itself is
67
+ # still WRITTEN after teardown — that is what the `super()` ordering was for.
68
+ Capybara::Simulated::MinitestTrace.capture_screenshots(self)
47
69
  super()
48
70
  ensure
49
71
  begin
@@ -25,25 +25,22 @@ module Capybara
25
25
 
26
26
  attr_reader :handle_id, :context_gen, :realm_id
27
27
 
28
- # Tick the virtual clock unconditionally on the text path. Unlike
29
- # `Node#[]` (attribute reads), the text readers are NOT preceded by
30
- # a `find_css` that ticks under the wall throttle: `have_text` /
31
- # `assert_text` polls `.text` directly against an already-found,
32
- # cached scope node, so the find loop short-circuits without
33
- # re-ticking. Gating these behind `timer_wait_elapsed?` therefore
34
- # stalls a pure text-poll loop's virtual clock and scheduled
35
- # `setTimeout`s never fire (smoke_spec virtual-clock contract).
36
- # They run ~once per poll-scope (not once per matched result like
37
- # the attribute filters the audit targeted), so they are not the
38
- # O(N) hot path and are safe to tick every call.
28
+ # The text readers advance the virtual clock, unlike `Node#[]`
29
+ # (attribute reads) which sit behind a `find_css` that already
30
+ # ticked: `have_text` / `assert_text` polls `.text` directly against
31
+ # an already-found, cached scope node, so the find loop
32
+ # short-circuits without re-ticking and a page waiting on a timer
33
+ # would never make progress. `tick_for_read` decides WHEN: a read
34
+ # leaves the clock where it is and the next query pays the step, so
35
+ # a walk over one query's results can't be stranded mid-way.
39
36
  def all_text
40
- browser.tick_real_time
37
+ browser.tick_for_read(handle_id)
41
38
  check_stale
42
39
  normalize_spacing(browser.all_text(handle_id))
43
40
  end
44
41
 
45
42
  def visible_text
46
- browser.tick_real_time
43
+ browser.tick_for_read(handle_id)
47
44
  check_stale
48
45
  normalize_visible_spacing(browser.visible_text(handle_id))
49
46
  end
@@ -104,7 +104,17 @@ module Capybara
104
104
  }
105
105
  end
106
106
 
107
- def checkout = @queue.pop
107
+ # Never BLOCK waiting on the warmers: on a loaded runner (parallel CI
108
+ # — every core already runs a sibling worker process) they can fall
109
+ # behind a session-per-iteration spec, and a blocking pop then parks
110
+ # the example until the global spec timeout fires. An empty pool
111
+ # instead costs the caller one inline `VM.new` — never slower than
112
+ # waiting for a warmer to build the same VM on a busier thread.
113
+ def checkout
114
+ @queue.pop(true)
115
+ rescue ThreadError
116
+ @queue.closed? ? nil : Quickjs::VM.new(**@vm_options)
117
+ end
108
118
 
109
119
  # SizedQueue#close unblocks pushers + makes future pops return
110
120
  # nil — necessary at process exit because a warmer mid-`VM.new`
@@ -150,6 +160,17 @@ module Capybara
150
160
  normalize(result)
151
161
  end
152
162
 
163
+ # Run `code` for its EFFECT, skipping the completion value. Mirrors
164
+ # `V8Runtime#eval_void` (which is where it earns its keep — rusty_racer
165
+ # marshals a completion value by RUNNING JS on it, so a discarded value
166
+ # can fail the eval); here it just skips `normalize`.
167
+ def eval_void(code)
168
+ v = vm
169
+ v.eval_code(code.to_s)
170
+ v.drain_jobs!
171
+ nil
172
+ end
173
+
153
174
  # the V8 engine drains its microtask queue at
154
175
  # the end of every call (V8's default microtask policy). QuickJS
155
176
  # does not: `js_std_await` only pumps pending jobs while it's
@@ -367,6 +388,9 @@ module Capybara
367
388
  # for every `<script type="module">`; V8 registers it too via
368
389
  # `V8Runtime#attach_native_module_loader`.
369
390
  browser = @browser
391
+ # The V8 path appends a SW fetch-context tail (moduleSwCtx, bridge.entry.js)
392
+ # that this block deliberately drops: QuickJS module loads don't dispatch SW
393
+ # fetch events (the engine runs `--tag ~wpt`; no covered observer).
370
394
  v.define_function('__csim_evalEsmEntry') {|url, inline_src|
371
395
  RuntimeShared.safe_call { browser.eval_esm_module(url, inline_src) }
372
396
  nil
@@ -410,6 +434,10 @@ module Capybara
410
434
  vm.define_function('__csim_swPostToClient') {|client_id, data| sw_hooks[:post_to_client]&.call(client_id, data); nil } if sw_hooks[:post_to_client]
411
435
  vm.define_function('__csim_swFocusClient') {|client_id| sw_hooks[:focus_client]&.call(client_id); nil } if sw_hooks[:focus_client]
412
436
  vm.define_function('__csim_swClaim') { sw_hooks[:claim]&.call; nil } if sw_hooks[:claim]
437
+ vm.define_function('__csim_swUnregisterRequest') { sw_hooks[:unregister]&.call; nil } if sw_hooks[:unregister]
438
+ vm.define_function('__csim_swNoteRouterRules') { sw_hooks[:router]&.call; nil } if sw_hooks[:router]
439
+ vm.define_function('__csim_swRaceNetwork') {|fetch_id, realm_id, url, method| sw_hooks[:race_network]&.call(fetch_id, realm_id, url, method); nil } if sw_hooks[:race_network]
440
+ vm.define_function('__csim_swExtendedChanged') {|n| sw_hooks[:extended]&.call(n); nil } if sw_hooks[:extended]
413
441
  vm.define_function('__csim_swFetchRespond') {|fetch_id, resp, realm_id| sw_hooks[:fetch_respond]&.call(fetch_id, resp, realm_id); nil } if sw_hooks[:fetch_respond]
414
442
  vm.define_function('__csim_swFetchStream') {|fetch_id, kind, payload, realm_id| sw_hooks[:fetch_stream]&.call(fetch_id, kind, payload, realm_id); nil } if sw_hooks[:fetch_stream]
415
443
  vm.define_function('__csim_workerPortPost') {|channel, data| sw_hooks[:port_post]&.call(channel, data); nil } if sw_hooks[:port_post]
@@ -424,13 +452,16 @@ module Capybara
424
452
  vm.eval_code('__csim_installWorkerScope();')
425
453
  vm.drain_jobs!
426
454
  WorkerRuntime.new(
427
- eval_fn: ->(s) { v = vm.eval_code(s.to_s); vm.drain_jobs!; v },
455
+ eval_void_fn: ->(s) { vm.eval_code(s.to_s); vm.drain_jobs!; nil },
428
456
  call_fn: ->(n, *a) { v = vm.call(n.to_s, *a); vm.drain_jobs!; v },
429
457
  drain_microtasks: -> { vm.drain_jobs! },
430
458
  drain_timers: -> { vm.call('__drainTimers', 50) },
431
459
  has_ready_timer: -> { !!vm.call('__hasReadyTimer') },
432
460
  # quickjs.rb has no explicit dispose; GC reclaims the VM.
433
461
  dispose: -> { nil }
462
+ # …and no cross-thread interrupt either, so `terminate` stays nil: a QuickJS worker
463
+ # inside a call is still only reachable through its inbox, and its own 30 s eval timeout
464
+ # is the backstop.
434
465
  )
435
466
  end
436
467
 
@@ -41,8 +41,14 @@ module Capybara
41
41
  # JS exception that crashes the whole script chain. Bodies take
42
42
  # `(browser, *js_args)` and return whatever the JS caller expects.
43
43
  BROWSER_HOST_FNS = {
44
- '__rackFetch' => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4], a[5], credentials: a[6] || 'same-origin', referrer_policy: a[7], referrer: a[8], cache_mode: a[9] || 'default', initiator: a[10], site_seed: a[11], origin_null: a[12]) },
44
+ '__rackFetch' => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4], a[5], credentials: a[6] || 'same-origin', referrer_policy: a[7], referrer: a[8], cache_mode: a[9] || 'default', initiator: a[10], site_seed: a[11], origin_null: a[12], client_url: a[13], cookie_cross_site: a[14] == true, nav_dest: a[15]) },
45
45
  '__csimExternalAsset' => ->(b, *a) { b.external_asset_source(a[0]) },
46
+ # fetch(…, {keepalive}) — eager detached-thread dispatch + one-shot result poll.
47
+ '__csim_keepaliveStart' => ->(b, *a) { b.keepalive_start(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10]) },
48
+ '__csim_keepaliveTake' => ->(b, *a) { b.keepalive_take(a[0]) },
49
+ # A controlled document's external classic script → its controller's fetch
50
+ # event, synchronously (destination 'script'). See sw_script_subresource_fetch.
51
+ '__csim_swScriptFetch' => ->(b, *a) { b.sw_script_subresource_fetch(a[0], a[1], a[2], a[3], a[4] || 'script', a[5] || 'no-cors', a[6] || 'include', binary: a[7] == true, integrity: a[8] || '') },
46
52
  '__locationAssign' => ->(b, *a) { b.location_assign(a[0]); nil },
47
53
  '__locationReload' => ->(b, *_) { b.location_reload; nil },
48
54
  # A nested browsing context navigating its OWN location (a[1] = the frame's
@@ -117,7 +123,7 @@ module Capybara
117
123
  # is). `opener` = rel=opener (target=_blank defaults to noopener); the Driver
118
124
  # forces noopener for a cross-partition blob: target.
119
125
  '__csimOpenAuxFromRealm' => ->(b, *a) { b.open_aux_from_realm(a[0], a[1], a[2]); nil },
120
- '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2]); nil },
126
+ '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2], a[3]); nil },
121
127
  '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i, a[3]); nil },
122
128
  # BroadcastChannel isolate-wide, creation-ordered registry (the multi-realm delivery path). A
123
129
  # channel registers on construction / unregisters on close; `bc_post` snapshots the eligible
@@ -146,10 +152,19 @@ module Capybara
146
152
  # a[3] is the CREATING realm (the worker dies when it is discarded); a[4] that context's
147
153
  # CURRENT controller handle, which a DEDICATED worker inherits rather than scope-matching
148
154
  # its own — often opaque (blob:/data:) — script URL. 0 when the creator is uncontrolled.
149
- '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1], creator_key: a[2], realm_id: a[3].to_i, controller_handle: a[4].to_i) },
155
+ '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1], creator_key: a[2], realm_id: a[3].to_i, controller_handle: a[4].to_i, script_type: a[5]) },
150
156
  # navigator.serviceWorker.register (universal-server only) — spawn a worker
151
157
  # running the SW script as an executor context. Returns its handle.
152
- '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true, creator_key: a[1]) },
158
+ '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true, creator_key: a[1], sw_scope: a[2], script_type: a[3]) },
159
+ # Registration update surface: the updateViaCache mode is REGISTRATION-wide state
160
+ # (scope-keyed, host-owned — a frame's registration object must see the mode the
161
+ # top window set), and __csim_swUpdateFetch is the Update algorithm's fetch +
162
+ # byte-check (sw_registration_update_fetch).
163
+ '__csim_swSetUpdateViaCache' => ->(b, *a) { b.sw_set_update_via_cache(a[0], a[1]) },
164
+ '__csim_swScopeUpdateViaCache' => ->(b, *a) { b.sw_scope_update_via_cache(a[0]) },
165
+ '__csim_swUpdateFetch' => ->(b, *a) { b.sw_registration_update_fetch(a[0], a[1], a[2].to_i, a[3]) },
166
+ '__csim_swNoteImport' => ->(b, *a) { b.sw_note_import(a[0].to_i, a[1], a[2]) },
167
+ '__csim_swDropPendingScript' => ->(b, *a) { b.sw_drop_pending_script(a[0]) },
153
168
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
154
169
  # ServiceWorker.postMessage from a client window → the SW's `message` event (source = client).
155
170
  '__csim_serviceWorkerPostMessage' => ->(b, *a) { b.service_worker_post_message(a[0], a[1], a[2], a[3]); nil },
@@ -164,6 +179,12 @@ module Capybara
164
179
  # control is installed, by the realm, because only it knows its own URL, frame type and
165
180
  # controller. `a[3]` is the controlling worker's handle, 0 when uncontrolled.
166
181
  '__csimNoteClient' => ->(b, *a) { b.sw_note_client(a[0], a[1], a[2], a[3]); nil },
182
+ # A worker client's controller changed (claim adoption inside the worker isolate) —
183
+ # update the HOST-owned record (a worker must not take the realm-style report path).
184
+ '__csim_workerNoteController' => ->(b, *a) { b.sw_note_worker_controller(a[0], a[1]); nil },
185
+ # A controlled worker's importScripts, through its SW's fetch event (synchronous
186
+ # on the calling worker's thread — sw_import_script_fetch).
187
+ '__csim_swImportFetch' => ->(b, *a) { b.sw_import_script_fetch(a[0], a[1], a[2]) },
167
188
  # A controlled client's fetch → the controlling SW's `fetch` event. Returns false if the SW
168
189
  # is gone (client falls back to the network).
169
190
  '__csim_serviceWorkerControllerFetch' => ->(b, *a) { b.service_worker_controller_fetch(a[0], a[1], a[2], a[3]) },
@@ -173,14 +194,27 @@ module Capybara
173
194
  # Client lifecycle mirrors scope→active-worker into Ruby so a navigation (fetched
174
195
  # Ruby-side before the destination realm's JS exists) can find its controlling SW.
175
196
  '__csim_swRegisterScope' => ->(b, *a) { b.sw_register_scope(a[0], a[1]); nil },
197
+ # A live registration object exists at this scope (Register job success) — claim's
198
+ # longest-registration-wins must see it before any worker state exists.
199
+ '__csim_swNoteRegistered' => ->(b, *a) { b.sw_note_registered(a[0]); nil },
200
+ # The lifecycle reached 'activating': the scope's active worker exists but its
201
+ # activate waitUntil hasn't settled — Handle Fetch parks functional events on it.
202
+ '__csim_swNoteActivating' => ->(b, *a) { b.sw_note_activating(a[0], a[1]); nil },
176
203
  '__csim_swUnregisterScope' => ->(b, *a) { b.sw_unregister_scope(a[0]); nil },
204
+ # unregister() parked its Clear Registration: the workers live on until no client is
205
+ # using the registration and no extended work is pending (see sw_note_uninstalling).
206
+ '__csim_swNoteUninstalling' => ->(b, *a) { b.sw_note_uninstalling(a[0], a[1], a[2] || []); nil },
177
207
  # The active worker handle at an EXACT scope (0 if none), so a register() from a realm with no
178
208
  # local registration (a different iframe registering an already-active scope) can synthesize a
179
209
  # registration reflecting the shared active worker instead of installing a duplicate.
180
210
  '__csim_swActiveHandleForScope' => ->(b, *a) { b.sw_active_handle_for_scope(a[0]) },
181
- # Does this worker still control any client? An incoming worker may only activate once the
182
- # outgoing one controls nothing (or skipWaiting is called) — see _scheduleLifecycle.
183
- '__csim_swControlsClients' => ->(b, *a) { b.sw_worker_controls_clients?(a[0]) },
211
+ # The committed script type at that scope the synthesized registration's
212
+ # reg._workerType (module SWs observed from a second realm).
213
+ '__csim_swScopeWorkerType' => ->(b, *a) { b.sw_scope_worker_type(a[0]) },
214
+ # HTML "try activate" in one atomic verdict: may `candidate` (installed, in the waiting
215
+ # slot) take over from `outgoing`? Extended work / controllees / skipWaiting are all
216
+ # host state — see sw_may_activate? and _scheduleLifecycle.
217
+ '__csim_swMayActivate' => ->(b, *a) { b.sw_may_activate?(a[0], a[1]) },
184
218
  '__csim_swNoteActivationParked' => ->(b, *_) { b.sw_note_activation_parked; nil },
185
219
  # Navigation Preload state (NavigationPreloadManager), keyed by the registration's active
186
220
  # worker handle — reached identically from the client (registration.active._handle) and the
@@ -190,10 +224,13 @@ module Capybara
190
224
  # A navigation (iframe/document load) → its controlling SW's `fetch` event, awaited
191
225
  # synchronously. Returns the response wire hash, or nil to load from the network.
192
226
  '__csim_swNavigationFetch' => ->(b, *a) { b.service_worker_navigation_fetch(a[0], is_reload: !!a[1], is_history: !!a[2], referrer_source: a[3], method: a[4] || 'GET', body_b64: a[5] || '', content_type: a[6]) },
227
+ '__csim_frameNavigationFetch' => ->(b, *a) { b.frame_navigation_fetch(a[0], a[2], is_reload: !!a[1], secure_ancestors: a[3].nil? || !!a[3], method: a[4] || 'GET', body_b64: a[5] || '', content_type: a[6], defer_ok: !!a[7], dest: a[8] || 'iframe') },
193
228
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
194
229
  '__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
195
230
  '__csim_renderText' => ->(b, *a) { b.render_text(a[0], a[1], a[2], a[3], a[4]) },
231
+ '__csim_fontAdvances' => ->(b, *a) { b.font_advance_table(a[0], a[1]) },
196
232
  '__csim_loadImage' => ->(b, *a) { b.load_image(a[0], !!a[1], a[2] || 'same-origin') },
233
+ '__csim_imageLoadStart' => ->(b, *a) { b.image_load_start(a[0], !!a[1], a[2] || 'same-origin') },
197
234
  '__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
198
235
  # WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
199
236
  # delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
@@ -206,7 +243,7 @@ module Capybara
206
243
  # this so it doesn't bail before an async message (e.g. a freshly-spawned
207
244
  # worker's first postMessage) has had a chance to land.
208
245
  '__csim_asyncIoPending' => ->(b, *_a) { b.async_io_pending? },
209
- '__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0]) },
246
+ '__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0], a[1]) },
210
247
  '__csim_transferFetch' => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
211
248
  # Zero-copy postMessage transfer-token bookkeeping (see Browser#drop_pending_transfers).
212
249
  '__csim_transferIssued' => ->(b, *a) { b.transfer_token_issued(a[0]); nil },
@@ -216,7 +253,7 @@ module Capybara
216
253
  # eager-@app.calls a foreign URL (side effects: extra visit / log row).
217
254
  '__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
218
255
  '__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
219
- '__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0]) },
256
+ '__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0], !!a[1], a[2] || 'same-origin', a[3]) },
220
257
  '__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
221
258
  # WebAuthn create / get raise `WebauthnState::Error` carrying
222
259
  # the DOMException name (`InvalidStateError`, …); rescue here
@@ -25,6 +25,11 @@ module Capybara
25
25
  @@maps = {}
26
26
  @@lock = Mutex.new
27
27
 
28
+ # Drop the fetched source maps (part of `Browser.clear_http_cache`).
29
+ def self.clear
30
+ @@lock.synchronize { @@maps.clear }
31
+ end
32
+
28
33
  def initialize(browser)
29
34
  @browser = browser
30
35
  end
@@ -5,9 +5,13 @@ require 'fileutils'
5
5
 
6
6
  module Capybara
7
7
  module Simulated
8
- # Per-test trace of Capybara actions with DOM snapshots, console
9
- # output, and network requests interleaved. JSON output, one file
10
- # per test — downstream tooling builds whatever viewer it wants.
8
+ # Per-test trace of Capybara actions with DOM snapshots, screenshots,
9
+ # console output, and network requests interleaved. JSON output, one
10
+ # file per test — downstream tooling builds whatever viewer it wants.
11
+ #
12
+ # A screenshot is carried INLINE as a data URL, like everything else here:
13
+ # the viewer's whole point is that it opens from `file://` with no server,
14
+ # and a side-file image would need one (or a second artefact to lose).
11
15
  #
12
16
  # Off by default. `CSIM_TRACE_DIR=/path/to/dir` enables auto-mode
13
17
  # via `Browser#record_action`; the RSpec hook in `csim_rspec.rb`
@@ -21,6 +25,7 @@ module Capybara
21
25
  :url_before,
22
26
  :url_after,
23
27
  :dom_after, # only the post-action snapshot — the previous step's `dom_after` is the implicit "before"
28
+ :shot_after, # …and the same moment PAINTED, as a `data:image/png;base64,…` URL
24
29
  :console,
25
30
  :network,
26
31
  :elapsed_ms,
@@ -34,17 +39,30 @@ module Capybara
34
39
 
35
40
  # Render the self-contained HTML viewer for a trace JSON *string*,
36
41
  # embedding it inline (the `capybara-simulated trace` CLI is the
37
- # caller). `</` `<\/` so an embedded `</script>` inside a DOM
38
- # snapshot can't close the data block early still valid JSON
39
- # (`\/` is a legal JSON escape for `/`). The whole point of inline
40
- # embedding over fetch / `import … with { type: 'json' }` is that
41
- # the result opens straight from `file://` with no server (module /
42
- # fetch loads are CORS-blocked for `file://` origins).
42
+ # caller). The whole point of inline embedding over fetch / `import
43
+ # with { type: 'json' }` is that the result opens straight from
44
+ # `file://` with no server (module / fetch loads are CORS-blocked
45
+ # for `file://` origins).
46
+ #
47
+ # EVERY `<` is escaped, not just `</`. Escaping only the closing
48
+ # form looks sufficient — nothing can close the block early — and
49
+ # is worse than nothing: `<!--` puts the HTML tokenizer into
50
+ # script-data-escaped state and a following `<script` into
51
+ # script-data-DOUBLE-escaped state, where a real `</script>` no
52
+ # longer closes the element and only `</script` can leave... which
53
+ # is exactly what the old escaping guaranteed could never appear.
54
+ # A DOM snapshot of a page with a commented-out script tag —
55
+ # `<!-- <script src="/analytics.js"></script> -->`, which is not an
56
+ # exotic thing for a page to contain — therefore swallowed the rest
57
+ # of the viewer as text and rendered a blank white page, with
58
+ # nothing in the console to say why. `\u003c` is a legal escape
59
+ # inside a JSON string, `<` cannot appear anywhere else in JSON, and
60
+ # `JSON.parse` restores it, so the round trip is exact.
43
61
  def self.render_viewer(json_text)
44
62
  template = (@viewer_template ||= File.read(VIEWER_TEMPLATE_PATH))
45
63
  # Block form: the replacement is taken literally, so backslashes
46
64
  # in the JSON aren't interpreted as regexp backreferences.
47
- template.sub(VIEWER_DATA_TOKEN) { json_text.to_s.gsub('</', '<\/') }
65
+ template.sub(VIEWER_DATA_TOKEN) { json_text.to_s.gsub('<', '\u003c') }
48
66
  end
49
67
 
50
68
  attr_reader :steps, :metadata
@@ -99,7 +117,7 @@ module Capybara
99
117
  @network_buf = []
100
118
  end
101
119
 
102
- def finish_step(url_after: nil, dom_after: nil, error: nil)
120
+ def finish_step(url_after: nil, dom_after: nil, shot_after: nil, error: nil)
103
121
  return unless @open_step
104
122
  s = @open_step
105
123
  @steps << Step.new(
@@ -109,6 +127,7 @@ module Capybara
109
127
  url_before: s[:url_before],
110
128
  url_after: url_after,
111
129
  dom_after: dom_after,
130
+ shot_after: shot_after,
112
131
  console: @console_buf,
113
132
  network: @network_buf,
114
133
  elapsed_ms: (s[:start_ms] - @started_at).round,
@@ -120,6 +139,14 @@ module Capybara
120
139
  @network_buf = []
121
140
  end
122
141
 
142
+ # Is the step just recorded another attempt at the SAME failing action? Capybara retries an
143
+ # action for its whole wait window, and every attempt records a step — so this is what keeps
144
+ # a screenshot from being painted 60 times for one failed click.
145
+ def retrying_failure?(kind, description)
146
+ last = @steps.last
147
+ !last.nil? && !last.error.nil? && last.kind == kind && last.description == description
148
+ end
149
+
123
150
  def empty? = @steps.empty?
124
151
 
125
152
  def to_h
@@ -23,12 +23,38 @@ module Capybara
23
23
  # unless the driver actually recorded something.
24
24
  def persist(driver, dir, title:, file:, outcome:, exception:)
25
25
  return unless driver.respond_to?(:tracing?) && driver.tracing?
26
+ # `engine` only when the driver can say — and never at the cost of the write: this method
27
+ # exists to produce the trace file, so a driver call that raises must not take it down
28
+ # (the same reason the screenshot below is in its own rescue). A driver that has no answer
29
+ # leaves the key out rather than writing `null`.
30
+ engine = begin
31
+ driver.js_engine if driver.respond_to?(:js_engine)
32
+ rescue StandardError
33
+ nil
34
+ end
26
35
  driver.current_trace.metadata.merge!(
27
- title: title,
28
- file: file,
29
- outcome: outcome,
30
- exception: exception
36
+ {title: title, file: file, outcome: outcome, exception: exception, engine: engine}.compact
31
37
  )
38
+ # The state the example ENDED in, painted once — and painted HERE, after the example,
39
+ # rather than per step: a paint is ~50 ms on V8 and ~525 ms on QuickJS, and doing it inside
40
+ # an action's failure path puts it inside Capybara's retry window, where it can turn an
41
+ # action a retry would have rescued into a failure (measured: a click waiting on an overlay
42
+ # went from 35 ms to 563 ms). Only for a failure — that is the state anyone opens the trace
43
+ # to look at, and a passing example should pay nothing.
44
+ # …unless the host already took it, before its own teardown reset the page (see
45
+ # `minitest.rb`). Whoever gets there first with a LIVE page wins.
46
+ if outcome.to_s == 'failed' && !driver.current_trace.metadata[:screenshot] &&
47
+ driver.respond_to?(:trace_screenshot)
48
+ # In its own rescue, and rescuing more than `StandardError`: the paint is the one part of
49
+ # persisting that runs arbitrary engine code, and the trace file — the thing this method
50
+ # exists to write — must not be lost to it.
51
+ begin
52
+ shot = driver.trace_screenshot
53
+ driver.current_trace.metadata[:screenshot] = shot if shot
54
+ rescue Exception => e # rubocop:disable Lint/RescueException
55
+ warn "capybara-simulated: trace screenshot failed: #{e.class}: #{e.message}"
56
+ end
57
+ end
32
58
  driver.stop_tracing(path: File.join(dir, "#{slug(title)}.json"))
33
59
  end
34
60