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.
- checksums.yaml +4 -4
- data/README.md +43 -18
- data/lib/capybara/simulated/asset_cache.rb +25 -12
- data/lib/capybara/simulated/browser.rb +4182 -338
- data/lib/capybara/simulated/driver.rb +205 -30
- data/lib/capybara/simulated/errors.rb +12 -0
- data/lib/capybara/simulated/js/bridge.bundle.js +14151 -3473
- data/lib/capybara/simulated/minitest.rb +22 -0
- data/lib/capybara/simulated/node.rb +18 -13
- data/lib/capybara/simulated/quickjs_runtime.rb +55 -10
- data/lib/capybara/simulated/runtime_shared.rb +71 -33
- 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 +265 -70
- 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 +15 -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
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
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 {
|
|
@@ -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
|
|