capybara-simulated 0.9.0 → 0.11.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
@@ -232,24 +253,24 @@ module Capybara
232
253
  # precompiled bytecode. Partial in-VM resets carry the same
233
254
  # library-init-leak hazards V8Runtime documents.
234
255
  #
235
- # We don't `@vm&.dispose!` before swapping: per-visit rebuilds
236
- # happen on every spec example, and `dispose!` blocks on the
237
- # quickjs GC running with the GVL held. Ruby GC will eventually
238
- # reach the unreferenced VM and the gem's dfree handler frees
239
- # the JSRuntime. The transient C-heap growth between GCs is the
240
- # tradeoff for not paying ~hundreds of ms per spec.
256
+ # The OLD VM is disposed here, not left to Ruby's GC: a VM holding the bridge is ~40 MB of
257
+ # C heap behind a Ruby object a few hundred bytes big, so nothing prompts a collection and
258
+ # the dead VMs pile up a flatware worker climbed monotonically from 300 MB to 4.6 GB over
259
+ # one QuickJS gate, and 30 sessions of two visits each held 1945 MB (measured 2026-09-03).
260
+ # `dispose!` on a bridge-sized VM costs ~4 ms (the same thirty sessions: 3.69 s → 3.92 s,
261
+ # 515 MB), not the hundreds of milliseconds this used to fear.
241
262
  def rebuild_ctx
263
+ old = @vm
242
264
  @vm = build_vm
265
+ old&.dispose!
243
266
  end
244
267
 
245
268
  # Same operation as `rebuild_ctx` since per-visit rebuilds are
246
269
  # already the inter-test reset point.
247
270
  def reset_page = rebuild_ctx
248
271
 
249
- # PERMANENTLY drop this runtime. Distinct from `rebuild_ctx` above, which
250
- # deliberately does NOT `dispose!` that runs per visit, on every example,
251
- # and `dispose!` blocks on the quickjs GC with the GVL held. Here it runs
252
- # once, when a session is dropped for good, and the cost is the point.
272
+ # PERMANENTLY drop this runtime: the current VM goes the way every superseded one goes in
273
+ # `rebuild_ctx`, disposed rather than left to a GC that has no reason to run.
253
274
  #
254
275
  # This used to be intentionally absent, on the reasoning that Ruby GC's
255
276
  # dfree would reach an unreferenced `@vm` on its own. Measured, it does not
@@ -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
 
@@ -449,6 +480,7 @@ module Capybara
449
480
  resolved = browser.resolve_module_specifier(specifier, importer)
450
481
  body = browser.rack_fetch_body(resolved)
451
482
  return nil unless body
483
+ browser.note_module_fetch(resolved.to_s) # its Resource Timing entry ('script')
452
484
  # `.json` (and `?import` JSON) imports come from Vite's
453
485
  # `import.meta.glob` and `import x from './data.json'`
454
486
  # patterns. quickjs.rb's loader passes the source through
@@ -41,8 +41,15 @@ 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
+ '__csimExternalAssetMeta' => ->(b, *a) { b.external_asset_meta(a[0]) },
47
+ # fetch(…, {keepalive}) — eager detached-thread dispatch + one-shot result poll.
48
+ '__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]) },
49
+ '__csim_keepaliveTake' => ->(b, *a) { b.keepalive_take(a[0]) },
50
+ # A controlled document's external classic script → its controller's fetch
51
+ # event, synchronously (destination 'script'). See sw_script_subresource_fetch.
52
+ '__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
53
  '__locationAssign' => ->(b, *a) { b.location_assign(a[0]); nil },
47
54
  '__locationReload' => ->(b, *_) { b.location_reload; nil },
48
55
  # A nested browsing context navigating its OWN location (a[1] = the frame's
@@ -117,7 +124,7 @@ module Capybara
117
124
  # is). `opener` = rel=opener (target=_blank defaults to noopener); the Driver
118
125
  # forces noopener for a cross-partition blob: target.
119
126
  '__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 },
127
+ '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2], a[3]); nil },
121
128
  '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i, a[3]); nil },
122
129
  # BroadcastChannel isolate-wide, creation-ordered registry (the multi-realm delivery path). A
