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.
@@ -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 >= 0.1.4 installs a near-heap-limit callback on every isolate,
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 (rusty >= 0.1.9, the gem's
156
- # floor). Used by the per-visit heap-pressure relief in `rebuild_ctx`.
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.eval("globalThis.__csimChildRealmIds && globalThis.__csimChildRealmIds.delete(#{id.to_i});") rescue nil
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.eval('__csim_installWorker();')
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` (rusty >= 0.1.10) name the looping JS
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.eval('__csim_installWorker();')
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.eval(RuntimeShared.snapshot_src) unless has_bridge
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
- realm.eval(<<~JS)
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.eval(<<~JS)
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.eval(<<~JS)
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.eval("(globalThis.__csimChildRealmIds || (globalThis.__csimChildRealmIds = new Set())).add(#{realm.id});")
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 = inline_src || @browser.rack_fetch_body(url_s)
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
- # Trailing `;undefined` suppresses the script's COMPLETION VALUE so
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.run
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
- # Trailing `;undefined` makes the script's COMPLETION VALUE undefined so
1319
- # `c.eval`'s return crosses the V8→Ruby boundary on the trivial
1320
- # marshalling fast path. Without it, a leading-lexical inline script
1321
- # ending in a jQuery-ish expression (`const cfg=…; $(…)`) returns a
1322
- # `ce.fn.init` (array-like, non-cloneable) here, which falls into the
1323
- # deep-copy filter slow-path pure waste, since the value
1324
- # is discarded (`nil` below). The `//# sourceURL` line is a comment and
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.eval("#{body}\n;undefined\n//# sourceURL=#{label.to_s.tr("\n", ' ')}")
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.eval(<<~JS)
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.eval("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
1373
- c.eval('__csim_installWorker();')
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.eval("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
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.eval(<<~JS)
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.eval(src.to_s); nil })
1451
- c.eval('__csim_installWorkerScope();')
1555
+ c.attach('__csim_workerImportEval', ->(src) { c.eval_void(src.to_s) })
1556
+ c.eval_void('__csim_installWorkerScope();')
1452
1557
  WorkerRuntime.new(
1453
- eval_fn: ->(s) { c.eval(s.to_s) },
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Simulated
5
- VERSION = '0.9.0'
5
+ VERSION = '0.10.0'
6
6
  end
7
7
  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 five callbacks. Worker thread doesn't care
9
- # which engine it's running on; it just calls `eval` / `call` /
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(eval_fn:, call_fn:, drain_microtasks:, drain_timers:, has_ready_timer:, dispose:)
14
- @eval = eval_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
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 eval(src) = @eval.call(src)
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
@@ -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