capybara-simulated 0.8.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
@@ -138,6 +135,14 @@ module Capybara
138
135
  self
139
136
  end
140
137
 
138
+ # Capybara's `Element#scroll_to(:current, offset: [dx, dy])` calls this after the position
139
+ # handling above — a relative scroll from wherever the element is now.
140
+ def scroll_by(dx, dy)
141
+ check_stale
142
+ browser.scroll_by(handle_id, dx, dy)
143
+ self
144
+ end
145
+
141
146
  # Capybara's standard rect API. No layout engine — but
142
147
  # The element's coarse border-box (viewport-relative), from the layout engine — backs the
143
148
  # spatial selectors (`:above`/`:below`/`:near`) and coordinate drag. Deterministic per layout
@@ -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
@@ -246,14 +267,27 @@ module Capybara
246
267
  # already the inter-test reset point.
247
268
  def reset_page = rebuild_ctx
248
269
 
249
- # NOTE: intentionally NO `dispose` Browser#dispose gates its
250
- # `@runtime.dispose` call on `respond_to?(:dispose)`, so QuickJS skips it.
251
- # QuickJS VMs aren't pinned by a process-wide registry the way V8 isolates
252
- # are in V8Runtime's `@@live`; an aux window's Browser becomes unreferenced
253
- # on close and Ruby GC's dfree frees its `@vm` (the same lifecycle
254
- # `rebuild_ctx` already relies on). Adding a `@vm = nil` dispose would only
255
- # introduce a NoMethodError window for any stray post-close call (eval/call
256
- # don't all nil-guard `@vm`) with no leak benefit.
270
+ # PERMANENTLY drop this runtime. Distinct from `rebuild_ctx` above, which
271
+ # deliberately does NOT `dispose!` that runs per visit, on every example,
272
+ # and `dispose!` blocks on the quickjs GC with the GVL held. Here it runs
273
+ # once, when a session is dropped for good, and the cost is the point.
274
+ #
275
+ # This used to be intentionally absent, on the reasoning that Ruby GC's
276
+ # dfree would reach an unreferenced `@vm` on its own. Measured, it does not
277
+ # in time to matter: 30 sessions created and dropped in a loop held 1979 MB
278
+ # with no dispose and 1971 MB with one, because `Browser#dispose` gates on
279
+ # `respond_to?(:dispose)` and so did nothing at all here. The suite's peak
280
+ # RSS is what pays for it.
281
+ #
282
+ # The old note's hazard — a stray post-close `eval`/`call` finding `@vm`
283
+ # nil — is real, so this leaves `@vm` in place and lets the gem's own
284
+ # freed-VM handling answer; `Driver#disposed?` keeps the live-driver walk
285
+ # from stepping a dropped runtime in the first place.
286
+ def dispose
287
+ @vm&.dispose!
288
+ rescue StandardError
289
+ nil
290
+ end
257
291
 
258
292
  # bridge.js patches `Intl.DateTimeFormat`; rusty_racer ships ICU built-in but
259
293
  # QuickJS gates it behind a polyfill flag (other surfaces bridge.js touches —
@@ -354,6 +388,9 @@ module Capybara
354
388
  # for every `<script type="module">`; V8 registers it too via
355
389
  # `V8Runtime#attach_native_module_loader`.
356
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).
357
394
  v.define_function('__csim_evalEsmEntry') {|url, inline_src|
358
395
  RuntimeShared.safe_call { browser.eval_esm_module(url, inline_src) }
359
396
  nil
@@ -395,7 +432,12 @@ module Capybara
395
432
  vm.define_function('__csimBroadcast') {|name, data, _rid, origin| broadcast_out&.call(name, data, origin); nil } if broadcast_out
396
433
  # Service-worker → main-thread signals via the outbox (see v8_runtime#build_worker).
397
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]
435
+ vm.define_function('__csim_swFocusClient') {|client_id| sw_hooks[:focus_client]&.call(client_id); nil } if sw_hooks[:focus_client]
398
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]
399
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]
400
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]
401
443
  vm.define_function('__csim_workerPortPost') {|channel, data| sw_hooks[:port_post]&.call(channel, data); nil } if sw_hooks[:port_post]
