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.
- checksums.yaml +4 -4
- data/README.md +39 -15
- data/lib/capybara/simulated/asset_cache.rb +31 -15
- data/lib/capybara/simulated/browser.rb +4077 -357
- data/lib/capybara/simulated/driver.rb +163 -17
- data/lib/capybara/simulated/errors.rb +12 -0
- data/lib/capybara/simulated/js/bridge.bundle.js +21037 -3446
- data/lib/capybara/simulated/minitest.rb +22 -0
- data/lib/capybara/simulated/node.rb +10 -13
- data/lib/capybara/simulated/quickjs_runtime.rb +44 -12
- data/lib/capybara/simulated/runtime_shared.rb +53 -9
- data/lib/capybara/simulated/stack_resolver.rb +5 -0
- data/lib/capybara/simulated/trace.rb +38 -11
- data/lib/capybara/simulated/trace_persistence.rb +30 -4
- data/lib/capybara/simulated/trace_viewer.html +561 -207
- data/lib/capybara/simulated/v8_runtime.rb +202 -53
- data/lib/capybara/simulated/version.rb +1 -1
- data/lib/capybara/simulated/worker_runtime.rb +34 -11
- data/lib/capybara/simulated.rb +14 -0
- data/vendor/js/vendor.bundle.js +14 -14
- metadata +29 -1
|
@@ -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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
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
|
|
250
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
182
|
-
#
|
|
183
|
-
'
|
|
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
|
|
@@ -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,
|
|
9
|
-
# output, and network requests interleaved. JSON output, one
|
|
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).
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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:
|
|
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
|
|