123
130
  # channel registers on construction / unregisters on close; `bc_post` snapshots the eligible
@@ -146,10 +153,19 @@ module Capybara
146
153
  # a[3] is the CREATING realm (the worker dies when it is discarded); a[4] that context's
147
154
  # CURRENT controller handle, which a DEDICATED worker inherits rather than scope-matching
148
155
  # 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) },
156
+ '__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
157
  # navigator.serviceWorker.register (universal-server only) — spawn a worker
151
158
  # 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]) },
159
+ '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true, creator_key: a[1], sw_scope: a[2], script_type: a[3]) },
160
+ # Registration update surface: the updateViaCache mode is REGISTRATION-wide state
161
+ # (scope-keyed, host-owned — a frame's registration object must see the mode the
162
+ # top window set), and __csim_swUpdateFetch is the Update algorithm's fetch +
163
+ # byte-check (sw_registration_update_fetch).
164
+ '__csim_swSetUpdateViaCache' => ->(b, *a) { b.sw_set_update_via_cache(a[0], a[1]) },
165
+ '__csim_swScopeUpdateViaCache' => ->(b, *a) { b.sw_scope_update_via_cache(a[0]) },
166
+ '__csim_swUpdateFetch' => ->(b, *a) { b.sw_registration_update_fetch(a[0], a[1], a[2].to_i, a[3]) },
167
+ '__csim_swNoteImport' => ->(b, *a) { b.sw_note_import(a[0].to_i, a[1], a[2]) },
168
+ '__csim_swDropPendingScript' => ->(b, *a) { b.sw_drop_pending_script(a[0]) },
153
169
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
154
170
  # ServiceWorker.postMessage from a client window → the SW's `message` event (source = client).
155
171
  '__csim_serviceWorkerPostMessage' => ->(b, *a) { b.service_worker_post_message(a[0], a[1], a[2], a[3]); nil },
@@ -164,6 +180,12 @@ module Capybara
164
180
  # control is installed, by the realm, because only it knows its own URL, frame type and
165
181
  # controller. `a[3]` is the controlling worker's handle, 0 when uncontrolled.
166
182
  '__csimNoteClient' => ->(b, *a) { b.sw_note_client(a[0], a[1], a[2], a[3]); nil },
183
+ # A worker client's controller changed (claim adoption inside the worker isolate) —
184
+ # update the HOST-owned record (a worker must not take the realm-style report path).
185
+ '__csim_workerNoteController' => ->(b, *a) { b.sw_note_worker_controller(a[0], a[1]); nil },
186
+ # A controlled worker's importScripts, through its SW's fetch event (synchronous
187
+ # on the calling worker's thread — sw_import_script_fetch).
188
+ '__csim_swImportFetch' => ->(b, *a) { b.sw_import_script_fetch(a[0], a[1], a[2]) },
167
189
  # A controlled client's fetch → the controlling SW's `fetch` event. Returns false if the SW
168
190
  # is gone (client falls back to the network).
169
191
  '__csim_serviceWorkerControllerFetch' => ->(b, *a) { b.service_worker_controller_fetch(a[0], a[1], a[2], a[3]) },
@@ -173,14 +195,27 @@ module Capybara
173
195
  # Client lifecycle mirrors scope→active-worker into Ruby so a navigation (fetched
174
196
  # Ruby-side before the destination realm's JS exists) can find its controlling SW.
175
197
  '__csim_swRegisterScope' => ->(b, *a) { b.sw_register_scope(a[0], a[1]); nil },
198
+ # A live registration object exists at this scope (Register job success) — claim's
199
+ # longest-registration-wins must see it before any worker state exists.
200
+ '__csim_swNoteRegistered' => ->(b, *a) { b.sw_note_registered(a[0]); nil },
201
+ # The lifecycle reached 'activating': the scope's active worker exists but its
202
+ # activate waitUntil hasn't settled — Handle Fetch parks functional events on it.
203
+ '__csim_swNoteActivating' => ->(b, *a) { b.sw_note_activating(a[0], a[1]); nil },
176
204
  '__csim_swUnregisterScope' => ->(b, *a) { b.sw_unregister_scope(a[0]); nil },