@@ -410,13 +452,16 @@ module Capybara
410
452
  vm.eval_code('__csim_installWorkerScope();')
411
453
  vm.drain_jobs!
412
454
  WorkerRuntime.new(
413
- 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 },
414
456
  call_fn: ->(n, *a) { v = vm.call(n.to_s, *a); vm.drain_jobs!; v },
415
457
  drain_microtasks: -> { vm.drain_jobs! },
416
458
  drain_timers: -> { vm.call('__drainTimers', 50) },
417
459
  has_ready_timer: -> { !!vm.call('__hasReadyTimer') },
418
460
  # quickjs.rb has no explicit dispose; GC reclaims the VM.
419
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.
420
465
  )
421
466
  end
422
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
@@ -67,8 +73,10 @@ module Capybara
67
73
  # isHistoryNavigation. Must run while the outgoing realm (a[0]) is still alive.
68
74
  '__csim_recordFrameNav' => ->(b, *a) { b.record_frame_nav(a[0].to_i, a[1]); nil },
69
75
  '__setTimersActive' => ->(b, *a) { b.timers_active = !!a[0]; nil },
70
- '__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1]); nil },
71
- '__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1]); nil },
76
+ # a[2] is the realm that navigated: a nested browsing context keeps its OWN session
77
+ # history, so a frame's pushState must not be mirrored onto the top document's.
78
+ '__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1], a[2]); nil },
79
+ '__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1], a[2]); nil },
72
80
  '__historyGo' => ->(b, *a) { b.history_go(a[0]); nil },
73
81
  '__historyLength' => ->(b, *_) { b.history_length },
74
82
  '__csimReadFilePick' => ->(b, *a) { b.read_file_pick(a[0], a[1], a[2], a[3]) },
@@ -107,14 +115,15 @@ module Capybara
107
115
  '__csim_rackFetchAsyncAbort' => ->(b, *a) { b.rack_fetch_async_abort(a[0]); nil },
108
116
  # Cross-window references (window.open / opener / postMessage). A separate-VM
109
117
  # aux window forwards to the Driver; a same-origin window realm lives in this
110
- # isolate. a[2] is the opener's realm id (for wiring window.opener).
111
- '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1], a[2]) },
118
+ # isolate. a[2] is the opener's realm id (for wiring window.opener); a[3]/a[4] the opener
119
+ # document's base URL and origin, which an about:blank popup inherits.
120
+ '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1], a[2], a[3], a[4]) },
112
121
  # A `target=_blank`/named link/area activation from a frame or window realm:
113
122
  # open a new auxiliary window (the realm's VM isn't rebuilt — a fresh window
114
123
  # is). `opener` = rel=opener (target=_blank defaults to noopener); the Driver
115
124
  # forces noopener for a cross-partition blob: target.
116
125
  '__csimOpenAuxFromRealm' => ->(b, *a) { b.open_aux_from_realm(a[0], a[1], a[2]); nil },
117
- '__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 },
118
127
  '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i, a[3]); nil },
119
128
  # BroadcastChannel isolate-wide, creation-ordered registry (the multi-realm delivery path). A
120
129
  # channel registers on construction / unregisters on close; `bc_post` snapshots the eligible
@@ -140,10 +149,22 @@ module Capybara
140
149
  # Fire an aux window's OWN `load` event (in its VM) — deferred by the
141
150
  # opener so a child `window.onload` runs after the opener's current task.
142
151
  '__csimFireAuxWindowLoad' => ->(b, *a) { b.fire_aux_window_load(a[0]); nil },
