capybara-simulated 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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!
@@ -426,8 +437,15 @@ module Capybara
426
437
  # through its own navigation.
427
438
  def window_realm_meta = (@window_realm_meta ||= {})
428
439
 
440
+ # Is this realm a TOP-LEVEL browsing context? The main realm is, and so is an auxiliary
441
+ # window realm — `frame_realm_parents` records 0 for one because it has no parent FRAME,
442
+ # which is not the same as being nested in the main window. An ancestor walk (the focus
443
+ # chain, most of all) has to stop here rather than step into the opener.
444
+ def top_level_realm?(realm_id) = realm_id.to_i.zero? || window_realm_meta.key?(realm_id.to_i)
445
+
429
446
  def dispose_frame_realms
430
447
  @realm_module_handles&.clear
448
+ @module_sw_ctxs&.clear
431
449
  @frame_realm_depths&.clear
432
450
  @frame_realm_parents&.clear
433
451
  @window_realm_meta&.clear
@@ -443,14 +461,14 @@ module Capybara
443
461
  # / module state, exactly like the main page's per-visit rebuild. `parent_id`
444
462
  # keeps the new realm's `parent`/`top` wired to the owning realm. The
445
463
  # Browser then re-points the iframe element at the new id (`__csimRebindFrameRealm`).
446
- 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)
447
465
  # A re-navigated document discards its child browsing contexts, so dispose the old realm's
448
466
  # DESCENDANT frame realms too — not just old_id. The JS src-reassignment path gets this for
449
467
  # free (the old document's iframe elements go away → DOM-unregister disposes their realms);
450
468
  # the Ruby reload path (navigate_realm_self_get/_post) rebuilds without that DOM teardown, so
451
469
  # a descendant frame's realm would otherwise linger and its contentWindow stay live.
452
470
  dispose_frame_realm_tree(old_id)
453
- 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)
454
472
  end
455
473
 
456
474
  # Dispose a frame realm and every descendant frame realm (transitively), deepest first so a
@@ -471,7 +489,11 @@ module Capybara
471
489
  # Like reload_frame_realm, a re-navigated window discards its child browsing contexts — dispose
472
490
  # the descendant frame realms too, not just old_id, so a popup's iframes don't linger.
473
491
  dispose_frame_realm_tree(old_id)
474
- create_window_realm(url, body, content_type, opener_id: meta[:opener_id], window_name: meta[:window_name])
492
+ create_window_realm(
493
+ url, body, content_type,
494
+ opener_id: meta[:opener_id], window_name: meta[:window_name],
495
+ about_base: meta[:about_base], about_origin: meta[:about_origin]
496
+ )
475
497
  end
476
498
 
477
499
  # Tear down a single frame realm (e.g. a descendant frame destroyed when an
@@ -483,7 +505,13 @@ module Capybara
483
505
  @browser.revoke_realm_blobs(id) rescue nil
484
506
  # Drop it from the SW Client registry so matchAll stops returning a dead client.
485
507
  @browser.sw_unregister_client(id) rescue nil
508
+ # A dedicated worker is owned by the context that created it — discarding the context
509
+ # terminates it (and unregisters its own client record).
510
+ @browser.terminate_realm_workers(id) rescue nil
511
+ # If it held the focus chain, focus returns to the top-level browsing context.
512
+ @browser.note_realm_discarded(id) rescue nil
486
513
  @realm_module_handles&.delete(id)
514
+ @module_sw_ctxs&.delete(id)
487
515
  @frame_realm_depths&.delete(id)
488
516
  @frame_realm_parents&.delete(id)
489
517
  @window_realm_meta&.delete(id)
@@ -492,7 +520,7 @@ module Capybara
492
520
  # unregister path (its iframe element going away), but a WINDOW realm has no
493
521
  # element — this is its only eviction, and it covers the reload (dispose +
494
522
  # recreate) path too, where the old id would otherwise leak in the set.
495
- 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
496
524
  fr = frame_realms.delete(id)