205
+ # unregister() parked its Clear Registration: the workers live on until no client is
206
+ # using the registration and no extended work is pending (see sw_note_uninstalling).
207
+ '__csim_swNoteUninstalling' => ->(b, *a) { b.sw_note_uninstalling(a[0], a[1], a[2] || []); nil },
177
208
  # The active worker handle at an EXACT scope (0 if none), so a register() from a realm with no
178
209
  # local registration (a different iframe registering an already-active scope) can synthesize a
179
210
  # registration reflecting the shared active worker instead of installing a duplicate.
180
211
  '__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]) },
212
+ # The committed script type at that scope the synthesized registration's
213
+ # reg._workerType (module SWs observed from a second realm).
214
+ '__csim_swScopeWorkerType' => ->(b, *a) { b.sw_scope_worker_type(a[0]) },
215
+ # HTML "try activate" in one atomic verdict: may `candidate` (installed, in the waiting
216
+ # slot) take over from `outgoing`? Extended work / controllees / skipWaiting are all
217
+ # host state — see sw_may_activate? and _scheduleLifecycle.
218
+ '__csim_swMayActivate' => ->(b, *a) { b.sw_may_activate?(a[0], a[1]) },
184
219
  '__csim_swNoteActivationParked' => ->(b, *_) { b.sw_note_activation_parked; nil },
185
220
  # Navigation Preload state (NavigationPreloadManager), keyed by the registration's active
186
221
  # worker handle — reached identically from the client (registration.active._handle) and the
@@ -190,10 +225,19 @@ module Capybara
190
225
  # A navigation (iframe/document load) → its controlling SW's `fetch` event, awaited
191
226
  # synchronously. Returns the response wire hash, or nil to load from the network.
192
227
  '__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]) },
228
+ '__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
229
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
194
230
  '__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
195
231
  '__csim_renderText' => ->(b, *a) { b.render_text(a[0], a[1], a[2], a[3], a[4]) },
232
+ '__csim_fontAdvances' => ->(b, *a) { b.font_advance_table(a[0], a[1]) },
233
+ '__csim_fontAdvancesFromUrl' => ->(b, *a) { b.font_advance_table_from_url(a[0]) },
234
+ '__csim_localFontTable' => ->(b, *a) { b.local_font_table(a[0], a[1] || '') },
235
+ '__csim_resourceTimingFetch' => ->(b, *a) { b.resource_timing_fetch(a[0], a[1] == true, a[2] || 'same-origin') },
236
+ '__csim_takeModuleRt' => ->(b, *a) { b.take_module_rt },
237
+ '__csim_takeWorkerRt' => ->(b, *a) { b.take_worker_rt },
238
+ '__csim_fontAdvancesFromBytes' => ->(b, *a) { b.font_advance_table_from_bytes(a[0]) },
196
239
  '__csim_loadImage' => ->(b, *a) { b.load_image(a[0], !!a[1], a[2] || 'same-origin') },
240
+ '__csim_imageLoadStart' => ->(b, *a) { b.image_load_start(a[0], !!a[1], a[2] || 'same-origin') },
197
241
  '__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
198
242
  # WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
199
243
  # delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
@@ -206,7 +250,7 @@ module Capybara
206
250
  # this so it doesn't bail before an async message (e.g. a freshly-spawned
207
251
  # worker's first postMessage) has had a chance to land.
208
252
  '__csim_asyncIoPending' => ->(b, *_a) { b.async_io_pending? },
209
- '__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0]) },
253
+ '__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0], a[1]) },
210
254
  '__csim_transferFetch' => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
211
255
  # Zero-copy postMessage transfer-token bookkeeping (see Browser#drop_pending_transfers).
212
256
  '__csim_transferIssued' => ->(b, *a) { b.transfer_token_issued(a[0]); nil },
@@ -216,7 +260,7 @@ module Capybara
216
260
  # eager-@app.calls a foreign URL (side effects: extra visit / log row).
217
261
  '__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
218
262
  '__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
219
- '__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0]) },
263
+ '__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0], !!a[1], a[2] || 'same-origin', a[3]) },
220
264
  '__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
221
265
  # WebAuthn create / get raise `WebauthnState::Error` carrying
222
266
  # 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