143
- '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1], creator_key: a[2]) },
152
+ # a[3] is the CREATING realm (the worker dies when it is discarded); a[4] that context's
153
+ # CURRENT controller handle, which a DEDICATED worker inherits rather than scope-matching
154
+ # its own — often opaque (blob:/data:) — script URL. 0 when the creator is uncontrolled.
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]) },
144
156
  # navigator.serviceWorker.register (universal-server only) — spawn a worker
145
157
  # running the SW script as an executor context. Returns its handle.
146
- '__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]) },
147
168
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
148
169
  # ServiceWorker.postMessage from a client window → the SW's `message` event (source = client).
149
170
  '__csim_serviceWorkerPostMessage' => ->(b, *a) { b.service_worker_post_message(a[0], a[1], a[2], a[3]); nil },
@@ -151,6 +172,19 @@ module Capybara
151
172
  # relay a client-realm port's postMessage to its remote (worker/SW) peer.
152
173
  '__csimClientPortEndpoint' => ->(b, *a) { b.port_channel_endpoint_realm(a[0], a[1]); nil },
153
174
  '__csimClientPortPost' => ->(b, *a) { b.client_port_post(a[0], a[1]); nil },
175
+ # The focus chain moved into this realm's browsing context (a focus() commit, or an
176
+ # <iframe> focused in its container, which hands focus to the nested context).
177
+ '__csimNoteFocusedRealm' => ->(b, *a) { b.note_focused_realm(a[0]); nil },
178
+ # This realm is a service-worker client: reported at document load and again whenever
179
+ # control is installed, by the realm, because only it knows its own URL, frame type and
180
+ # controller. `a[3]` is the controlling worker's handle, 0 when uncontrolled.
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]) },
154
188
  # A controlled client's fetch → the controlling SW's `fetch` event. Returns false if the SW
155
189
  # is gone (client falls back to the network).
156
190
  '__csim_serviceWorkerControllerFetch' => ->(b, *a) { b.service_worker_controller_fetch(a[0], a[1], a[2], a[3]) },
@@ -160,11 +194,28 @@ module Capybara
160
194
  # Client lifecycle mirrors scope→active-worker into Ruby so a navigation (fetched
161
195
  # Ruby-side before the destination realm's JS exists) can find its controlling SW.
162
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 },
163
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 },
164
207
  # The active worker handle at an EXACT scope (0 if none), so a register() from a realm with no
165
208
  # local registration (a different iframe registering an already-active scope) can synthesize a
166
209
  # registration reflecting the shared active worker instead of installing a duplicate.
167
210
  '__csim_swActiveHandleForScope' => ->(b, *a) { b.sw_active_handle_for_scope(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]) },
218
+ '__csim_swNoteActivationParked' => ->(b, *_) { b.sw_note_activation_parked; nil },
168
219
  # Navigation Preload state (NavigationPreloadManager), keyed by the registration's active
169
220
  # worker handle — reached identically from the client (registration.active._handle) and the
170
221
  # worker (__csimWorkerHandle). Get returns {enabled, headerValue}; set leaves a nil field as-is.
@@ -173,10 +224,13 @@ module Capybara
173
224
  # A navigation (iframe/document load) → its controlling SW's `fetch` event, awaited
174
225
  # synchronously. Returns the response wire hash, or nil to load from the network.
175
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') },
176
228
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
177
229
  '__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
178
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]) },
179
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') },
180
234
  '__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
181
235
  # WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
182
236
  # delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
@@ -189,7 +243,7 @@ module Capybara
189
243
  # this so it doesn't bail before an async message (e.g. a freshly-spawned
190
244
  # worker's first postMessage) has had a chance to land.
191
245
  '__csim_asyncIoPending' => ->(b, *_a) { b.async_io_pending? },
192
- '__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0]) },
246
+ '__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0], a[1]) },
193
247
  '__csim_transferFetch' => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