497
525
  fr.dispose rescue nil if fr
498
526
  nil
@@ -583,7 +611,7 @@ module Capybara
583
611
  # contexts N -> 1). See `relieve_heap_pressure`.
584
612
  relieve_heap_pressure
585
613
  attach_host_fns(@ctx)
586
- @ctx.eval('__csim_installWorker();')
614
+ @ctx.eval_void('__csim_installWorker();')
587
615
  return @ctx
588
616
  rescue StandardError => e
589
617
  warn "[capybara-simulated] warm context reset failed, falling back to cold rebuild: #{e.class}: #{e.message}"
@@ -711,7 +739,7 @@ module Capybara
711
739
  # occasional JS-side infinite loop would otherwise stall the whole
712
740
  # run; the timeout converts the hang into a
713
741
  # `RustyRacer::ScriptTerminatedError` on that one example — whose
714
- # `#message` / `#js_backtrace` (rusty >= 0.1.10) name the looping JS
742
+ # `#message` / `#js_backtrace` name the looping JS
715
743
  # frame (function + source position), so an in-V8 hang is diagnosable
716
744
  # from the failure alone, no live debugger attach needed. The
717
745
  # terminate escalates through any nested frames (it is
@@ -733,7 +761,7 @@ module Capybara
733
761
  def build_ctx
734
762
  c = Ctx.new(snapshot: @snapshot || self.class.snapshot, timeout: CALL_TIMEOUT_MS)
735
763
  attach_host_fns(c)
736
- c.eval('__csim_installWorker();')
764
+ c.eval_void('__csim_installWorker();')
737
765
  c
738
766
  end
739
767
 
@@ -753,8 +781,8 @@ module Capybara
753
781
  # id (or nil on failure — then the bridge keeps its same-realm fallback).
754
782
  # The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
755
783
  def attach_frame_realm_loader(c)
756
- 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) {
757
- 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) }
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) }
758
786
  })
759
787
  # Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
760
788
  # the bridge calls this to tear down the superseded one so it doesn't
@@ -799,14 +827,14 @@ module Capybara
799
827
  # document — rebind realm-executing variants on top, then reseed per-realm JS.
800
828
  def seed_realm_bridge(realm)
801
829
  has_bridge = realm.eval("typeof __csimLoadDocument === 'function'")
802
- realm.eval(RuntimeShared.snapshot_src) unless has_bridge
830
+ realm.eval_void(RuntimeShared.snapshot_src) unless has_bridge
803
831
  attach_run_script_with_cache(realm)
804
832
  attach_realm_esm_entry(realm)
805
833
  reseed_realm_js(realm)
806
834
  realm
807
835
  end
808
836
 
809
- 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)
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)
810
838
  depth = (frame_realm_depths[parent_id] || 0) + 1
811
839
  if depth > MAX_FRAME_DEPTH
812
840
  @browser.log_console('warn', "iframe nesting depth #{depth} exceeds #{MAX_FRAME_DEPTH}; not building #{url}")
@@ -826,7 +854,15 @@ module Capybara
826
854
  # `top` propagates up the chain (the main realm's `top` is itself). A
827
855
  # nested frame thus reaches its TRUE parent, not unconditionally the
828
856
  # main frame. `parent_id` is an integer the marshaller carries verbatim.
829
- 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)
830
866
  if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
