capybara-simulated 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +38 -14
- data/lib/capybara/simulated/asset_cache.rb +25 -12
- data/lib/capybara/simulated/browser.rb +3665 -325
- data/lib/capybara/simulated/driver.rb +163 -17
- data/lib/capybara/simulated/errors.rb +12 -0
- data/lib/capybara/simulated/js/bridge.bundle.js +10665 -2705
- data/lib/capybara/simulated/minitest.rb +22 -0
- data/lib/capybara/simulated/node.rb +10 -13
- data/lib/capybara/simulated/quickjs_runtime.rb +33 -2
- data/lib/capybara/simulated/runtime_shared.rb +46 -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 +199 -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 +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
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -41,8 +41,14 @@ module Capybara
|
|
|
41
41
|
# JS exception that crashes the whole script chain. Bodies take
|
|
42
42
|
# `(browser, *js_args)` and return whatever the JS caller expects.
|
|
43
43
|
BROWSER_HOST_FNS = {
|
|
44
|
-
'__rackFetch' => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4], a[5], credentials: a[6] || 'same-origin', referrer_policy: a[7], referrer: a[8], cache_mode: a[9] || 'default', initiator: a[10], site_seed: a[11], origin_null: a[12]) },
|
|
44
|
+
'__rackFetch' => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4], a[5], credentials: a[6] || 'same-origin', referrer_policy: a[7], referrer: a[8], cache_mode: a[9] || 'default', initiator: a[10], site_seed: a[11], origin_null: a[12], client_url: a[13], cookie_cross_site: a[14] == true, nav_dest: a[15]) },
|
|
45
45
|
'__csimExternalAsset' => ->(b, *a) { b.external_asset_source(a[0]) },
|
|
46
|
+
# fetch(…, {keepalive}) — eager detached-thread dispatch + one-shot result poll.
|
|
47
|
+
'__csim_keepaliveStart' => ->(b, *a) { b.keepalive_start(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10]) },
|
|
48
|
+
'__csim_keepaliveTake' => ->(b, *a) { b.keepalive_take(a[0]) },
|
|
49
|
+
# A controlled document's external classic script → its controller's fetch
|
|
50
|
+
# event, synchronously (destination 'script'). See sw_script_subresource_fetch.
|
|
51
|
+
'__csim_swScriptFetch' => ->(b, *a) { b.sw_script_subresource_fetch(a[0], a[1], a[2], a[3], a[4] || 'script', a[5] || 'no-cors', a[6] || 'include', binary: a[7] == true, integrity: a[8] || '') },
|
|
46
52
|
'__locationAssign' => ->(b, *a) { b.location_assign(a[0]); nil },
|
|
47
53
|
'__locationReload' => ->(b, *_) { b.location_reload; nil },
|
|
48
54
|
# A nested browsing context navigating its OWN location (a[1] = the frame's
|
|
@@ -117,7 +123,7 @@ module Capybara
|
|
|
117
123
|
# is). `opener` = rel=opener (target=_blank defaults to noopener); the Driver
|
|
118
124
|
# forces noopener for a cross-partition blob: target.
|
|
119
125
|
'__csimOpenAuxFromRealm' => ->(b, *a) { b.open_aux_from_realm(a[0], a[1], a[2]); nil },
|
|
120
|
-
'__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2]); nil },
|
|
126
|
+
'__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2], a[3]); nil },
|
|
121
127
|
'__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i, a[3]); nil },
|
|
122
128
|
# BroadcastChannel isolate-wide, creation-ordered registry (the multi-realm delivery path). A
|
|
123
129
|
# channel registers on construction / unregisters on close; `bc_post` snapshots the eligible
|
|
@@ -146,10 +152,19 @@ module Capybara
|
|
|
146
152
|
# a[3] is the CREATING realm (the worker dies when it is discarded); a[4] that context's
|
|
147
153
|
# CURRENT controller handle, which a DEDICATED worker inherits rather than scope-matching
|
|
148
154
|
# its own — often opaque (blob:/data:) — script URL. 0 when the creator is uncontrolled.
|
|
149
|
-
'__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1], creator_key: a[2], realm_id: a[3].to_i, controller_handle: a[4].to_i) },
|
|
155
|
+
'__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1], creator_key: a[2], realm_id: a[3].to_i, controller_handle: a[4].to_i, script_type: a[5]) },
|
|
150
156
|
# navigator.serviceWorker.register (universal-server only) — spawn a worker
|
|
151
157
|
# running the SW script as an executor context. Returns its handle.
|
|
152
|
-
'__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true, creator_key: a[1]) },
|
|
158
|
+
'__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true, creator_key: a[1], sw_scope: a[2], script_type: a[3]) },
|
|
159
|
+
# Registration update surface: the updateViaCache mode is REGISTRATION-wide state
|
|
160
|
+
# (scope-keyed, host-owned — a frame's registration object must see the mode the
|
|
161
|
+
# top window set), and __csim_swUpdateFetch is the Update algorithm's fetch +
|
|
162
|
+
# byte-check (sw_registration_update_fetch).
|
|
163
|
+
'__csim_swSetUpdateViaCache' => ->(b, *a) { b.sw_set_update_via_cache(a[0], a[1]) },
|
|
164
|
+
'__csim_swScopeUpdateViaCache' => ->(b, *a) { b.sw_scope_update_via_cache(a[0]) },
|
|
165
|
+
'__csim_swUpdateFetch' => ->(b, *a) { b.sw_registration_update_fetch(a[0], a[1], a[2].to_i, a[3]) },
|
|
166
|
+
'__csim_swNoteImport' => ->(b, *a) { b.sw_note_import(a[0].to_i, a[1], a[2]) },
|
|
167
|
+
'__csim_swDropPendingScript' => ->(b, *a) { b.sw_drop_pending_script(a[0]) },
|
|
153
168
|
'__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
|
|
154
169
|
# ServiceWorker.postMessage from a client window → the SW's `message` event (source = client).
|
|
155
170
|
'__csim_serviceWorkerPostMessage' => ->(b, *a) { b.service_worker_post_message(a[0], a[1], a[2], a[3]); nil },
|
|
@@ -164,6 +179,12 @@ module Capybara
|
|
|
164
179
|
# control is installed, by the realm, because only it knows its own URL, frame type and
|
|
165
180
|
# controller. `a[3]` is the controlling worker's handle, 0 when uncontrolled.
|
|
166
181
|
'__csimNoteClient' => ->(b, *a) { b.sw_note_client(a[0], a[1], a[2], a[3]); nil },
|
|
182
|
+
# A worker client's controller changed (claim adoption inside the worker isolate) —
|
|
183
|
+
# update the HOST-owned record (a worker must not take the realm-style report path).
|
|
184
|
+
'__csim_workerNoteController' => ->(b, *a) { b.sw_note_worker_controller(a[0], a[1]); nil },
|
|
185
|
+
# A controlled worker's importScripts, through its SW's fetch event (synchronous
|
|
186
|
+
# on the calling worker's thread — sw_import_script_fetch).
|
|
187
|
+
'__csim_swImportFetch' => ->(b, *a) { b.sw_import_script_fetch(a[0], a[1], a[2]) },
|
|
167
188
|
# A controlled client's fetch → the controlling SW's `fetch` event. Returns false if the SW
|
|
168
189
|
# is gone (client falls back to the network).
|
|
169
190
|
'__csim_serviceWorkerControllerFetch' => ->(b, *a) { b.service_worker_controller_fetch(a[0], a[1], a[2], a[3]) },
|
|
@@ -173,14 +194,27 @@ module Capybara
|
|
|
173
194
|
# Client lifecycle mirrors scope→active-worker into Ruby so a navigation (fetched
|
|
174
195
|
# Ruby-side before the destination realm's JS exists) can find its controlling SW.
|
|
175
196
|
'__csim_swRegisterScope' => ->(b, *a) { b.sw_register_scope(a[0], a[1]); nil },
|
|
197
|
+
# A live registration object exists at this scope (Register job success) — claim's
|
|
198
|
+
# longest-registration-wins must see it before any worker state exists.
|
|
199
|
+
'__csim_swNoteRegistered' => ->(b, *a) { b.sw_note_registered(a[0]); nil },
|
|
200
|
+
# The lifecycle reached 'activating': the scope's active worker exists but its
|
|
201
|
+
# activate waitUntil hasn't settled — Handle Fetch parks functional events on it.
|
|
202
|
+
'__csim_swNoteActivating' => ->(b, *a) { b.sw_note_activating(a[0], a[1]); nil },
|
|
176
203
|
'__csim_swUnregisterScope' => ->(b, *a) { b.sw_unregister_scope(a[0]); nil },
|
|
204
|
+
# unregister() parked its Clear Registration: the workers live on until no client is
|
|
205
|
+
# using the registration and no extended work is pending (see sw_note_uninstalling).
|
|
206
|
+
'__csim_swNoteUninstalling' => ->(b, *a) { b.sw_note_uninstalling(a[0], a[1], a[2] || []); nil },
|
|
177
207
|
# The active worker handle at an EXACT scope (0 if none), so a register() from a realm with no
|
|
178
208
|
# local registration (a different iframe registering an already-active scope) can synthesize a
|
|
179
209
|
# registration reflecting the shared active worker instead of installing a duplicate.
|
|
180
210
|
'__csim_swActiveHandleForScope' => ->(b, *a) { b.sw_active_handle_for_scope(a[0]) },
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
'
|
|
211
|
+
# The committed script type at that scope — the synthesized registration's
|
|
212
|
+
# reg._workerType (module SWs observed from a second realm).
|
|
213
|
+
'__csim_swScopeWorkerType' => ->(b, *a) { b.sw_scope_worker_type(a[0]) },
|
|
214
|
+
# HTML "try activate" in one atomic verdict: may `candidate` (installed, in the waiting
|
|
215
|
+
# slot) take over from `outgoing`? Extended work / controllees / skipWaiting are all
|
|
216
|
+
# host state — see sw_may_activate? and _scheduleLifecycle.
|
|
217
|
+
'__csim_swMayActivate' => ->(b, *a) { b.sw_may_activate?(a[0], a[1]) },
|
|
184
218
|
'__csim_swNoteActivationParked' => ->(b, *_) { b.sw_note_activation_parked; nil },
|
|
185
219
|
# Navigation Preload state (NavigationPreloadManager), keyed by the registration's active
|
|
186
220
|
# worker handle — reached identically from the client (registration.active._handle) and the
|
|
@@ -190,10 +224,13 @@ module Capybara
|
|
|
190
224
|
# A navigation (iframe/document load) → its controlling SW's `fetch` event, awaited
|
|
191
225
|
# synchronously. Returns the response wire hash, or nil to load from the network.
|
|
192
226
|
'__csim_swNavigationFetch' => ->(b, *a) { b.service_worker_navigation_fetch(a[0], is_reload: !!a[1], is_history: !!a[2], referrer_source: a[3], method: a[4] || 'GET', body_b64: a[5] || '', content_type: a[6]) },
|
|
227
|
+
'__csim_frameNavigationFetch' => ->(b, *a) { b.frame_navigation_fetch(a[0], a[2], is_reload: !!a[1], secure_ancestors: a[3].nil? || !!a[3], method: a[4] || 'GET', body_b64: a[5] || '', content_type: a[6], defer_ok: !!a[7], dest: a[8] || 'iframe') },
|
|
193
228
|
'__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
|
|
194
229
|
'__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
|
|
195
230
|
'__csim_renderText' => ->(b, *a) { b.render_text(a[0], a[1], a[2], a[3], a[4]) },
|
|
231
|
+
'__csim_fontAdvances' => ->(b, *a) { b.font_advance_table(a[0], a[1]) },
|
|
196
232
|
'__csim_loadImage' => ->(b, *a) { b.load_image(a[0], !!a[1], a[2] || 'same-origin') },
|
|
233
|
+
'__csim_imageLoadStart' => ->(b, *a) { b.image_load_start(a[0], !!a[1], a[2] || 'same-origin') },
|
|
197
234
|
'__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
|
|
198
235
|
# WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
|
|
199
236
|
# delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
|
|
@@ -206,7 +243,7 @@ module Capybara
|
|
|
206
243
|
# this so it doesn't bail before an async message (e.g. a freshly-spawned
|
|
207
244
|
# worker's first postMessage) has had a chance to land.
|
|
208
245
|
'__csim_asyncIoPending' => ->(b, *_a) { b.async_io_pending? },
|
|
209
|
-
'__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0]) },
|
|
246
|
+
'__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0], a[1]) },
|
|
210
247
|
'__csim_transferFetch' => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
|
|
211
248
|
# Zero-copy postMessage transfer-token bookkeeping (see Browser#drop_pending_transfers).
|
|
212
249
|
'__csim_transferIssued' => ->(b, *a) { b.transfer_token_issued(a[0]); nil },
|
|
@@ -216,7 +253,7 @@ module Capybara
|
|
|
216
253
|
# eager-@app.calls a foreign URL (side effects: extra visit / log row).
|
|
217
254
|
'__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
|
|
218
255
|
'__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
|
|
219
|
-
'__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0]) },
|
|
256
|
+
'__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0], !!a[1], a[2] || 'same-origin', a[3]) },
|
|
220
257
|
'__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
|
|
221
258
|
# WebAuthn create / get raise `WebauthnState::Error` carrying
|
|
222
259
|
# the DOMException name (`InvalidStateError`, …); rescue here
|
|
@@ -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
|
|