194
248
  # Zero-copy postMessage transfer-token bookkeeping (see Browser#drop_pending_transfers).
195
249
  '__csim_transferIssued' => ->(b, *a) { b.transfer_token_issued(a[0]); nil },
@@ -199,7 +253,7 @@ module Capybara
199
253
  # eager-@app.calls a foreign URL (side effects: extra visit / log row).
200
254
  '__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
201
255
  '__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
202
- '__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]) },
203
257
  '__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
204
258
  # WebAuthn create / get raise `WebauthnState::Error` carrying
205
259
  # the DOMException name (`InvalidStateError`, …); rescue here
@@ -227,34 +281,18 @@ module Capybara
227
281
  # Host fns that route to pure stdlib — no Browser surface,
228
282
  # nothing to safe_call, no allocation needed for the wrap. Skip
229
283
  # the rescue overhead on every per-find / per-event invocation.
230
- # Process-wide cascade-rule cache (mirrors the script bytecode cache). The
231
- # built {hide, layout} rules are deterministic per (stylesheet-set,
232
- # viewport), so the JS side caches the serialized rules keyed by a digest of
233
- # the sheet sources and skips the ~12-15 ms css-tree parse + per-rule
234
- # specificity + terminalKey rebuild on every per-visit VM rebuild. Lives in
235
- # Ruby (not the VM) so it survives `rebuild_ctx`. Key space is tiny (one app
236
- # ships one stylesheet set), so the map stays small; no eviction needed.
237
- CASCADE_RULE_CACHE = {}
238
- CASCADE_RULE_CACHE_MUTEX = Mutex.new
239
-
240
- # Process-wide PER-SHEET parse cache (companion to CASCADE_RULE_CACHE). The
241
- # built whole-cascade is cached above, but it misses whenever a page's inline
242
- # `<style>` changes (Avo injects per-page styles), forcing a rebuild that
243
- # re-parses every sheet — including unchanged linked bundles (avo.base.css).
244
- # `parseSheet` is pure, so the JS side caches its serialized `{hide,layout}`
245
- # keyed by (cssText hash, viewport) here, surviving the per-visit VM rebuild
246
- # that wipes the in-VM `__sheetCache` — the CSS analogue of the JS bytecode
247
- # cache. Keyed by content, so a content change yields a new key. Capped.
284
+ # Process-wide PER-SHEET parse cache (the CSS analogue of the JS bytecode
285
+ # cache). `parseSheet` is pure, so the JS side caches its serialized
286
+ # `{hide,layout}` here keyed by (cssText hash, viewport), surviving the
287
+ # per-visit VM rebuild that wipes the in-VM `__sheetCache`. A cascade
288
+ # rebuild then re-parses only sheets it has never seen (content change =
289
+ # new key). Content-keyed ONLY never url-keyed so freshness stays the
290
+ # asset cache's call. Capped.
248
291
  SHEET_PARSE_CACHE = {}
249
292
  SHEET_PARSE_CACHE_MUTEX = Mutex.new
250
293
  SHEET_PARSE_CACHE_MAX = 2048
251
294
 
252
295
  STDLIB_HOST_FNS = {
253
- '__csimCascadeCacheGet' => ->(*a) { CASCADE_RULE_CACHE_MUTEX.synchronize { CASCADE_RULE_CACHE[a[0].to_s] } },
254
- '__csimCascadeCachePut' => lambda {|*a|
255
- CASCADE_RULE_CACHE_MUTEX.synchronize { CASCADE_RULE_CACHE[a[0].to_s] = a[1].to_s }
256
- nil
257
- },
258
296
  '__csimSheetCacheGet' => ->(*a) { SHEET_PARSE_CACHE_MUTEX.synchronize { SHEET_PARSE_CACHE[a[0].to_s] } },
259
297
  '__csimSheetCachePut' => lambda {|*a|
260
298
  SHEET_PARSE_CACHE_MUTEX.synchronize {
@@ -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