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
|
@@ -11,8 +11,17 @@
|
|
|
11
11
|
|
|
12
12
|
require 'digest'
|
|
13
13
|
require 'fileutils'
|
|
14
|
+
require 'uri'
|
|
14
15
|
require 'rusty_racer'
|
|
15
16
|
|
|
17
|
+
# The engine is a SOFT dependency (the gemspec names no version), so say what
|
|
18
|
+
# we need here rather than letting it surface as a NoMethodError from inside
|
|
19
|
+
# `rebuild_ctx`'s warm-reset rescue, which would report it as a failed reset.
|
|
20
|
+
# 0.2.1: Context#eval_void / Script#run_void. 0.2.0: Module#graph_async?.
|
|
21
|
+
unless RustyRacer::Context.method_defined?(:eval_void)
|
|
22
|
+
raise LoadError, "capybara-simulated needs rusty_racer >= 0.2.1 (found #{RustyRacer::VERSION})"
|
|
23
|
+
end
|
|
24
|
+
|
|
16
25
|
require_relative 'runtime_shared'
|
|
17
26
|
require_relative 'script_cache'
|
|
18
27
|
require_relative 'worker_runtime'
|
|
@@ -27,7 +36,7 @@ begin
|
|
|
27
36
|
# 8900×8900 fixture through the transfer-buffer path). Match
|
|
28
37
|
# Discourse's own testem flag of 4 GB so the test fits.
|
|
29
38
|
#
|
|
30
|
-
# rusty_racer
|
|
39
|
+
# rusty_racer installs a near-heap-limit callback on every isolate,
|
|
31
40
|
# so EXCEEDING this cap raises a catchable `RustyRacer::V8OutOfMemoryError`
|
|
32
41
|
# (and the isolate recovers) instead of V8 aborting the whole process with a
|
|
33
42
|
# fatal "Reached heap limit". So this value doubles as the memory backstop: a
|
|
@@ -113,6 +122,7 @@ module Capybara
|
|
|
113
122
|
# policy), so a returned eval/call has already run its end-of-script
|
|
114
123
|
# microtasks.
|
|
115
124
|
def eval(src) = @ctx.eval(src)
|
|
125
|
+
def eval_void(src) = @ctx.eval_void(src)
|
|
116
126
|
def call(name, *args) = @ctx.call(name, *args)
|
|
117
127
|
|
|
118
128
|
# Record every attach so `create_context` can replay them: the bridge
|
|
@@ -152,8 +162,8 @@ module Capybara
|
|
|
152
162
|
def terminate = @iso.terminate
|
|
153
163
|
def dispose = @iso.dispose
|
|
154
164
|
def perform_microtask_checkpoint = @iso.perform_microtask_checkpoint
|
|
155
|
-
# V8 heap accounting + a forced full GC
|
|
156
|
-
#
|
|
165
|
+
# V8 heap accounting + a forced full GC. Used by the per-visit
|
|
166
|
+
# heap-pressure relief in `rebuild_ctx`.
|
|
157
167
|
def heap_statistics = @iso.heap_statistics
|
|
158
168
|
def low_memory_notification = @iso.low_memory_notification
|
|
159
169
|
|
|
@@ -336,6 +346,7 @@ module Capybara
|
|
|
336
346
|
end
|
|
337
347
|
|
|
338
348
|
def eval(code) = ctx.eval(code.to_s)
|
|
349
|
+
def eval_void(code) = ctx.eval_void(code.to_s)
|
|
339
350
|
def call(name, *args)
|
|
340
351
|
result = ctx.call(name, *args)
|
|
341
352
|
ScriptCache.warm_pending!
|
|
@@ -434,6 +445,7 @@ module Capybara
|
|
|
434
445
|
|
|
435
446
|
def dispose_frame_realms
|
|
436
447
|
@realm_module_handles&.clear
|
|
448
|
+
@module_sw_ctxs&.clear
|
|
437
449
|
@frame_realm_depths&.clear
|
|
438
450
|
@frame_realm_parents&.clear
|
|
439
451
|
@window_realm_meta&.clear
|
|
@@ -449,14 +461,14 @@ module Capybara
|
|
|
449
461
|
# / module state, exactly like the main page's per-visit rebuild. `parent_id`
|
|
450
462
|
# keeps the new realm's `parent`/`top` wired to the owning realm. The
|
|
451
463
|
# Browser then re-points the iframe element at the new id (`__csimRebindFrameRealm`).
|
|
452
|
-
def reload_frame_realm(old_id, parent_id, url, body, content_type)
|
|
464
|
+
def reload_frame_realm(old_id, parent_id, url, body, content_type, client_id = nil)
|
|
453
465
|
# A re-navigated document discards its child browsing contexts, so dispose the old realm's
|
|
454
466
|
# DESCENDANT frame realms too — not just old_id. The JS src-reassignment path gets this for
|
|
455
467
|
# free (the old document's iframe elements go away → DOM-unregister disposes their realms);
|
|
456
468
|
# the Ruby reload path (navigate_realm_self_get/_post) rebuilds without that DOM teardown, so
|
|
457
469
|
# a descendant frame's realm would otherwise linger and its contentWindow stay live.
|
|
458
470
|
dispose_frame_realm_tree(old_id)
|
|
459
|
-
create_frame_realm(ctx, url, body, content_type, parent_id)
|
|
471
|
+
create_frame_realm(ctx, url, body, content_type, parent_id, nil, nil, nil, nil, nil, nil, client_id)
|
|
460
472
|
end
|
|
461
473
|
|
|
462
474
|
# Dispose a frame realm and every descendant frame realm (transitively), deepest first so a
|
|
@@ -499,6 +511,7 @@ module Capybara
|
|
|
499
511
|
# If it held the focus chain, focus returns to the top-level browsing context.
|
|
500
512
|
@browser.note_realm_discarded(id) rescue nil
|
|
501
513
|
@realm_module_handles&.delete(id)
|
|
514
|
+
@module_sw_ctxs&.delete(id)
|
|
502
515
|
@frame_realm_depths&.delete(id)
|
|
503
516
|
@frame_realm_parents&.delete(id)
|
|
504
517
|
@window_realm_meta&.delete(id)
|
|
@@ -507,7 +520,7 @@ module Capybara
|
|
|
507
520
|
# unregister path (its iframe element going away), but a WINDOW realm has no
|
|
508
521
|
# element — this is its only eviction, and it covers the reload (dispose +
|
|
509
522
|
# recreate) path too, where the old id would otherwise leak in the set.
|
|
510
|
-
ctx.
|
|
523
|
+
ctx.eval_void("globalThis.__csimChildRealmIds && globalThis.__csimChildRealmIds.delete(#{id.to_i});") rescue nil
|
|
511
524
|
fr = frame_realms.delete(id)
|
|
512
525
|
fr.dispose rescue nil if fr
|
|
513
526
|
nil
|
|
@@ -598,7 +611,7 @@ module Capybara
|
|
|
598
611
|
# contexts N -> 1). See `relieve_heap_pressure`.
|
|
599
612
|
relieve_heap_pressure
|
|
600
613
|
attach_host_fns(@ctx)
|
|
601
|
-
@ctx.
|
|
614
|
+
@ctx.eval_void('__csim_installWorker();')
|
|
602
615
|
return @ctx
|
|
603
616
|
rescue StandardError => e
|
|
604
617
|
warn "[capybara-simulated] warm context reset failed, falling back to cold rebuild: #{e.class}: #{e.message}"
|
|
@@ -726,7 +739,7 @@ module Capybara
|
|
|
726
739
|
# occasional JS-side infinite loop would otherwise stall the whole
|
|
727
740
|
# run; the timeout converts the hang into a
|
|
728
741
|
# `RustyRacer::ScriptTerminatedError` on that one example — whose
|
|
729
|
-
# `#message` / `#js_backtrace`
|
|
742
|
+
# `#message` / `#js_backtrace` name the looping JS
|
|
730
743
|
# frame (function + source position), so an in-V8 hang is diagnosable
|
|
731
744
|
# from the failure alone, no live debugger attach needed. The
|
|
732
745
|
# terminate escalates through any nested frames (it is
|
|
@@ -748,7 +761,7 @@ module Capybara
|
|
|
748
761
|
def build_ctx
|
|
749
762
|
c = Ctx.new(snapshot: @snapshot || self.class.snapshot, timeout: CALL_TIMEOUT_MS)
|
|
750
763
|
attach_host_fns(c)
|
|
751
|
-
c.
|
|
764
|
+
c.eval_void('__csim_installWorker();')
|
|
752
765
|
c
|
|
753
766
|
end
|
|
754
767
|
|
|
@@ -768,8 +781,8 @@ module Capybara
|
|
|
768
781
|
# id (or nil on failure — then the bridge keeps its same-realm fallback).
|
|
769
782
|
# The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
|
|
770
783
|
def attach_frame_realm_loader(c)
|
|
771
|
-
c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil) {
|
|
772
|
-
RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id, frame_name, frame_doc_origin, frame_location_origin, js_url_source, frame_about_base, frame_viewport) }
|
|
784
|
+
c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil, client_id = nil) {
|
|
785
|
+
RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id, frame_name, frame_doc_origin, frame_location_origin, js_url_source, frame_about_base, frame_viewport, client_id) }
|
|
773
786
|
})
|
|
774
787
|
# Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
|
|
775
788
|
# the bridge calls this to tear down the superseded one so it doesn't
|
|
@@ -814,14 +827,14 @@ module Capybara
|
|
|
814
827
|
# document — rebind realm-executing variants on top, then reseed per-realm JS.
|
|
815
828
|
def seed_realm_bridge(realm)
|
|
816
829
|
has_bridge = realm.eval("typeof __csimLoadDocument === 'function'")
|
|
817
|
-
realm.
|
|
830
|
+
realm.eval_void(RuntimeShared.snapshot_src) unless has_bridge
|
|
818
831
|
attach_run_script_with_cache(realm)
|
|
819
832
|
attach_realm_esm_entry(realm)
|
|
820
833
|
reseed_realm_js(realm)
|
|
821
834
|
realm
|
|
822
835
|
end
|
|
823
836
|
|
|
824
|
-
def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil)
|
|
837
|
+
def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil, client_id = nil)
|
|
825
838
|
depth = (frame_realm_depths[parent_id] || 0) + 1
|
|
826
839
|
if depth > MAX_FRAME_DEPTH
|
|
827
840
|
@browser.log_console('warn', "iframe nesting depth #{depth} exceeds #{MAX_FRAME_DEPTH}; not building #{url}")
|
|
@@ -841,7 +854,15 @@ module Capybara
|
|
|
841
854
|
# `top` propagates up the chain (the main realm's `top` is itself). A
|
|
842
855
|
# nested frame thus reaches its TRUE parent, not unconditionally the
|
|
843
856
|
# main frame. `parent_id` is an integer the marshaller carries verbatim.
|
|
844
|
-
|
|
857
|
+
#
|
|
858
|
+
# eval_void, and not by preference: a statement list's completion value is
|
|
859
|
+
# its last statement's, so this block evaluates to the WindowProxy it just
|
|
860
|
+
# assigned — and marshalling a value RUNS JS, so the proxy's `ownKeys` trap
|
|
861
|
+
# throws SecurityError at a cross-origin parent. That lands after every
|
|
862
|
+
# write here has already succeeded, aborting the rest of the frame's boot
|
|
863
|
+
# (leaving it on the snapshot's default origin) over a value we never asked
|
|
864
|
+
# for. Every eval below whose value we discard is spelled the same way.
|
|
865
|
+
realm.eval_void(<<~JS)
|
|
845
866
|
if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
|
|
846
867
|
var __parentWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(#{parent_id.to_i});
|
|
847
868
|
if (__parentWin) {
|
|
@@ -869,6 +890,15 @@ module Capybara
|
|
|
869
890
|
# HTML / control bytes survive (Ruby's String#inspect is NOT a faithful
|
|
870
891
|
# JS string escaper — it mangles \a, \e, and binary bytes).
|
|
871
892
|
realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
|
|
893
|
+
# The navigation's reserved client id — this document's realm ADOPTS it as its
|
|
894
|
+
# service-worker Client identity (`event.resultingClientId` resolves to THIS
|
|
895
|
+
# client after commit). Seeded BEFORE the document loads so the realm's own
|
|
896
|
+
# client reports (sw-client.js clientId()) already carry it; the browser-side
|
|
897
|
+
# alias keeps message routing and record minting coherent (sw_adopt_client_id).
|
|
898
|
+
unless client_id.to_s.empty?
|
|
899
|
+
realm.eval_void("globalThis.__csimClientId = #{JSON.generate(client_id.to_s)};")
|
|
900
|
+
@browser.sw_adopt_client_id(realm.id, client_id.to_s)
|
|
901
|
+
end
|
|
872
902
|
# Set window.name from the container's `name` attribute BEFORE the document
|
|
873
903
|
# loads, so a frame whose load handler reads window.name to identify itself
|
|
874
904
|
# (declarative-shadow declarative-child-frame) sees it.
|
|
@@ -959,6 +989,7 @@ module Capybara
|
|
|
959
989
|
# including any module handles its scripts compiled before the throw.
|
|
960
990
|
if realm
|
|
961
991
|
@realm_module_handles&.delete(realm.id)
|
|
992
|
+
@module_sw_ctxs&.delete(realm.id)
|
|
962
993
|
realm.dispose rescue nil
|
|
963
994
|
end
|
|
964
995
|
nil
|
|
@@ -983,7 +1014,7 @@ module Capybara
|
|
|
983
1014
|
# `window.closed` (flag-backed) and `window.close()` (marks closed; the realm
|
|
984
1015
|
# lingers inert until the Browser tears the isolate down — matching a real
|
|
985
1016
|
# closed window whose proxy stays valid and reports closed === true).
|
|
986
|
-
realm.
|
|
1017
|
+
realm.eval_void(<<~JS)
|
|
987
1018
|
globalThis.__csimIsWindowRealm = true;
|
|
988
1019
|
globalThis.__csimWindowClosedFlag = false;
|
|
989
1020
|
try {
|
|
@@ -999,7 +1030,7 @@ module Capybara
|
|
|
999
1030
|
# guard is on nil, not on 0 (0 is falsy but real here). Assigning globalThis.opener
|
|
1000
1031
|
# routes through the bridge's opener setter (stores the override the getter returns).
|
|
1001
1032
|
unless opener_id.nil?
|
|
1002
|
-
realm.
|
|
1033
|
+
realm.eval_void(<<~JS)
|
|
1003
1034
|
if (typeof globalThis.__csimFrameWindowProxyFor === 'function') {
|
|
1004
1035
|
var __op = globalThis.__csimFrameWindowProxyFor(#{opener_id.to_i});
|
|
1005
1036
|
if (__op) globalThis.opener = __op;
|
|
@@ -1035,7 +1066,7 @@ module Capybara
|
|
|
1035
1066
|
# Register with the opener (main) realm's child-realm set so `drainChildRealms`
|
|
1036
1067
|
# steps THIS realm's event loop too — otherwise its queued tasks (e.g. a
|
|
1037
1068
|
# BroadcastChannel delivery from a blob document) never fire.
|
|
1038
|
-
ctx.
|
|
1069
|
+
ctx.eval_void("(globalThis.__csimChildRealmIds || (globalThis.__csimChildRealmIds = new Set())).add(#{realm.id});")
|
|
1039
1070
|
# Remember the window's opener / name so a self-navigation (reload_window_realm
|
|
1040
1071
|
# builds a FRESH realm) can carry them across — a real popup keeps window.opener
|
|
1041
1072
|
# and window.name through its own navigation.
|
|
@@ -1065,9 +1096,19 @@ module Capybara
|
|
|
1065
1096
|
# by default, or a frame realm + its realm-local cache (Module handles are
|
|
1066
1097
|
# context-bound; sharing the main cache would link a frame's imports
|
|
1067
1098
|
# against main-context modules).
|
|
1068
|
-
def eval_esm_module(url, inline_src = nil, target: nil, handles: nil)
|
|
1099
|
+
def eval_esm_module(url, inline_src = nil, target: nil, handles: nil, sw: nil)
|
|
1069
1100
|
target ||= ctx
|
|
1070
1101
|
handles ||= native_module_handles
|
|
1102
|
+
# A CONTROLLED document's module-graph source fetches dispatch SW fetch
|
|
1103
|
+
# events (destination 'script', mode 'cors'). The context is recorded per
|
|
1104
|
+
# REALM (not scoped to this call) so a dynamic `import()` resolved later
|
|
1105
|
+
# by the isolate resolver still finds it; `root` carries the entry URL,
|
|
1106
|
+
# whose fetch alone uses the element's own `integrity` attribute.
|
|
1107
|
+
# KNOWN LOSS (documented divergence): one slot per realm means the LAST
|
|
1108
|
+
# evaluated script's fetch options win — a deferred import() from an
|
|
1109
|
+
# earlier script picks up the later script's credentials/root. Spec wants
|
|
1110
|
+
# per-referring-script options; no vendored test observes the difference.
|
|
1111
|
+
module_sw_ctxs[realm_key(target)] = sw&.merge(root: url.to_s)
|
|
1071
1112
|
m = native_module_for(url, inline_src, target, handles)
|
|
1072
1113
|
return nil unless m
|
|
1073
1114
|
begin
|
|
@@ -1108,7 +1149,27 @@ module Capybara
|
|
|
1108
1149
|
def native_module_for(url, inline_src, target, handles)
|
|
1109
1150
|
return handles[url] if handles.key?(url)
|
|
1110
1151
|
url_s = url.to_s
|
|
1111
|
-
src
|
|
1152
|
+
src = inline_src
|
|
1153
|
+
if src.nil? && (sw = module_sw_ctxs[realm_key(target)])
|
|
1154
|
+
# Only the graph's ENTRY fetch carries the element's integrity
|
|
1155
|
+
# attribute; every other module resolves integrity from the import map
|
|
1156
|
+
# (HTML "resolve a module integrity metadata").
|
|
1157
|
+
integ = url_s == sw[:root] ? sw[:integrity].to_s : ''
|
|
1158
|
+
integ = @browser.importmap_integrity(url_s) if integ.empty?
|
|
1159
|
+
r = @browser.sw_script_subresource_fetch(
|
|
1160
|
+
sw[:handle],
|
|
1161
|
+
url_s,
|
|
1162
|
+
sw[:client_id],
|
|
1163
|
+
sw[:referrer],
|
|
1164
|
+
'script',
|
|
1165
|
+
'cors',
|
|
1166
|
+
sw[:credentials] || 'same-origin',
|
|
1167
|
+
integrity: integ
|
|
1168
|
+
)
|
|
1169
|
+
return handles[url] = nil if r && r['blocked'] # respondWith failed the load
|
|
1170
|
+
src = r && r['body']
|
|
1171
|
+
end
|
|
1172
|
+
src ||= @browser.rack_fetch_body(url_s)
|
|
1112
1173
|
return handles[url] = nil unless src
|
|
1113
1174
|
body = module_body(url_s, src)
|
|
1114
1175
|
# No-cd warm path: once this isolate has compiled a URL, its in-memory
|
|
@@ -1164,8 +1225,8 @@ module Capybara
|
|
|
1164
1225
|
# realm-correctness as static `<script type=module>` via
|
|
1165
1226
|
# `attach_realm_esm_entry`.
|
|
1166
1227
|
def attach_native_module_loader(c)
|
|
1167
|
-
c.attach('__csim_evalEsmEntry', ->(url, inline) {
|
|
1168
|
-
RuntimeShared.safe_call { eval_esm_module(url, inline) }
|
|
1228
|
+
c.attach('__csim_evalEsmEntry', ->(url, inline, *sw) {
|
|
1229
|
+
RuntimeShared.safe_call { eval_esm_module(url, inline, sw: esm_sw_ctx(sw)) }
|
|
1169
1230
|
nil
|
|
1170
1231
|
})
|
|
1171
1232
|
c.dynamic_import_resolver = ->(specifier, referrer, initiating) {
|
|
@@ -1191,11 +1252,43 @@ module Capybara
|
|
|
1191
1252
|
(@realm_module_handles ||= {})[realm_id] ||= {}
|
|
1192
1253
|
end
|
|
1193
1254
|
|
|
1255
|
+
# realm -> SW fetch context for module-source fetches (see eval_esm_module).
|
|
1256
|
+
# Invalidated exactly like native_module_handles (a service worker OUTLIVES
|
|
1257
|
+
# navigation, so a stale entry would be ACTIVE, not inert: the next page's
|
|
1258
|
+
# dynamic import would dispatch fetch events for an uncontrolled document);
|
|
1259
|
+
# frame-realm entries also drop with their realm in the dispose paths.
|
|
1260
|
+
def module_sw_ctxs
|
|
1261
|
+
@module_sw_ctxs ||= {}
|
|
1262
|
+
key = [ctx.object_id, ctx.generation]
|
|
1263
|
+
if @module_sw_ctxs_key != key
|
|
1264
|
+
@module_sw_ctxs = {}
|
|
1265
|
+
@module_sw_ctxs_key = key
|
|
1266
|
+
end
|
|
1267
|
+
@module_sw_ctxs
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
def realm_key(target)
|
|
1271
|
+
target.equal?(ctx) ? 0 : target.id
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
# The optional [handle, clientId, referrer, credentials, integrity] tail
|
|
1275
|
+
# moduleSwCtx (bridge.entry.js) appends to __csim_evalEsmEntry, or nil.
|
|
1276
|
+
def esm_sw_ctx(sw)
|
|
1277
|
+
return nil unless sw && sw[0].to_i.positive?
|
|
1278
|
+
{
|
|
1279
|
+
handle: sw[0].to_i,
|
|
1280
|
+
client_id: sw[1].to_s,
|
|
1281
|
+
referrer: sw[2].to_s,
|
|
1282
|
+
credentials: sw[3].to_s,
|
|
1283
|
+
integrity: sw[4].to_s
|
|
1284
|
+
}
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1194
1287
|
# Frame-document `<script type=module>` entry, bound to the realm.
|
|
1195
1288
|
def attach_realm_esm_entry(realm)
|
|
1196
|
-
realm.attach('__csim_evalEsmEntry', ->(url, inline) {
|
|
1289
|
+
realm.attach('__csim_evalEsmEntry', ->(url, inline, *sw) {
|
|
1197
1290
|
RuntimeShared.safe_call {
|
|
1198
|
-
eval_esm_module(url, inline, target: realm, handles: realm_module_handles(realm.id))
|
|
1291
|
+
eval_esm_module(url, inline, target: realm, handles: realm_module_handles(realm.id), sw: esm_sw_ctx(sw))
|
|
1199
1292
|
}
|
|
1200
1293
|
nil
|
|
1201
1294
|
})
|
|
@@ -1239,15 +1332,7 @@ module Capybara
|
|
|
1239
1332
|
# pay the rendezvous round-trip.
|
|
1240
1333
|
c.attach('__csim_runScriptCached', ->(label, body) {
|
|
1241
1334
|
RuntimeShared.safe_call {
|
|
1242
|
-
|
|
1243
|
-
# `script.run`'s return crosses the V8→Ruby boundary on the trivial
|
|
1244
|
-
# marshalling fast path. Without it, a large inline script ending
|
|
1245
|
-
# in a jQuery-ish expression returns a `ce.fn.init` (array-like,
|
|
1246
|
-
# non-cloneable) that drags through the deep-copy filter —
|
|
1247
|
-
# pure waste, since the value is discarded (`nil` below). The SHA keys
|
|
1248
|
-
# the bytecode cache on the COMPILED source, so the suffix must be
|
|
1249
|
-
# hashed and fed to `queue_warm` too (else cached_data is rejected).
|
|
1250
|
-
src = "#{body}\n;undefined"
|
|
1335
|
+
src = body.to_s
|
|
1251
1336
|
# No-cd warm path, mirroring `native_module_for`: once this
|
|
1252
1337
|
# isolate has compiled a (label, bytesize), re-visits compile
|
|
1253
1338
|
# straight against V8's source-keyed in-memory cache — skipping
|
|
@@ -1273,7 +1358,13 @@ module Capybara
|
|
|
1273
1358
|
@compiled_script_keys[key] = true if key
|
|
1274
1359
|
end
|
|
1275
1360
|
begin
|
|
1276
|
-
script
|
|
1361
|
+
# run_void, not run: a `<script>`'s completion value is nobody's
|
|
1362
|
+
# answer, and marshalling it would drag a large inline script's
|
|
1363
|
+
# trailing jQuery-ish expression (a non-cloneable `ce.fn.init`)
|
|
1364
|
+
# through the deep-copy filter for nothing. This is why `src` is
|
|
1365
|
+
# the body verbatim — it used to carry a `;undefined` suffix, which
|
|
1366
|
+
# then had to be hashed into the bytecode-cache key as well.
|
|
1367
|
+
script.run_void
|
|
1277
1368
|
ensure
|
|
1278
1369
|
script.dispose
|
|
1279
1370
|
end
|
|
@@ -1315,17 +1406,15 @@ module Capybara
|
|
|
1315
1406
|
# as the QuickJS runner does. Swallowing here would turn a
|
|
1316
1407
|
# throwing leading-`const` inline script into a silent `load`.
|
|
1317
1408
|
c.attach('__csim_runScriptEval', ->(label, body) {
|
|
1318
|
-
#
|
|
1319
|
-
#
|
|
1320
|
-
#
|
|
1321
|
-
#
|
|
1322
|
-
#
|
|
1323
|
-
#
|
|
1324
|
-
#
|
|
1325
|
-
# doesn't affect the completion value; lexical declarations persist as a
|
|
1409
|
+
# A `<script>`'s completion value is nobody's answer, and reading it is
|
|
1410
|
+
# not free: a leading-lexical inline script ending in a jQuery-ish
|
|
1411
|
+
# expression (`const cfg=…; $(…)`) evaluates to a `ce.fn.init`
|
|
1412
|
+
# (array-like, non-cloneable) that drags through the deep-copy filter,
|
|
1413
|
+
# and a hostile getter in there would raise an error this call has no
|
|
1414
|
+
# business raising. `eval_void` says so outright, replacing the trailing
|
|
1415
|
+
# `;undefined` this used to append. Lexical declarations persist as a
|
|
1326
1416
|
# side effect of eval, independent of the completion value.
|
|
1327
|
-
c.
|
|
1328
|
-
nil
|
|
1417
|
+
c.eval_void("#{body}\n//# sourceURL=#{label.to_s.tr("\n", ' ')}")
|
|
1329
1418
|
})
|
|
1330
1419
|
install_run_script_dispatcher(c)
|
|
1331
1420
|
end
|
|
@@ -1337,7 +1426,7 @@ module Capybara
|
|
|
1337
1426
|
# run after the attaches it captures (`attach_run_script_with_cache`
|
|
1338
1427
|
# installs it last for exactly that reason).
|
|
1339
1428
|
def install_run_script_dispatcher(c)
|
|
1340
|
-
c.
|
|
1429
|
+
c.eval_void(<<~JS)
|
|
1341
1430
|
(function () {
|
|
1342
1431
|
const cached = globalThis.__csim_runScriptCached;
|
|
1343
1432
|
const runEval = globalThis.__csim_runScriptEval;
|
|
@@ -1345,6 +1434,18 @@ module Capybara
|
|
|
1345
1434
|
// Leading top-level lexical declaration, after optional BOM /
|
|
1346
1435
|
// whitespace / line+block comments / a "use strict" prologue.
|
|
1347
1436
|
const LEADS_LEXICAL = /^[\\s\\uFEFF]*(?:(?:\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)\\s*)*(?:["']use strict["'];?\\s*)?(?:export\\s+)?(?:const|let|class)[\\s{\\[]/;
|
|
1437
|
+
// A top-level lexical declaration can also sit MID-file (WPT's
|
|
1438
|
+
// cookie-helper.sub.js opens with an IIFE and declares `const
|
|
1439
|
+
// wait_for_message` at line 129) — under `(0, eval)` it block-scopes
|
|
1440
|
+
// and later <script>s see a ReferenceError. Detect it by a
|
|
1441
|
+
// declaration at COLUMN 0 of any line: real top-level declarations
|
|
1442
|
+
// in unminified files start unindented, function-body ones are
|
|
1443
|
+
// indented. Residual known gap: a one-line `stmt; const CFG = …`
|
|
1444
|
+
// (declaration mid-LINE) still takes the fast path and loses the
|
|
1445
|
+
// binding — no observed page does this. A false positive (a template
|
|
1446
|
+
// literal's line starting with `const `) merely routes through the
|
|
1447
|
+
// always-correct ctx.eval path.
|
|
1448
|
+
const MID_LEXICAL = /^(?:const|let|class)[\\s{\\[]/m;
|
|
1348
1449
|
// A "use strict" directive prologue. A classic <script> evaluates as a
|
|
1349
1450
|
// top-level Script, where top-level `var` / `function` declarations
|
|
1350
1451
|
// bind on the global object even in strict mode — but the JS-only
|
|
@@ -1356,7 +1457,7 @@ module Capybara
|
|
|
1356
1457
|
const LEADS_USE_STRICT = /^[\\s\\uFEFF]*(?:(?:\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)\\s*)*["']use strict["']/;
|
|
1357
1458
|
globalThis.__csim_runScript = function (label, body) {
|
|
1358
1459
|
if (body.length >= threshold) return cached(label, body);
|
|
1359
|
-
if (LEADS_LEXICAL.test(body) || LEADS_USE_STRICT.test(body)) return runEval(label || 'csim-eval', body);
|
|
1460
|
+
if (LEADS_LEXICAL.test(body) || LEADS_USE_STRICT.test(body) || MID_LEXICAL.test(body)) return runEval(label || 'csim-eval', body);
|
|
1360
1461
|
(0, eval)(body + '\\n//# sourceURL=' + (label || 'csim-eval'));
|
|
1361
1462
|
};
|
|
1362
1463
|
})();
|
|
@@ -1369,8 +1470,8 @@ module Capybara
|
|
|
1369
1470
|
# `__csim_installWorker()` post-snapshot init; the `__csim_runScript`
|
|
1370
1471
|
# dispatcher comes from `attach_run_script_with_cache` (realm-bound).
|
|
1371
1472
|
def reseed_realm_js(c)
|
|
1372
|
-
c.
|
|
1373
|
-
c.
|
|
1473
|
+
c.eval_void("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
|
|
1474
|
+
c.eval_void('__csim_installWorker();')
|
|
1374
1475
|
end
|
|
1375
1476
|
|
|
1376
1477
|
# Class-level attach so Worker isolates (Ruby-thread-owned
|
|
@@ -1391,7 +1492,7 @@ module Capybara
|
|
|
1391
1492
|
# microtask-checkpoint semantics. Alias it to the namespace's native
|
|
1392
1493
|
# in-isolate checkpoint so callers pay ~sub-µs instead of an
|
|
1393
1494
|
# attached-fn cross-thread round-trip.
|
|
1394
|
-
c.
|
|
1495
|
+
c.eval_void("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
|
|
1395
1496
|
# Register the bridge's recorder for V8's promise-reject notifications
|
|
1396
1497
|
# — the channel that surfaces rejections NO handler ever sees
|
|
1397
1498
|
# (fire-and-forget async functions, bare `Promise.reject`); the
|
|
@@ -1400,7 +1501,7 @@ module Capybara
|
|
|
1400
1501
|
# unhandled-rejection.js leaves registration to us. Main realm only —
|
|
1401
1502
|
# the recorder routes per-realm via `contextGlobal` itself, and a
|
|
1402
1503
|
# frame-realm registration would dangle once that realm is disposed.
|
|
1403
|
-
c.
|
|
1504
|
+
c.eval_void(<<~JS)
|
|
1404
1505
|
if (typeof globalThis.#{HOST_NAMESPACE_NAME}.setPromiseRejectHandler === 'function' &&
|
|
1405
1506
|
typeof globalThis.__csimPromiseRejected === 'function') {
|
|
1406
1507
|
globalThis.#{HOST_NAMESPACE_NAME}.setPromiseRejectHandler(globalThis.__csimPromiseRejected);
|
|
@@ -1425,6 +1526,10 @@ module Capybara
|
|
|
1425
1526
|
c.attach('__csim_swNavigateClient', ->(client_id, url, nav_id) { sw_hooks[:navigate_client]&.call(client_id, url, nav_id); nil }) if sw_hooks[:navigate_client]
|
|
1426
1527
|
c.attach('__csim_swClaim', -> { sw_hooks[:claim]&.call; nil }) if sw_hooks[:claim]
|
|
1427
1528
|
c.attach('__csim_swSkipWaitingRequest', -> { sw_hooks[:skip_waiting]&.call; nil }) if sw_hooks[:skip_waiting]
|
|
1529
|
+
c.attach('__csim_swNoteRouterRules', -> { sw_hooks[:router]&.call; nil }) if sw_hooks[:router]
|
|
1530
|
+
c.attach('__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]
|
|
1531
|
+
c.attach('__csim_swUnregisterRequest', -> { sw_hooks[:unregister]&.call; nil }) if sw_hooks[:unregister]
|
|
1532
|
+
c.attach('__csim_swExtendedChanged', ->(n) { sw_hooks[:extended]&.call(n); nil }) if sw_hooks[:extended]
|
|
1428
1533
|
c.attach('__csim_swFetchRespond', ->(fetch_id, resp, realm_id) { sw_hooks[:fetch_respond]&.call(fetch_id, resp, realm_id); nil }) if sw_hooks[:fetch_respond]
|
|
1429
1534
|
c.attach('__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]
|
|
1430
1535
|
# Cross-isolate MessagePort channel signals (a worker/SW port endpoint + its outbound messages).
|
|
@@ -1447,15 +1552,56 @@ module Capybara
|
|
|
1447
1552
|
# join the realm's shared global lexical environment where later code sees them.
|
|
1448
1553
|
# `(0, eval)` would block-scope them to the eval and they'd vanish. `c.eval` is
|
|
1449
1554
|
# the top-level-script path (same as the worker's own body eval).
|
|
1450
|
-
c.attach('__csim_workerImportEval', ->(src) { c.
|
|
1451
|
-
c.
|
|
1555
|
+
c.attach('__csim_workerImportEval', ->(src) { c.eval_void(src.to_s) })
|
|
1556
|
+
c.eval_void('__csim_installWorkerScope();')
|
|
1452
1557
|
WorkerRuntime.new(
|
|
1453
|
-
|
|
1558
|
+
eval_void_fn: ->(s) { c.eval_void(s.to_s) },
|
|
1454
1559
|
call_fn: ->(n, *a) { c.call(n.to_s, *a) },
|
|
1455
1560
|
drain_microtasks: -> { c.perform_microtask_checkpoint },
|
|
1456
1561
|
drain_timers: -> { c.call('__drainTimers', 50) },
|
|
1457
1562
|
has_ready_timer: -> { !!c.call('__hasReadyTimer') },
|
|
1458
|
-
dispose: -> { c.dispose rescue nil }
|
|
1563
|
+
dispose: -> { c.dispose rescue nil },
|
|
1564
|
+
# Called from the SESSION BOUNDARY's thread, not this worker's: V8's terminate is
|
|
1565
|
+
# thread-safe by design, and it is the only way to end a call that is already running.
|
|
1566
|
+
terminate: -> { c.terminate rescue nil },
|
|
1567
|
+
# A `{type: 'module'}` service worker's main script + static import graph,
|
|
1568
|
+
# via V8's native module API (the same surface the main realm's
|
|
1569
|
+
# eval_esm_module uses). The whole graph resolves through the root's
|
|
1570
|
+
# instantiate callback (V8 calls it per unresolved edge, transitively);
|
|
1571
|
+
# `fetch_import` runs on the worker's own thread and raises to fail the
|
|
1572
|
+
# evaluation. Specifier resolution is PLAIN URL resolution — a worker has
|
|
1573
|
+
# no document, so the page's importmap does not apply, and a bare
|
|
1574
|
+
# specifier is a resolution failure per the spec.
|
|
1575
|
+
eval_module_graph: lambda {|src, url, fetch_import|
|
|
1576
|
+
src_text = RuntimeShared.utf8_text(src.to_s.dup)
|
|
1577
|
+
handles = {}
|
|
1578
|
+
root = c.compile_module(src_text, filename: url.to_s)
|
|
1579
|
+
handles[url.to_s] = root
|
|
1580
|
+
root.instantiate do |spec, ref|
|
|
1581
|
+
s = spec.to_s
|
|
1582
|
+
resolved =
|
|
1583
|
+
if s.match?(%r{\A[a-z]+://}i)
|
|
1584
|
+
s
|
|
1585
|
+
elsif s.start_with?('/', './', '../')
|
|
1586
|
+
URI.join((ref || url).to_s, s).to_s
|
|
1587
|
+
else
|
|
1588
|
+
raise "Failed to resolve module specifier '#{s}'"
|
|
1589
|
+
end
|
|
1590
|
+
handles[resolved] ||= c.compile_module(RuntimeShared.utf8_text(fetch_import.call(resolved).to_s.dup), filename: resolved)
|
|
1591
|
+
end
|
|
1592
|
+
# Top-level await is disallowed in a service worker module ("Run Service
|
|
1593
|
+
# Worker" fails the script; Chrome rejects the registration). V8's
|
|
1594
|
+
# IsGraphAsync (Module#graph_async?) answers it for the WHOLE
|
|
1595
|
+
# instantiated graph, which is what the spec asks: TLA hiding in an
|
|
1596
|
+
# imported module fails too. Per-module `[[HasTLA]]` would name the
|
|
1597
|
+
# offender but not this question — it can't see an imported module's
|
|
1598
|
+
# await. This lambda serves service workers only; a dedicated module
|
|
1599
|
+
# worker, where TLA is legal, would need the check parameterized.
|
|
1600
|
+
raise 'Top-level await is disallowed in a service worker' if root.graph_async?
|
|
1601
|
+
|
|
1602
|
+
root.evaluate
|
|
1603
|
+
nil
|
|
1604
|
+
}
|
|
1459
1605
|
)
|
|
1460
1606
|
end
|
|
1461
1607
|
end
|
|
@@ -5,26 +5,49 @@ module Capybara
|
|
|
5
5
|
# Engine-uniform adapter Browser#run_worker drives. Each engine
|
|
6
6
|
# class (`V8Runtime`, `QuickJSRuntime`) has a `build_worker` class
|
|
7
7
|
# method that constructs the engine-specific Context/VM and wires
|
|
8
|
-
# it through these
|
|
9
|
-
# which engine it's running on; it just calls `
|
|
8
|
+
# it through these callbacks. Worker thread doesn't care
|
|
9
|
+
# which engine it's running on; it just calls `eval_void` / `call` /
|
|
10
10
|
# `drain_microtasks` / `drain_timers` / `has_ready_timer?` /
|
|
11
|
-
# `dispose`.
|
|
11
|
+
# `terminate` / `dispose`.
|
|
12
12
|
class WorkerRuntime
|
|
13
|
-
def initialize(
|
|
14
|
-
@
|
|
15
|
-
@call
|
|
16
|
-
@drain_microtasks
|
|
17
|
-
@drain_timers
|
|
18
|
-
@has_ready_timer
|
|
19
|
-
@dispose
|
|
13
|
+
def initialize(eval_void_fn:, call_fn:, drain_microtasks:, drain_timers:, has_ready_timer:, dispose:, terminate: nil, eval_module_graph: nil)
|
|
14
|
+
@eval_void = eval_void_fn
|
|
15
|
+
@call = call_fn
|
|
16
|
+
@drain_microtasks = drain_microtasks
|
|
17
|
+
@drain_timers = drain_timers
|
|
18
|
+
@has_ready_timer = has_ready_timer
|
|
19
|
+
@dispose = dispose
|
|
20
|
+
@terminate = terminate
|
|
21
|
+
@eval_module_graph = eval_module_graph
|
|
20
22
|
end
|
|
21
23
|
|
|
22
|
-
def
|
|
24
|
+
def eval_void(src) = @eval_void.call(src)
|
|
23
25
|
def call(name, *args) = @call.call(name, *args)
|
|
24
26
|
def drain_microtasks = @drain_microtasks.call
|
|
25
27
|
def drain_timers = @drain_timers.call
|
|
26
28
|
def has_ready_timer? = @has_ready_timer.call
|
|
27
29
|
def dispose = @dispose.call
|
|
30
|
+
# Stop whatever JavaScript this worker is running, FROM ANOTHER THREAD — the one thing the
|
|
31
|
+
# main thread can do to a worker that is inside a call, where the `:terminate` inbox message
|
|
32
|
+
# cannot reach it and `Thread#kill` does not land (see `Browser#stop_worker_js`). The call in
|
|
33
|
+
# flight ends as a terminated call; the worker's own loop then unwinds and disposes.
|
|
34
|
+
# `nil` on an engine with nothing to hand back — QuickJS — where this is a no-op.
|
|
35
|
+
def terminate = @terminate&.call
|
|
36
|
+
# Can this engine stop a call from ANOTHER thread at all? V8 can; QuickJS cannot. The
|
|
37
|
+
# boundary asks, because the answer changes what it should do next: where there is nothing
|
|
38
|
+
# to terminate, waiting for a terminate to work is pure delay and `Thread#kill` — which does
|
|
39
|
+
# land on that engine — is the right escalation.
|
|
40
|
+
def terminable? = !@terminate.nil?
|
|
41
|
+
|
|
42
|
+
# Native ES-module evaluation of a worker MAIN script + its static import
|
|
43
|
+
# graph (a `{type: 'module'}` service worker). `fetch_import` is called on
|
|
44
|
+
# the worker's own thread for each resolved static import URL and returns
|
|
45
|
+
# the script source (raising fails the evaluation — an import that 404s or
|
|
46
|
+
# has a non-JS MIME type fails the module script, and with it the Run
|
|
47
|
+
# Service Worker job). nil on engines without a native module API
|
|
48
|
+
# (QuickJS) — the caller falls back to the classic-eval path.
|
|
49
|
+
def module_graph? = !@eval_module_graph.nil?
|
|
50
|
+
def eval_module_graph(src, url, fetch_import) = @eval_module_graph.call(src, url, fetch_import)
|
|
28
51
|
end
|
|
29
52
|
end
|
|
30
53
|
end
|
data/lib/capybara/simulated.rb
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'capybara'
|
|
4
|
+
# The rasteriser behind image decoding and the canvas surface — required HERE rather than lazily at
|
|
5
|
+
# each use, because a bundle without it does not merely lose canvas: an image's intrinsic size feeds
|
|
6
|
+
# LAYOUT, so pages with images would be laid out wrong while only warning. The gem is a hard
|
|
7
|
+
# dependency; what can still be missing is the libvips SYSTEM library it binds to, so name it.
|
|
8
|
+
begin
|
|
9
|
+
require 'vips'
|
|
10
|
+
rescue LoadError => e
|
|
11
|
+
raise LoadError, "capybara-simulated needs the libvips system library (Debian/Ubuntu " \
|
|
12
|
+
"`libvips42`, Homebrew `vips`, Gentoo `media-libs/vips`): #{e.message}"
|
|
13
|
+
end
|
|
4
14
|
require 'capybara/simulated/version'
|
|
5
15
|
require 'capybara/simulated/driver'
|
|
6
16
|
|
|
@@ -24,6 +34,10 @@ module Capybara
|
|
|
24
34
|
# set together. Read-and-cleared by the register_driver block.
|
|
25
35
|
class << self
|
|
26
36
|
attr_accessor :next_driver_viewport, :next_driver_user_agent
|
|
37
|
+
|
|
38
|
+
# Empty the process-wide HTTP cache (see `Driver#clear_http_cache`) — the
|
|
39
|
+
# module-level form for a hook that runs before any session exists.
|
|
40
|
+
def clear_http_cache = Browser.clear_http_cache
|
|
27
41
|
end
|
|
28
42
|
end
|
|
29
43
|
end
|