831
867
  var __parentWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(#{parent_id.to_i});
832
868
  if (__parentWin) {
@@ -854,10 +890,24 @@ module Capybara
854
890
  # HTML / control bytes survive (Ruby's String#inspect is NOT a faithful
855
891
  # JS string escaper — it mangles \a, \e, and binary bytes).
856
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
857
902
  # Set window.name from the container's `name` attribute BEFORE the document
858
903
  # loads, so a frame whose load handler reads window.name to identify itself
859
904
  # (declarative-shadow declarative-child-frame) sees it.
860
905
  realm.call('__csimSetWindowName', frame_name.to_s) unless frame_name.nil?
906
+ # The frame's viewport is its container's content box, measured by the parent realm. Seed it
907
+ # BEFORE the document loads so load-time `innerWidth` reads and `@media` evaluation see the
908
+ # frame's own size rather than the top window's. `nil` = an unrendered container, which is a
909
+ # 0x0 window — pass it through rather than skipping, or the frame keeps the top-level size.
910
+ realm.call('__csimFrameViewportChanged', frame_viewport)
861
911
  # Seed the frame's document origin (opaque/inherited) BEFORE the document
862
912
  # loads, so its load-time scripts read the right self.origin. nil → a
863
913
  # real-URL frame whose origin is its own location origin.
@@ -881,21 +931,31 @@ module Capybara
881
931
  # (sw_note_realm_controller); recording it BEFORE the load also lets a NESTED frame built during
882
932
  # this frame's load inherit the controller.
883
933
  opaque = opaque_frame_url?(url)
884
- # A sandboxed frame WITHOUT allow-same-origin has an OPAQUE origin (doc origin 'null') and is
885
- # cross-origin to its creator, so it does NOT inherit the creator's controller (srcdoc-iframe:
886
- # "sandboxed srcdoc should not inherit"). allow-same-origin restores the parent origin → inherits.
887
- inheritable = opaque && frame_doc_origin.to_s != 'null'
888
- ctrl = @browser.sw_client_controller_for(url.to_s)
889
- ctrl ||= @browser.sw_inherited_controller_for(parent_id) if inheritable
890
- if ctrl
891
- realm.call('__csim_swSetControllerDirect', *ctrl)
892
- # Only an OPAQUE (about:blank / srcdoc) frame is mirrored into the SW's clients.matchAll():
893
- # a real-URL frame's registration would fire during its SYNCHRONOUS SW nav-fetch (main thread
894
- # blocked on @sw_nav_outbox), perturbing worker timing — the http-src client model is deferred.
895
- @browser.sw_note_realm_controller(realm.id, ctrl)
896
- @browser.sw_register_client(realm.id, url.to_s, 'window', 'nested', ctrl[0]) if opaque
934
+ # A sandboxed frame WITHOUT allow-same-origin has an OPAQUE origin (doc origin 'null'), which
935
+ # is cross-origin to everything so it is NOT CONTROLLED AT ALL. It inherits no controller
936
+ # from its creator (srcdoc-iframe: "sandboxed srcdoc should not inherit"), its subresource
937
+ # fetches never reach the SW, and it is absent from `clients.matchAll()`. Excluding it from
938
+ # only the client registry would not hold: a controlled frame's first subresource fetch
939
+ # re-registers it lazily from the fetch event (js/src/workers.js clientFor). The SW does still
940
+ # answer its NAVIGATION request, because a CSP-header sandbox is knowable only FROM that
941
+ # response (sandboxed-iframe-fetch-event, "Service worker should NOT control the sandboxed
942
+ # page"). allow-same-origin restores the parent origin, and a real-URL frame carries no
943
+ # explicit origin at all (nil = its own location origin) both are controlled normally.
944
+ if frame_doc_origin.to_s != 'null'
945
+ ctrl = @browser.sw_client_controller_for(url.to_s)
946
+ ctrl ||= @browser.sw_inherited_controller_for(parent_id) if opaque
947
+ if ctrl
948
+ # Wiring the controller is what registers this frame as a client — the realm reports
949
+ # itself from installController (js/src/sw-client.js), before the document below loads.
950
+ realm.call('__csim_swSetControllerDirect', *ctrl)
951
+ @browser.sw_note_realm_controller(realm.id, ctrl)
952
+ end
897
953
  end
898
954
  realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
955
+ # This document now has a URL and any host-wired controller, so it can announce itself
956
+ # as a service-worker client — an UNCONTROLLED context is still a client of its origin
957
+ # and must show up in `matchAll({includeUncontrolled: true})`.
958
+ realm.call('__csim_swReportClient') rescue nil
899
959
  # A `javascript:` URL frame: the initial empty document is now loaded and
900
960
  # parent/top are wired, so evaluate the URL's script in the realm (global
901
961
  # scope). Per HTML, only a STRING result navigates the frame to a new
@@ -929,6 +989,7 @@ module Capybara
929
989
  # including any module handles its scripts compiled before the throw.
930
990
  if realm
931
991
  @realm_module_handles&.delete(realm.id)
992
+ @module_sw_ctxs&.delete(realm.id)
932
993
  realm.dispose rescue nil
933
994
  end
934
995
  nil
@@ -944,7 +1005,7 @@ module Capybara
944
1005
  # a native `__csimFrameWindowProxyFor`, so `popup.document` is a real
945
1006
  # same-isolate Document (cross-window adoptNode works). The single isolate also
946
1007
  # makes a same-origin window far cheaper than today's isolate-per-window.
947
- def create_window_realm(url, body, content_type, opener_id: nil, window_name: nil, doc_origin: nil, location_origin: nil)
1008
+ def create_window_realm(url, body, content_type, opener_id: nil, window_name: nil, doc_origin: nil, location_origin: nil, about_base: nil, about_origin: nil)
948
1009
  realm = seed_realm_bridge(ctx.create_context)
949
1010
  # Mark it a top-level window realm so its location setter routes to
950
1011
  # __csimWindowRealmNavigate (reload THIS realm) rather than the frame-nav or
@@ -953,7 +1014,7 @@ module Capybara
953
1014
  # `window.closed` (flag-backed) and `window.close()` (marks closed; the realm
954
1015
  # lingers inert until the Browser tears the isolate down — matching a real
955
1016
  # closed window whose proxy stays valid and reports closed === true).
956
- realm.eval(<<~JS)
1017
+ realm.eval_void(<<~JS)
957
1018
  globalThis.__csimIsWindowRealm = true;
958
1019
  globalThis.__csimWindowClosedFlag = false;
959
1020
  try {
@@ -969,14 +1030,28 @@ module Capybara
969
1030
  # guard is on nil, not on 0 (0 is falsy but real here). Assigning globalThis.opener
970
1031
  # routes through the bridge's opener setter (stores the override the getter returns).
971
1032
  unless opener_id.nil?
972
- realm.eval(<<~JS)
1033
+ realm.eval_void(<<~JS)
973
1034
  if (typeof globalThis.__csimFrameWindowProxyFor === 'function') {
974
1035
  var __op = globalThis.__csimFrameWindowProxyFor(#{opener_id.to_i});
975
1036
  if (__op) globalThis.opener = __op;
976
1037
  }
977
1038
  JS
978
1039
  end
979
- realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
1040
+ # `window.open()` with no URL opens about:blank — that IS the new document's URL, and a
1041
+ # realm built from the snapshot would otherwise keep reporting the OPENER's (making the
1042
+ # popup a phantom duplicate of its opener everywhere a document URL is read, the service-
1043
+ # worker client set included). Its ORIGIN and its BASE url are inherited from the opener,
1044
+ # exactly as an empty <iframe>'s are (create_frame_realm's frame_about_base): the origin
1045
+ # so the popup isn't cross-origin to the window that opened it, the base so relative
1046
+ # resolution inside it still targets the opener's document.
1047
+ if url.to_s.empty?
1048
+ realm.call('__csimUpdateLocation', 'about:blank')
1049
+ realm.call('__csimSetAboutBaseURL', about_base.to_s) unless about_base.to_s.empty?
1050
+ doc_origin ||= about_origin unless about_origin.to_s.empty?
1051
+ location_origin ||= about_origin unless about_origin.to_s.empty?
1052
+ else
1053
+ realm.call('__csimUpdateLocation', url.to_s)
1054
+ end
980
1055
  realm.call('__csimSetWindowName', window_name.to_s) unless window_name.nil?
981
1056
  realm.call('__csimSetDocumentOrigin', doc_origin.to_s) unless doc_origin.nil?
982
1057
  realm.call('__csimSetLocationOrigin', location_origin.to_s) unless location_origin.nil?
@@ -991,12 +1066,14 @@ module Capybara
991
1066
  # Register with the opener (main) realm's child-realm set so `drainChildRealms`
992
1067
  # steps THIS realm's event loop too — otherwise its queued tasks (e.g. a
993
1068
  # BroadcastChannel delivery from a blob document) never fire.
994
- ctx.eval("(globalThis.__csimChildRealmIds || (globalThis.__csimChildRealmIds = new Set())).add(#{realm.id});")
1069
+ ctx.eval_void("(globalThis.__csimChildRealmIds || (globalThis.__csimChildRealmIds = new Set())).add(#{realm.id});")
995
1070
  # Remember the window's opener / name so a self-navigation (reload_window_realm
996
1071
  # builds a FRESH realm) can carry them across — a real popup keeps window.opener
997
1072
  # and window.name through its own navigation.
998
- window_realm_meta[realm.id] = {opener_id: opener_id, window_name: window_name}
1073
+ window_realm_meta[realm.id] = {opener_id: opener_id, window_name: window_name, about_base: about_base, about_origin: about_origin}
999
1074
  realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
1075
+ # As in create_frame_realm: an auxiliary window is a client of its origin too.
1076
+ realm.call('__csim_swReportClient') rescue nil
1000
1077
  realm.call('__csimFireWindowLoad') rescue nil
1001
1078
  realm.id
1002
1079
  rescue StandardError => e
@@ -1019,9 +1096,19 @@ module Capybara
1019
1096
  # by default, or a frame realm + its realm-local cache (Module handles are
1020
1097
  # context-bound; sharing the main cache would link a frame's imports
1021
1098
  # against main-context modules).
1022
- 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)
1023
1100
  target ||= ctx
1024
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)
1025
1112
  m = native_module_for(url, inline_src, target, handles)
1026
1113
  return nil unless m
1027
1114
  begin
@@ -1062,7 +1149,27 @@ module Capybara
1062
1149
  def native_module_for(url, inline_src, target, handles)
1063
1150
  return handles[url] if handles.key?(url)
1064
1151
  url_s = url.to_s
1065
- 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)
1066
1173
  return handles[url] = nil unless src
1067
1174
  body = module_body(url_s, src)
1068
1175
  # No-cd warm path: once this isolate has compiled a URL, its in-memory
@@ -1118,8 +1225,8 @@ module Capybara
1118
1225
  # realm-correctness as static `<script type=module>` via
1119
1226
  # `attach_realm_esm_entry`.
1120
1227
  def attach_native_module_loader(c)
1121
- c.attach('__csim_evalEsmEntry', ->(url, inline) {
1122
- 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)) }
1123
1230
  nil
1124
1231
  })
1125
1232
  c.dynamic_import_resolver = ->(specifier, referrer, initiating) {
@@ -1145,11 +1252,43 @@ module Capybara
1145
1252
  (@realm_module_handles ||= {})[realm_id] ||= {}
1146
1253
  end
1147
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
+
1148
1287
  # Frame-document `<script type=module>` entry, bound to the realm.
1149
1288
  def attach_realm_esm_entry(realm)
1150
- realm.attach('__csim_evalEsmEntry', ->(url, inline) {
1289
+ realm.attach('__csim_evalEsmEntry', ->(url, inline, *sw) {
1151
1290
  RuntimeShared.safe_call {
1152
- 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))
1153
1292
  }
1154
1293
  nil
1155
1294
  })
@@ -1193,15 +1332,7 @@ module Capybara
1193
1332
  # pay the rendezvous round-trip.
1194
1333
  c.attach('__csim_runScriptCached', ->(label, body) {
1195
1334
  RuntimeShared.safe_call {
1196
- # Trailing `;undefined` suppresses the script's COMPLETION VALUE so
1197
- # `script.run`'s return crosses the V8→Ruby boundary on the trivial
1198
- # marshalling fast path. Without it, a large inline script ending
1199
- # in a jQuery-ish expression returns a `ce.fn.init` (array-like,
1200
- # non-cloneable) that drags through the deep-copy filter —
1201
- # pure waste, since the value is discarded (`nil` below). The SHA keys
1202
- # the bytecode cache on the COMPILED source, so the suffix must be
1203
- # hashed and fed to `queue_warm` too (else cached_data is rejected).
1204
- src = "#{body}\n;undefined"
1335
+ src = body.to_s
1205
1336
  # No-cd warm path, mirroring `native_module_for`: once this
1206
1337
  # isolate has compiled a (label, bytesize), re-visits compile
1207
1338
  # straight against V8's source-keyed in-memory cache — skipping
@@ -1227,7 +1358,13 @@ module Capybara
1227
1358
  @compiled_script_keys[key] = true if key
1228
1359
  end
1229
1360
  begin
1230
- 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
1231
1368
  ensure
1232
1369
  script.dispose
1233
1370
  end
@@ -1269,17 +1406,15 @@ module Capybara
1269
1406
  # as the QuickJS runner does. Swallowing here would turn a
1270
1407
  # throwing leading-`const` inline script into a silent `load`.
1271
1408
  c.attach('__csim_runScriptEval', ->(label, body) {
1272
- # Trailing `;undefined` makes the script's COMPLETION VALUE undefined so
1273
- # `c.eval`'s return crosses the V8→Ruby boundary on the trivial
1274
- # marshalling fast path. Without it, a leading-lexical inline script
1275
- # ending in a jQuery-ish expression (`const cfg=…; $(…)`) returns a
1276
- # `ce.fn.init` (array-like, non-cloneable) here, which falls into the
1277
- # deep-copy filter slow-path pure waste, since the value
1278
- # is discarded (`nil` below). The `//# sourceURL` line is a comment and
1279
- # 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
1280
1416
  # side effect of eval, independent of the completion value.
1281
- c.eval("#{body}\n;undefined\n//# sourceURL=#{label.to_s.tr("\n", ' ')}")
1282
- nil
1417
+ c.eval_void("#{body}\n//# sourceURL=#{label.to_s.tr("\n", ' ')}")
1283
1418
  })
1284
1419
  install_run_script_dispatcher(c)
1285
1420
  end
@@ -1291,7 +1426,7 @@ module Capybara
1291
1426
  # run after the attaches it captures (`attach_run_script_with_cache`
1292
1427
  # installs it last for exactly that reason).
1293
1428
  def install_run_script_dispatcher(c)
1294
- c.eval(<<~JS)
1429
+ c.eval_void(<<~JS)
1295
1430
  (function () {
1296
1431
  const cached = globalThis.__csim_runScriptCached;
1297
1432
  const runEval = globalThis.__csim_runScriptEval;
@@ -1299,6 +1434,18 @@ module Capybara
1299
1434
  // Leading top-level lexical declaration, after optional BOM /
1300
1435
  // whitespace / line+block comments / a "use strict" prologue.
1301
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;
1302
1449
  // A "use strict" directive prologue. A classic <script> evaluates as a
1303
1450
  // top-level Script, where top-level `var` / `function` declarations
1304
1451
  // bind on the global object even in strict mode — but the JS-only
@@ -1310,7 +1457,7 @@ module Capybara
1310
1457
  const LEADS_USE_STRICT = /^[\\s\\uFEFF]*(?:(?:\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)\\s*)*["']use strict["']/;
1311
1458
  globalThis.__csim_runScript = function (label, body) {
1312
1459
  if (body.length >= threshold) return cached(label, body);
1313
- 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);
1314
1461
  (0, eval)(body + '\\n//# sourceURL=' + (label || 'csim-eval'));
1315
1462
  };
1316
1463
  })();
@@ -1323,8 +1470,8 @@ module Capybara
1323
1470
  # `__csim_installWorker()` post-snapshot init; the `__csim_runScript`
1324
1471
  # dispatcher comes from `attach_run_script_with_cache` (realm-bound).
1325
1472
  def reseed_realm_js(c)
1326
- c.eval("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
1327
- c.eval('__csim_installWorker();')
1473
+ c.eval_void("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
1474
+ c.eval_void('__csim_installWorker();')
1328
1475
  end
1329
1476
 
1330
1477
  # Class-level attach so Worker isolates (Ruby-thread-owned
@@ -1345,7 +1492,7 @@ module Capybara
1345
1492
  # microtask-checkpoint semantics. Alias it to the namespace's native
1346
1493
  # in-isolate checkpoint so callers pay ~sub-µs instead of an
1347
1494
  # attached-fn cross-thread round-trip.
1348
- c.eval("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
1495
+ c.eval_void("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
1349
1496
  # Register the bridge's recorder for V8's promise-reject notifications
1350
1497
  # — the channel that surfaces rejections NO handler ever sees
1351
1498
  # (fire-and-forget async functions, bare `Promise.reject`); the
@@ -1354,7 +1501,7 @@ module Capybara
1354
1501
  # unhandled-rejection.js leaves registration to us. Main realm only —
1355
1502
  # the recorder routes per-realm via `contextGlobal` itself, and a
1356
1503
  # frame-realm registration would dangle once that realm is disposed.
1357
- c.eval(<<~JS)
1504
+ c.eval_void(<<~JS)
1358
1505
  if (typeof globalThis.#{HOST_NAMESPACE_NAME}.setPromiseRejectHandler === 'function' &&
1359
1506
  typeof globalThis.__csimPromiseRejected === 'function') {
1360
1507
  globalThis.#{HOST_NAMESPACE_NAME}.setPromiseRejectHandler(globalThis.__csimPromiseRejected);
@@ -1375,7 +1522,14 @@ module Capybara
1375
1522
  # deliver_worker_messages): client.postMessage, clients.claim (set the client's controller),
1376
1523
  # and a controlled fetch's respondWith result. See run_worker for the closures.
1377
1524
  c.attach('__csim_swPostToClient', ->(client_id, data) { sw_hooks[:post_to_client]&.call(client_id, data); nil }) if sw_hooks[:post_to_client]
1525
+ c.attach('__csim_swFocusClient', ->(client_id) { sw_hooks[:focus_client]&.call(client_id); nil }) if sw_hooks[:focus_client]
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]
1378
1527
  c.attach('__csim_swClaim', -> { sw_hooks[:claim]&.call; nil }) if sw_hooks[:claim]
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]
1379
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]
1380
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]
1381
1535
  # Cross-isolate MessagePort channel signals (a worker/SW port endpoint + its outbound messages).
@@ -1398,15 +1552,56 @@ module Capybara
1398
1552
  # join the realm's shared global lexical environment where later code sees them.
1399
1553
  # `(0, eval)` would block-scope them to the eval and they'd vanish. `c.eval` is
1400
1554
  # the top-level-script path (same as the worker's own body eval).
1401
- c.attach('__csim_workerImportEval', ->(src) { c.eval(src.to_s); nil })
1402
- c.eval('__csim_installWorkerScope();')
1555
+ c.attach('__csim_workerImportEval', ->(src) { c.eval_void(src.to_s) })
1556
+ c.eval_void('__csim_installWorkerScope();')
1403
1557
  WorkerRuntime.new(
1404
- eval_fn: ->(s) { c.eval(s.to_s) },
1558
+ eval_void_fn: ->(s) { c.eval_void(s.to_s) },
1405
1559
  call_fn: ->(n, *a) { c.call(n.to_s, *a) },
1406
1560
  drain_microtasks: -> { c.perform_microtask_checkpoint },
1407
1561
  drain_timers: -> { c.call('__drainTimers', 50) },
1408
1562
  has_ready_timer: -> { !!c.call('__hasReadyTimer') },
1409
- 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
+ }
1410
1605
  )
1411
1606
  end
1412
1607
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Simulated
5
- VERSION = '0.8.0'
5
+ VERSION = '0.10.0'
6
6
  end
7
7
  end